diff --git a/apps/dapp/app/api/agents/compete/route.ts b/apps/dapp/app/api/agents/compete/route.ts
index ca867a3c..6971d8ed 100644
--- a/apps/dapp/app/api/agents/compete/route.ts
+++ b/apps/dapp/app/api/agents/compete/route.ts
@@ -23,6 +23,8 @@ import {
assertKnownTokens,
assertNativeReserve,
} from '../../../../lib/chains/prepare'
+import { limitPair } from '../../../../lib/chains/limit'
+import { restOption, restRoute } from '../../../../lib/agents/rest-option'
import { chainModule } from '../../../../lib/chains/registry'
import {
ChainActionRefused,
@@ -172,8 +174,18 @@ async function chainRaceFacts(
// The chain's market already leaves out Stellar's assets, prices, lending
// rates and anchors; see `buildMarketContext`.
const base = await buildMarketContextAsync(chain)
+ const pair = action.kind === 'swap' ? limitPair(action.tokenIn, action.tokenOut ?? '') : undefined
+ const baseToken = chainApi.tokens.find((t) => t.symbol.toUpperCase() === pair?.base)
+ const marketUsd =
+ pair === undefined || baseToken === undefined
+ ? undefined
+ : pair.selling
+ ? price
+ : await chainApi.priceUsd(baseToken).catch(() => undefined)
+ const rest = marketUsd === undefined ? undefined : restOption(chainApi, action, marketUsd)
const market: MarketContext = {
...base,
+ ...(rest !== undefined ? { rest } : {}),
prices: price !== undefined && tokenIn !== undefined ? { [tokenIn.symbol]: price } : {},
venues: chainApi.venues
.filter((v) => routes.some((r) => r.id === `${v.id}-1`))
@@ -519,19 +531,31 @@ export async function POST(request: Request): Promise {
const settleAndMeasure = (proposal: AgentProposalResult): AgentProposalResult => {
const own = (market.routes ?? []).find((r) => r.id === proposal.routeId)
return measured(
- chainFacts === undefined ? settlePlan(proposal, intent, book) : settleChainPlan(proposal),
+ chainFacts === undefined
+ ? settlePlan(proposal, intent, book)
+ : settleChainPlan(proposal, own !== undefined ? market.rest : undefined),
own?.quote as SwapQuote | ChainRouteQuote | undefined,
market.prices
)
}
+ // A plan that rests an order signs the order, not the swap it was measured against.
const sendProposal = (proposal: AgentProposalResult): void => {
const own = (market.routes ?? []).find((r) => r.id === proposal.routeId)
send({
type: 'competition:proposal',
competitionId,
proposal,
- ...(own !== undefined ? { route: own.quote } : {}),
+ ...(own !== undefined
+ ? {
+ route:
+ proposal.executionMode === 'rest' &&
+ market.rest !== undefined &&
+ proposal.restPriceUsd !== undefined
+ ? restRoute(own.quote as ChainRouteQuote, market.rest, proposal.restPriceUsd)
+ : own.quote,
+ }
+ : {}),
})
}
@@ -740,7 +764,11 @@ export async function POST(request: Request): Promise {
...(unmeasuredRoutes.length > 0 ? { unmeasuredRoutes } : {}),
}
- const scored = scoreProposals(proposals, { competitionId, plan })
+ const scored = scoreProposals(proposals, {
+ competitionId,
+ plan,
+ ...(chainFacts !== undefined ? { restsBelowFills: true } : {}),
+ })
// An agent that held off has no plan to score.
const heldOff = new Set(proposals.filter((p) => p.held === true).map((p) => p.agent))
const winner = pickWinner(scored)
diff --git a/apps/dapp/app/api/chain/[chain]/orders/route.ts b/apps/dapp/app/api/chain/[chain]/orders/route.ts
new file mode 100644
index 00000000..1c95965b
--- /dev/null
+++ b/apps/dapp/app/api/chain/[chain]/orders/route.ts
@@ -0,0 +1,97 @@
+import { NextResponse } from 'next/server'
+import { z } from 'zod'
+
+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'
+import { isWalletAddress } from '../../../../../lib/wallet/address'
+
+export const runtime = 'nodejs'
+export const dynamic = 'force-dynamic'
+export const maxDuration = 20
+
+const Body = z.object({
+ account: z.string().min(1).max(128),
+ orderId: z.string().min(1).max(200),
+ action: z.enum(['cancel', 'collect']),
+})
+
+/** The account's resting limit orders on this chain, read when asked and never kept. */
+export async function GET(
+ request: Request,
+ { params }: { params: Promise<{ chain: string }> }
+): Promise {
+ 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 (!isWalletAddress(account)) {
+ return NextResponse.json({ error: 'invalid_account' }, { status: 400 })
+ }
+ if (chainApi.limitBook === undefined) {
+ return NextResponse.json({ orders: [] }, { headers: { 'cache-control': 'no-store' } })
+ }
+ try {
+ const orders = await chainApi.limitBook.openOrders(
+ account,
+ AbortSignal.timeout(BALANCE_DEADLINE_MS)
+ )
+ return NextResponse.json({ orders }, { headers: { 'cache-control': 'no-store' } })
+ } catch (e) {
+ if (e instanceof ChainActionRefused) {
+ return NextResponse.json({ error: 'invalid_account' }, { status: 400 })
+ }
+ reportError('chain/orders', e, { chain: chainApi.chain })
+ return NextResponse.json({ error: 'orders_unavailable' }, { status: 503 })
+ }
+}
+
+/** Builds the cancel or the collect of a resting order for the wallet to sign. */
+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 })
+ }
+
+ if (chainApi.limitBook === undefined) {
+ return NextResponse.json(
+ { error: 'There are no limit orders to change on this chain. Nothing was signed or sent.' },
+ { status: 422 }
+ )
+ }
+ try {
+ const { account, orderId, action } = parsed.data
+ const prepared = await chainApi.limitBook.prepareExit(account, orderId, action)
+ const venue = chainApi.venues.find((v) => v.id === prepared.venueId)
+ if (venue === undefined) {
+ throw new ChainActionRefused(
+ 'That order is not one I can build safely. Nothing was signed or sent.'
+ )
+ }
+ venue.assertSafe(prepared.signing, account, prepared.context)
+ return NextResponse.json({ prepared })
+ } catch (e) {
+ if (e instanceof ChainActionRefused) {
+ return NextResponse.json({ error: e.message }, { status: 422 })
+ }
+ reportError('chain/orders', 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]/prepare/route.ts b/apps/dapp/app/api/chain/[chain]/prepare/route.ts
index 8020930f..28f8015c 100644
--- a/apps/dapp/app/api/chain/[chain]/prepare/route.ts
+++ b/apps/dapp/app/api/chain/[chain]/prepare/route.ts
@@ -19,6 +19,7 @@ const Body = z.object({
'supply',
'stake',
'send',
+ 'limit',
'withdraw',
'unstake',
'borrow',
@@ -37,6 +38,7 @@ const Body = z.object({
tokenOut: z.string().min(1).max(16).optional(),
venue: z.string().min(1).max(40).optional(),
recipient: z.string().min(1).max(128).optional(),
+ limitPrice: z.string().min(1).max(40).optional(),
durationDays: z
.string()
.regex(/^[1-9]\d{0,4}$/)
@@ -81,6 +83,7 @@ export async function POST(
tokenOut,
venue,
recipient,
+ limitPrice,
durationDays,
dueDate,
invoiceId,
@@ -95,6 +98,7 @@ export async function POST(
...(tokenOut !== undefined ? { tokenOut } : {}),
...(venue !== undefined ? { venue } : {}),
...(recipient !== undefined ? { recipient } : {}),
+ ...(limitPrice !== undefined ? { limitPrice } : {}),
...(durationDays !== undefined ? { durationDays } : {}),
...(dueDate !== undefined ? { dueDate } : {}),
...(invoiceId !== undefined ? { invoiceId } : {}),
diff --git a/apps/dapp/app/api/chain/[chain]/submit/route.ts b/apps/dapp/app/api/chain/[chain]/submit/route.ts
index 5d5e868d..b39d46b8 100644
--- a/apps/dapp/app/api/chain/[chain]/submit/route.ts
+++ b/apps/dapp/app/api/chain/[chain]/submit/route.ts
@@ -27,6 +27,7 @@ const Signed = z.discriminatedUnion('family', [
}),
// 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) }),
+ z.object({ family: z.literal('evm-typed'), signature: z.string().min(1).max(200) }),
])
const Body = z.object({
@@ -59,14 +60,16 @@ export async function POST(
const venue = chainApi.venues.find((v) => v.id === venueId)
if (venue === undefined) return NextResponse.json({ error: 'unknown_venue' }, { status: 404 })
- // By family, since Avalanche and Monad are both EVM.
- if (signed.family !== CHAIN_DESCRIPTORS[chainApi.chain].family) {
+ // By family, since Avalanche and Monad are both EVM. A typed message is EVM's too.
+ const family = signed.family === 'evm-typed' ? 'evm' : signed.family
+ if (family !== CHAIN_DESCRIPTORS[chainApi.chain].family) {
return NextResponse.json({ error: 'wrong_chain' }, { status: 400 })
}
// 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') {
+ // ask the chain what was sent instead. A typed message carries only its signature, so its venue
+ // rebuilds what was signed and checks it before posting anything.
+ if (signed.family === 'solana' || signed.family === 'sui') {
const payload: SigningPayload =
signed.family === 'solana'
? { family: 'solana', transaction: signed.signedTransaction }
diff --git a/apps/dapp/components/intents/chain-action-confirm.tsx b/apps/dapp/components/intents/chain-action-confirm.tsx
index 3399f26c..b3991caa 100644
--- a/apps/dapp/components/intents/chain-action-confirm.tsx
+++ b/apps/dapp/components/intents/chain-action-confirm.tsx
@@ -5,6 +5,8 @@ import { ExternalLink, Loader2 } from 'lucide-react'
import type { ChainAction } from '../../hooks/use-chain-action'
import { stepErrorMessage } from '../../lib/agents/chain-race'
+import { limitSettledNote } from '../../lib/chains/orders-view'
+import { useChain } from '../../providers/chain-provider'
import type { PreparedAction } from '../../lib/chains/types'
function cardLine({ kind, pay, venueName }: PreparedAction): string {
@@ -45,6 +47,7 @@ export function ChainActionConfirm({
typedStep?: string | undefined
}): JSX.Element {
const { phase, prepared, hash, explorerUrl, earlier } = action
+ const { slug } = useChain()
const error = stepErrorMessage(action.error, sequenceSettled, typedStep)
const busy = phase === 'signing' || phase === 'submitting'
@@ -137,7 +140,29 @@ export function ChainActionConfirm({
) : null}
- {phase === 'settled' ? (
+ {phase === 'settled' && action.canContinue ? (
+
+
+ Done. One more signature finishes this.
+
+
+
+
+
+ ) : null}
+
+ {phase === 'settled' &&
+ !action.canContinue &&
+ prepared.kind === 'limit' &&
+ action.source?.kind === 'request' ? (
+
+ {limitSettledNote(slug)}
+
+ ) : null}
+
+ {phase === 'settled' && action.canContinue ? null : phase === 'settled' ? (
Sent
{explorerUrl !== undefined ? (
diff --git a/apps/dapp/components/intents/chain-orders-card.tsx b/apps/dapp/components/intents/chain-orders-card.tsx
new file mode 100644
index 00000000..5a453a46
--- /dev/null
+++ b/apps/dapp/components/intents/chain-orders-card.tsx
@@ -0,0 +1,130 @@
+'use client'
+
+import { Button, Card } from '@intent/ui'
+import { Loader2 } from 'lucide-react'
+import { useCallback, useEffect, useState } from 'react'
+
+import type { ChainAction } from '../../hooks/use-chain-action'
+import { useWallet } from '../../hooks/use-wallet'
+import type { RestingOrder } from '../../lib/chains/limit'
+import { exitsFor, freshExit, GONE, orderLine, type OrderExit } from '../../lib/chains/orders-view'
+import { useChain } from '../../providers/chain-provider'
+
+const UNAVAILABLE = 'Your orders could not be read just now. Nothing was signed or sent.'
+
+/**
+ * The account's resting limit orders, read from the venue when this opens and again after an exit
+ * settles, and never kept. Each press reads the order once more before anything is built.
+ */
+export function ChainOrdersCard({
+ action,
+ asked,
+}: {
+ action: ChainAction
+ asked: string
+}): JSX.Element {
+ const { slug } = useChain()
+ const { address } = useWallet()
+ const [orders, setOrders] = useState(null)
+ const [error, setError] = useState(undefined)
+ const [note, setNote] = useState(undefined)
+ const settledHash = action.phase === 'settled' ? action.hash : undefined
+
+ const read = useCallback(async (): Promise => {
+ if (address === undefined) return undefined
+ try {
+ const res = await fetch(`/api/chain/${slug}/orders?account=${encodeURIComponent(address)}`)
+ const body = (await res.json()) as { orders?: RestingOrder[] }
+ return res.ok && Array.isArray(body.orders) ? body.orders : undefined
+ } catch {
+ return undefined
+ }
+ }, [address, slug])
+
+ useEffect(() => {
+ let current = true
+ void read().then((found) => {
+ if (!current) return
+ if (found === undefined) {
+ setError(address === undefined ? 'Connect a wallet to see your orders.' : UNAVAILABLE)
+ return
+ }
+ setError(undefined)
+ setOrders(found)
+ })
+ return () => {
+ current = false
+ }
+ }, [read, address, settledHash])
+
+ async function press(listed: RestingOrder, wanted: OrderExit): Promise {
+ setNote(undefined)
+ const fresh = await read()
+ if (fresh === undefined) {
+ setNote(UNAVAILABLE)
+ return
+ }
+ setOrders(fresh)
+ const now = fresh.find((o) => o.id === listed.id)
+ const exit = freshExit(listed, now, wanted)
+ if (exit === 'gone') {
+ setNote(GONE)
+ return
+ }
+ action.prepareExit(listed.id, exit, asked)
+ }
+
+ const busy =
+ action.phase === 'building' || action.phase === 'signing' || action.phase === 'submitting'
+
+ return (
+
+ Your limit orders
+ {orders === null && error === undefined ? (
+
+
+ Reading your orders
+
+ ) : null}
+ {error !== undefined ? (
+
+ {error}
+
+ ) : null}
+ {orders !== null && orders.length === 0 ? (
+ You have no resting limit orders here.
+ ) : null}
+ {orders !== null && orders.length > 0 ? (
+
+ ) : null}
+ {note !== undefined ? (
+
+ {note}
+
+ ) : null}
+
+ )
+}
diff --git a/apps/dapp/components/intents/competition-panel.tsx b/apps/dapp/components/intents/competition-panel.tsx
index 22e3b9e3..652b2c63 100644
--- a/apps/dapp/components/intents/competition-panel.tsx
+++ b/apps/dapp/components/intents/competition-panel.tsx
@@ -8,11 +8,13 @@ import { Crown, Loader2, Radio, TriangleAlert, WifiOff } from 'lucide-react'
import type { CompetitionState } from '../../lib/agents/competition'
import {
panelErrorMessage,
+ chainRecommendationLine,
recommendationLine,
singleRouteNote,
unansweredLine,
} from '../../lib/agents/chain-race'
import { describePlan } from '../../lib/agents/competition'
+import { restAlternativeLine } from '../../lib/agents/rest-option'
import {
ALL_HELD_LINE,
allHeld,
@@ -235,6 +237,10 @@ export function CompetitionPanel({
agents.map((a) => ({ name: a.name, failed: proposals[a.key]?.failed }))
)
+ const restAlternative = restAlternativeLine(
+ Object.values(proposals).filter((p) => p.failed === undefined)
+ )
+
const routeNote =
chainName !== undefined ? singleRouteNote(state.routeCount, chainName) : undefined
@@ -485,12 +491,23 @@ export function CompetitionPanel({
is none — the route's real output decides, and a tie is broken
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 && p.held !== true).length} agents agree: ${describePlan(proposals[winner] ?? {})}. Pick any to execute.`
- : recommendationLine(proposals[winner]?.name, proposals[winner]?.figures)}
+ {chainName !== undefined
+ ? chainRecommendationLine(proposals[winner] ?? {}, {
+ agreeing: unanimous
+ ? Object.values(proposals).filter(
+ (p) => p.failed === undefined && p.held !== true
+ ).length
+ : 0,
+ })
+ : unanimous
+ ? `All ${Object.values(proposals).filter((p) => p.failed === undefined && p.held !== true).length} agents agree: ${describePlan(proposals[winner] ?? {})}. Pick any to execute.`
+ : recommendationLine(proposals[winner]?.name, proposals[winner]?.figures)}
) : null}
+ {decided && chainName !== undefined && restAlternative !== undefined ? (
+ {restAlternative}
+ ) : null}
{decided && everyoneHeld ? {ALL_HELD_LINE}
: null}
{decided && unanswered !== undefined ? {unanswered}
: null}
diff --git a/apps/dapp/components/intents/intent-chat.tsx b/apps/dapp/components/intents/intent-chat.tsx
index e98827ae..b3e38e76 100644
--- a/apps/dapp/components/intents/intent-chat.tsx
+++ b/apps/dapp/components/intents/intent-chat.tsx
@@ -185,6 +185,7 @@ import { parseStellarExit, whereReply, type StellarExit } from '../../lib/lend/p
import type { BlendPositions } from '../../lib/lend/position'
import { suppliableReserve } from '../../lib/lend/reserves'
import { lendingVenueName } from '../../lib/lend/venues'
+import { ChainOrdersCard } from './chain-orders-card'
import { IntentConfirm, type UnderstoodIntent } from './intent-confirm'
import { resolveAsset, toBaseUnits } from '../../lib/swap/assets'
import { toPriceFraction } from '../../lib/swap/limit-price'
@@ -393,6 +394,8 @@ export function IntentChat({
// to catch a misread instruction is before the agents spend a minute arguing
// about a trade that was never the one asked for.
const [pending, setPending] = useState(null)
+ /** The message that asked for the resting limit orders, while their list is on screen. */
+ const [ordersShown, setOrdersShown] = useState(null)
const [parsing, setParsing] = useState(false)
// The conversation around the open intent. `thread` holds the turns that came
// before it, kept in view so the chat reads as one; `notes` are things the
@@ -2201,6 +2204,7 @@ export function IntentChat({
return
}
+ setOrdersShown(null)
const cashoutRoute = routeCashout(parseCashout(text), slug, isMainnet())
const capabilities = chainCapabilities(slug)
const chainRead =
@@ -2275,10 +2279,21 @@ export function IntentChat({
startCashout(chainRead.route, text)
return
}
- if (chainRead.kind === 'action') {
+ if (chainRead.kind === 'action' || chainRead.kind === 'limit') {
startChainAction(chainRead.action, text)
return
}
+ if (chainRead.kind === 'orders') {
+ setClarifying(null)
+ setMessage(text)
+ resetTurnState()
+ setParsed(null)
+ setTurnId(null)
+ clearChainRace()
+ chainAction.reset()
+ setOrdersShown(text)
+ return
+ }
// A step 2 card still unsigned goes too, and the run's button is what brings it back.
chainAction.reset()
clearChainRace()
@@ -2494,7 +2509,12 @@ export function IntentChat({
// sentence goes, and only what the person typed themselves can be signed.
if (chainCaps !== undefined) {
const shown = turns[0]?.content ?? text
- const route = chainRouteFromReading(answer.read, answer.instruction, said)
+ const route = chainRouteFromReading(
+ answer.read,
+ answer.instruction,
+ said,
+ slug as ChainWithActions
+ )
if (route !== null && 'sequence' in route) {
leaveSequence()
startChainSequence(route.sequence, shown)
@@ -3562,6 +3582,9 @@ export function IntentChat({
/>
) : null}
+ {ordersShown !== null ? (
+
+ ) : null}
{cashout.session !== null ? (
diff --git a/apps/dapp/hooks/use-chain-action.ts b/apps/dapp/hooks/use-chain-action.ts
index e234e78f..ebcaa388 100644
--- a/apps/dapp/hooks/use-chain-action.ts
+++ b/apps/dapp/hooks/use-chain-action.ts
@@ -9,6 +9,7 @@ import {
afterSubmitting,
attemptNotice,
attemptStorage,
+ continueSource,
earlierAttempt,
latchBeforeSign,
noteSubmitAnswer,
@@ -17,6 +18,7 @@ import {
quoteExpiredBeforeSigning,
recordAttempt,
submitSigned,
+ type ActionSource,
type AttemptNotice,
type SignAttempt,
} from '../lib/chains/signing-flow'
@@ -49,6 +51,8 @@ export interface ChainActionState {
raceId?: string
/** This same action was already started recently, perhaps before a reload. */
earlier?: AttemptNotice
+ /** What this card was built from, so a setup step can build the next one again. */
+ source?: ActionSource
}
export interface ChainAction extends ChainActionState {
@@ -68,6 +72,12 @@ export interface ChainAction extends ChainActionState {
prepare: (request: ChainActionRequest, asked: string, raceId?: string) => void
/** Shows a message in the card without building anything, for what can't be read. */
refuse: (message: string, asked: string) => void
+ /** Builds the cancel or the collect of a resting order. */
+ prepareExit: (orderId: string, action: 'cancel' | 'collect', asked: string) => void
+ /** After a setup step settled: builds what it was a step toward. Nothing while building. */
+ continueSetup: () => void
+ /** A setup step has settled and the next card is one press away. */
+ canContinue: boolean
confirm: () => void
reset: () => void
}
@@ -103,8 +113,9 @@ export function useChainAction(): ChainAction {
setState({ phase: 'failed', asked, error: message })
}, [])
- const prepare = useCallback(
- (request: ChainActionRequest, asked: string, raceId?: string) => {
+ const build = useCallback(
+ (source: ActionSource, continuing: boolean, raceId?: string) => {
+ const asked = source.asked
if (address === undefined) {
setState({ phase: 'failed', asked, error: 'Connect a wallet first.' })
return
@@ -112,14 +123,27 @@ export function useChainAction(): ChainAction {
const account = address
generation.current += 1
const mine = generation.current
+ const exit = source.kind === 'exit' ? `${source.action}:${source.orderId}` : undefined
async function run(): Promise {
- setState({ phase: 'building', asked, building: request.kind })
- const res = await fetch(`/api/chain/${slug}/prepare`, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ account, request }),
+ setState({
+ phase: 'building',
+ asked,
+ building: source.kind === 'request' ? source.request.kind : 'swap',
+ source,
})
+ const res =
+ source.kind === 'request'
+ ? await fetch(`/api/chain/${slug}/prepare`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ account, request: source.request }),
+ })
+ : await fetch(`/api/chain/${slug}/orders`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ account, orderId: source.orderId, action: source.action }),
+ })
const body = (await res.json()) as { prepared?: PreparedAction; error?: string }
if (generation.current !== mine) return
if (body.prepared === undefined) {
@@ -130,17 +154,22 @@ export function useChainAction(): ChainAction {
})
return
}
- const earlier = earlierAttempt(attemptStorage(), {
- chain: slug,
- account,
- tokenIn: body.prepared.pay.symbol,
- amount: body.prepared.pay.amount,
- })
+ // A step after a setup step is the same request again, which is no second attempt.
+ const earlier = continuing
+ ? undefined
+ : earlierAttempt(attemptStorage(), {
+ chain: slug,
+ account,
+ tokenIn: body.prepared.pay.symbol,
+ amount: body.prepared.pay.amount,
+ ...(exit !== undefined ? { exit } : {}),
+ })
setState({
phase: 'review',
asked,
prepared: body.prepared,
account,
+ source,
...(raceId !== undefined ? { raceId } : {}),
...(earlier !== undefined ? { earlier: attemptNotice(earlier) } : {}),
})
@@ -154,6 +183,24 @@ export function useChainAction(): ChainAction {
[address, slug]
)
+ const prepare = useCallback(
+ (request: ChainActionRequest, asked: string, raceId?: string) =>
+ build({ kind: 'request', request, asked }, false, raceId),
+ [build]
+ )
+
+ const prepareExit = useCallback(
+ (orderId: string, action: 'cancel' | 'collect', asked: string) =>
+ build({ kind: 'exit', orderId, action, asked }, false),
+ [build]
+ )
+
+ const next = continueSource(state.phase, state.prepared, state.source)
+ const raceOfCard = state.raceId
+ const continueSetup = useCallback(() => {
+ if (next !== undefined) build(next, true, raceOfCard)
+ }, [next, build, raceOfCard])
+
const confirm = useCallback(() => {
const prepared = state.prepared
const builtFor = state.account
@@ -188,6 +235,9 @@ export function useChainAction(): ChainAction {
tokenIn: prepared.pay.symbol,
amount: prepared.pay.amount,
at: Date.now(),
+ ...(state.source?.kind === 'exit'
+ ? { exit: `${state.source.action}:${state.source.orderId}` }
+ : {}),
}
recordAttempt(storage, attempt)
// A wallet that sends for itself may send and then report an error, so
@@ -258,7 +308,19 @@ export function useChainAction(): ChainAction {
"This could not be confirmed. Check your wallet's activity before trying again, in case it went through.",
}))
})
- }, [state.prepared, state.account, state.raceId, adapter, slug])
+ }, [state.prepared, state.account, state.raceId, state.source, adapter, slug])
- return { ...state, signed, requested, release, prepare, refuse, confirm, reset }
+ return {
+ ...state,
+ signed,
+ requested,
+ release,
+ prepare,
+ prepareExit,
+ continueSetup,
+ canContinue: next !== undefined,
+ refuse,
+ confirm,
+ reset,
+ }
}
diff --git a/apps/dapp/hooks/use-competition.ts b/apps/dapp/hooks/use-competition.ts
index 62dc6075..9c3f49d3 100644
--- a/apps/dapp/hooks/use-competition.ts
+++ b/apps/dapp/hooks/use-competition.ts
@@ -12,7 +12,7 @@ import type {
import { DECIDE_AT, RACE_DURATION, WINDOW_SECONDS, revealFloor } from '../lib/agents/competition'
import { decodeFrame } from '../lib/agents/events'
import type { ChainActionRequest } from '../lib/chains/types'
-import { routeFigures, routeSource } from '../lib/agents/chain-race'
+import { routeFigures, routeRestCost, routeSource } from '../lib/agents/chain-race'
import { setAttributedRace } from '../lib/api/network-fetch'
import { planDecision, planFinalReveal, planReveal, planTalkReveal } from '../lib/agents/pacing'
import type { ThreadItem } from '../lib/agents/talk-lines'
@@ -371,6 +371,9 @@ export function useCompetition(
reasoning: frame.proposal.reasoning,
...(source !== undefined ? { source } : {}),
...(figures !== undefined ? { figures } : {}),
+ ...(routeRestCost(frame.route) !== undefined
+ ? { restCost: routeRestCost(frame.route) as { amount: string; symbol: string } }
+ : {}),
sliceCount: frame.proposal.sliceCount,
horizonMinutes: frame.proposal.horizonMinutes,
executionMode: frame.proposal.executionMode,
diff --git a/apps/dapp/lib/__tests__/agents-rest-option.test.ts b/apps/dapp/lib/__tests__/agents-rest-option.test.ts
new file mode 100644
index 00000000..e21a4bfa
--- /dev/null
+++ b/apps/dapp/lib/__tests__/agents-rest-option.test.ts
@@ -0,0 +1,258 @@
+import { describe, expect, it } from 'vitest'
+
+import type { AgentProposalResult, ProposalRequest } from '../agents/brain'
+import { buildMessages } from '../agents/brains/openai-compatible'
+import { SYSTEM_PROMPT, systemPromptFor } from '../agents/brief'
+import {
+ chainFigures,
+ chainRecommendationLine,
+ chainRequestFor,
+ reviewChainRoute,
+ routeSource,
+} from '../agents/chain-race'
+import type { ChainRouteQuote } from '../agents/chain-routes'
+import { restAlternativeLine, restBand, restOption, restRoute } from '../agents/rest-option'
+import { scoreProposals, unanimousChoice } from '../agents/scoring'
+import { settleChainPlan } from '../agents/settle-chain-plan'
+import type { ChainActionRequest, ChainModule } from '../chains/types'
+
+const sale: ChainActionRequest = { kind: 'swap', amount: '2', tokenIn: 'SOL', tokenOut: 'USDC' }
+const buy: ChainActionRequest = { kind: 'swap', amount: '100', tokenIn: 'USDC', tokenOut: 'SOL' }
+
+function chainWith(venues: object[]): ChainModule {
+ return { tokens: [], venues } as unknown as ChainModule
+}
+const limitVenue = {
+ id: 'kamino-limit',
+ name: 'Kamino',
+ kinds: ['limit'],
+ limitCost: { amount: '0.003', symbol: 'SOL' },
+}
+const solana = chainWith([{ id: 'jup', name: 'Jupiter', kinds: ['swap'] }, limitVenue])
+
+describe('restOption', () => {
+ it('offers the limit venue for a sale against a stablecoin', () => {
+ expect(restOption(solana, sale, 100)).toEqual({
+ venueId: 'kamino-limit',
+ venueName: 'Kamino',
+ marketUsd: 100,
+ selling: true,
+ cost: { amount: '0.003', symbol: 'SOL' },
+ notes: [],
+ })
+ })
+
+ it('knows a purchase is not a sale', () => {
+ expect(restOption(solana, buy, 100)?.selling).toBe(false)
+ })
+
+ it('is nothing on a chain with no limit venue', () => {
+ expect(
+ restOption(chainWith([{ id: 'x', name: 'X', kinds: ['swap'] }]), sale, 100)
+ ).toBeUndefined()
+ })
+
+ it('is nothing for a supply, a pair with no stablecoin, or a named venue', () => {
+ expect(restOption(solana, { kind: 'supply', amount: '1', tokenIn: 'USDC' }, 1)).toBeUndefined()
+ expect(restOption(solana, { ...sale, tokenOut: 'JUP' }, 100)).toBeUndefined()
+ expect(restOption(solana, { ...sale, venue: 'jupiter' }, 100)).toBeUndefined()
+ })
+
+ it('is nothing without a market price', () => {
+ expect(restOption(solana, sale, 0)).toBeUndefined()
+ })
+})
+
+describe('restBand', () => {
+ it('sits 0.1% to 5% above the market for a sale', () => {
+ const b = restBand(100, true)
+ expect(b.least).toBeCloseTo(100.1)
+ expect(b.most).toBeCloseTo(105)
+ })
+ it('sits 0.1% to 5% below the market for a purchase', () => {
+ const b = restBand(100, false)
+ expect(b.least).toBeCloseTo(99.9)
+ expect(b.most).toBeCloseTo(95)
+ })
+})
+
+function proposal(over: Partial = {}): AgentProposalResult {
+ return {
+ agent: 'a',
+ routeId: 'jup-1',
+ reasoning: 'r',
+ projectedAvgPriceUsd: 100,
+ projectedSlippagePct: 0.5,
+ venues: ['jup'],
+ sliceCount: 1,
+ confidence: 0.5,
+ horizonMinutes: 5,
+ executionMode: 'rest',
+ restPriceUsd: 102,
+ ...over,
+ } as AgentProposalResult
+}
+
+describe('settleChainPlan with a rest option', () => {
+ const rest = restOption(solana, sale, 100)!
+ it('keeps a sale resting 2% above the market', () => {
+ const s = settleChainPlan(proposal(), rest)
+ expect(s.executionMode).toBe('rest')
+ expect(s.restPriceUsd).toBe(102)
+ expect(s.sliceCount).toBe(1)
+ })
+ it.each([
+ ['below the market', 99],
+ ['6% above', 106],
+ ])('fills a sale %s', (_n, price) => {
+ const s = settleChainPlan(proposal({ restPriceUsd: price }), rest)
+ expect(s.executionMode).toBe('fill')
+ expect(s.restPriceUsd).toBeUndefined()
+ })
+ it('fills a purchase above the market and keeps one below it', () => {
+ const b = restOption(solana, buy, 100)!
+ expect(settleChainPlan(proposal({ restPriceUsd: 101 }), b).executionMode).toBe('fill')
+ expect(settleChainPlan(proposal({ restPriceUsd: 98 }), b).executionMode).toBe('rest')
+ })
+ it('always fills a split', () => {
+ const s = settleChainPlan(proposal({ executionMode: 'split', splitPct: 50 }), rest)
+ expect(s.executionMode).toBe('fill')
+ expect(s.splitPct).toBeUndefined()
+ })
+ it('always fills without a rest argument', () => {
+ expect(settleChainPlan(proposal()).executionMode).toBe('fill')
+ })
+})
+
+describe('scoring a rest proposal', () => {
+ it('never outscores a measured fill, and never makes agents agree', () => {
+ const fill = proposal({ agent: 'f', executionMode: 'fill', projectedSlippagePct: 2 })
+ const rest = proposal({ agent: 'r', projectedSlippagePct: 0 })
+ const scored = scoreProposals([rest, fill], { competitionId: 'c', restsBelowFills: true })
+ expect(scored[0]?.agent).toBe('f')
+ expect(unanimousChoice(scored)).toBe(false)
+ })
+})
+
+describe('the recommendation off Stellar', () => {
+ it('states one strategy when agents agree', () => {
+ expect(
+ chainRecommendationLine({ executionMode: 'fill', source: 'Jupiter' }, { agreeing: 2 })
+ ).toBe('Recommended: fill now via Jupiter. All 2 agents agree. You pick who executes.')
+ })
+ it('states one when there is a single route and no agreement to report', () => {
+ expect(chainRecommendationLine({ executionMode: 'fill', source: 'Jupiter' })).toBe(
+ 'Recommended: fill now via Jupiter. You pick who executes.'
+ )
+ })
+ it('names a resting price', () => {
+ expect(
+ chainRecommendationLine({ executionMode: 'rest', restPriceUsd: 98, source: 'Kamino' })
+ ).toBe('Recommended: rest at $98.00 via Kamino. You pick who executes.')
+ })
+})
+
+describe('restAlternativeLine', () => {
+ it('says what resting could cost', () => {
+ expect(
+ restAlternativeLine([
+ {
+ executionMode: 'rest',
+ restPriceUsd: 98,
+ source: 'Kamino',
+ restCost: { amount: '0.003', symbol: 'SOL' },
+ },
+ { executionMode: 'fill', source: 'Jupiter' },
+ ])
+ ).toBe(
+ '1 agent suggests resting at $98.00 through Kamino: it could get more, costs about 0.003 SOL, and may never fill. You choose.'
+ )
+ })
+ it('is nothing when nobody rests or everybody does', () => {
+ expect(restAlternativeLine([{ executionMode: 'fill' }])).toBeUndefined()
+ expect(restAlternativeLine([{ executionMode: 'rest', restPriceUsd: 98 }])).toBeUndefined()
+ })
+})
+
+describe('pressing Review on a rest proposal', () => {
+ const fillQuote = {
+ kind: 'chain',
+ request: sale,
+ venueId: 'jup',
+ venueName: 'Jupiter',
+ pay: { amount: '2', symbol: 'SOL' },
+ } as ChainRouteQuote
+ const rest = restOption(solana, sale, 100)!
+ const route = restRoute(fillQuote, rest, 102)
+
+ it('prepares a limit request with the amount and tokens the person gave', () => {
+ expect(chainRequestFor(route, sale)).toEqual({
+ kind: 'limit',
+ amount: '2',
+ tokenIn: 'SOL',
+ tokenOut: 'USDC',
+ venue: 'kamino-limit',
+ limitPrice: '102',
+ })
+ })
+ it('still refuses a route whose amount differs', () => {
+ expect(chainRequestFor(route, { ...sale, amount: '3' })).toBeUndefined()
+ })
+ it('is shown as resting at that venue', () => {
+ expect(routeSource(route)).toBe('Kamino')
+ expect(chainFigures(route)).toEqual({ headline: 'Rests at $102.00' })
+ })
+ it('goes through review as a limit request', () => {
+ let seen: ChainActionRequest | undefined
+ reviewChainRoute({
+ route,
+ asked: sale,
+ text: 't',
+ phase: 'idle',
+ prepare: (r) => {
+ seen = r
+ },
+ refuse: () => undefined,
+ })
+ expect(seen?.kind).toBe('limit')
+ })
+})
+
+describe('the brief', () => {
+ it('names resting only when asked to', () => {
+ expect(systemPromptFor('solana')).not.toMatch(/\brest\b/i)
+ expect(systemPromptFor('solana', true)).toMatch(/restPriceUsd/)
+ expect(systemPromptFor('solana', true)).toMatch(/small/i)
+ expect(systemPromptFor('stellar', true)).toBe(SYSTEM_PROMPT)
+ })
+ it('lists the option in the market context only when there is one', () => {
+ const rest = restOption(solana, sale, 100)!
+ const req = (withRest: boolean): ProposalRequest =>
+ ({
+ agent: 'a',
+ chain: 'solana',
+ intent: {
+ outcome: 'x',
+ escrowUsd: 200,
+ referencePriceUsd: 100,
+ targetPriceUsd: 0,
+ input: { type: 'market_sell', tokenIn: 'SOL', tokenOut: 'USDC' },
+ },
+ market: {
+ asOf: 'now',
+ prices: {},
+ venues: [],
+ volatilityHint: 'normal',
+ gasHint: 'normal',
+ ...(withRest ? { rest } : {}),
+ },
+ }) as unknown as ProposalRequest
+ const text = (r: ProposalRequest): string =>
+ buildMessages(r)
+ .map((m) => m.content)
+ .join('\n')
+ expect(text(req(true))).toMatch(/Kamino/)
+ expect(text(req(true))).toMatch(/0\.003 SOL/)
+ expect(text(req(false))).not.toMatch(/Resting option/)
+ })
+})
diff --git a/apps/dapp/lib/__tests__/beefy.test.ts b/apps/dapp/lib/__tests__/beefy.test.ts
index f5d2a923..6076be81 100644
--- a/apps/dapp/lib/__tests__/beefy.test.ts
+++ b/apps/dapp/lib/__tests__/beefy.test.ts
@@ -612,6 +612,8 @@ function fakeClient(
return {
tokenBalance: async () => BigInt(0),
nativeBalance: async () => BigInt(0),
+ allowance: async () => BigInt(0),
+ code: async () => undefined,
waitForReceipt: async () =>
({
status: 'success',
diff --git a/apps/dapp/lib/__tests__/chain-balances.test.ts b/apps/dapp/lib/__tests__/chain-balances.test.ts
index 46085f87..fa5c0eb3 100644
--- a/apps/dapp/lib/__tests__/chain-balances.test.ts
+++ b/apps/dapp/lib/__tests__/chain-balances.test.ts
@@ -385,7 +385,9 @@ describe('EVM balances', () => {
read.push(token)
return BigInt(2500000)
},
+ allowance: async () => BigInt(0),
nativeBalance: async () => BigInt('1500000000000000000'),
+ code: async () => '0x',
waitForReceipt: async () => {
throw new Error('unused')
},
diff --git a/apps/dapp/lib/__tests__/chain-limit-reading.test.ts b/apps/dapp/lib/__tests__/chain-limit-reading.test.ts
new file mode 100644
index 00000000..76383170
--- /dev/null
+++ b/apps/dapp/lib/__tests__/chain-limit-reading.test.ts
@@ -0,0 +1,107 @@
+import { describe, expect, it } from 'vitest'
+
+import { chainRouteFromReading, type ReadSwap } from '../chains/parse-sequence'
+import { limitSaid } from '../chains/parse-limit'
+import { LIMIT_NOT_HERE } from '../chains/prepare'
+
+const read = (
+ over: Partial = {}
+): ReadSwap & { action: string } => ({
+ action: 'swap',
+ tokenIn: 'SOL',
+ tokenOut: 'USDC',
+ amountUsd: 0,
+ amountStated: true,
+ followOn: null,
+ style: 'limit',
+ ...over,
+})
+
+const said = ['I want to get rid of 1 sol once it reaches 110']
+
+describe('the model reading a limit order', () => {
+ it('runs as a limit when the instruction is the person own words', () => {
+ const route = chainRouteFromReading(read(), 'sell 1 SOL at $110', said)
+ expect(route).toMatchObject({ action: { kind: 'limit', amount: '1', limitPrice: '110' } })
+ })
+
+ it('refuses a price the person did not say', () => {
+ const route = chainRouteFromReading(read(), 'sell 1 SOL at $120', said)
+ expect(route).toHaveProperty('refusal')
+ expect((route as { refusal: string }).refusal).toMatch(/Nothing was signed or sent\.$/)
+ })
+
+ it('never turns a limit reading it cannot parse into a market swap', () => {
+ const route = chainRouteFromReading(read(), 'swap 1 SOL to USDC', said)
+ expect(route).not.toHaveProperty('action')
+ expect((route as { refusal: string }).refusal).toContain('sell 1 SOL at $110')
+ })
+
+ it('reads a limit before a swap that takes its price for a venue', () => {
+ const route = chainRouteFromReading(
+ read({ style: 'market', tokenIn: 'USDC', tokenOut: 'SOL' }),
+ 'swap 50 USDC to SOL at 90',
+ ['swap 50 USDC to SOL at 90']
+ )
+ expect(route).toMatchObject({ action: { kind: 'limit', limitPrice: '90' } })
+ })
+
+ it('asks for the spend on a purchase that names only what it gets', () => {
+ const route = chainRouteFromReading(read(), 'buy 2 SOL at 90', ['buy 2 SOL at 90'])
+ expect((route as { refusal: string }).refusal).toContain(
+ 'Say how much you will spend, for example "buy SOL with 180 USDC at $90".'
+ )
+ })
+
+ it('asks for a count when the amount is dollars', () => {
+ const route = chainRouteFromReading(read(), 'sell $5 of SOL at $110', [
+ 'sell $5 of SOL at $110',
+ ])
+ expect((route as { refusal: string }).refusal).toContain(
+ 'Say how much SOL, for example "sell 0.05 SOL at $110".'
+ )
+ })
+
+ it('refuses a limit on a chain without one', () => {
+ const route = chainRouteFromReading(
+ read({ tokenIn: 'MON' }),
+ 'sell 1 MON at $5',
+ ['sell 1 MON at $5'],
+ 'monad'
+ )
+ expect(route).toEqual({ refusal: LIMIT_NOT_HERE.monad })
+ })
+
+ it('leaves a plain swap alone', () => {
+ const route = chainRouteFromReading(read({ style: 'market' }), 'swap 1 SOL to USDC', [
+ 'swap 1 SOL to USDC',
+ ])
+ expect(route).toMatchObject({ action: { kind: 'swap' } })
+ })
+})
+
+describe('a sale the person did not word as one', () => {
+ const sale = {
+ kind: 'limit' as const,
+ amount: '1',
+ tokenIn: 'SOL',
+ tokenOut: 'USDC',
+ limitPrice: '110',
+ }
+ it.each([
+ 'get me 1 sol once it reaches 110',
+ 'I want 1 SOL when it hits 110',
+ 'pick up 1 sol at 110',
+ 'grab 1 sol at $110',
+ 'sell my usdc to get 1 sol at 110',
+ 'I want to sell 1 SOL at 110',
+ ])('is not taken for "%s"', (words) => {
+ expect(limitSaid(sale, [words])).toBe(false)
+ })
+ it.each(['get rid of 1 sol once it reaches 110', 'sell 1 SOL at $110', 'dump 1 sol at 110'])(
+ 'is taken for "%s"',
+ (words) => {
+ expect(limitSaid(sale, [words])).toBe(true)
+ }
+ )
+})
diff --git a/apps/dapp/lib/__tests__/chain-limit-routes.test.ts b/apps/dapp/lib/__tests__/chain-limit-routes.test.ts
new file mode 100644
index 00000000..446b9538
--- /dev/null
+++ b/apps/dapp/lib/__tests__/chain-limit-routes.test.ts
@@ -0,0 +1,358 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+import { assertNativeReserve, LIMIT_NOT_HERE, prepareChainAction } from '../chains/prepare'
+import { ChainActionRefused, type ChainModule, type ChainVenue } from '../chains/types'
+
+const SOL = { symbol: 'SOL', id: 'so11', decimals: 9 }
+const USDC = { symbol: 'USDC', id: 'usdc', decimals: 6 }
+const MON = { symbol: 'MON', id: 'mon', decimals: 18 }
+
+const venuePrepare = vi.fn()
+const priceUsd = vi.fn()
+const balances = vi.fn()
+const openOrders = vi.fn()
+const prepareExit = vi.fn()
+
+function venue(extra: Partial = {}): ChainVenue {
+ return {
+ id: 'book',
+ name: 'Book',
+ kinds: ['limit'],
+ prepare: venuePrepare,
+ assertSafe: () => undefined,
+ ...extra,
+ }
+}
+
+function module(over: Partial = {}): ChainModule {
+ return {
+ chain: 'solana',
+ tokens: [SOL, USDC],
+ venues: [venue()],
+ defaultVenue: { limit: 'book' },
+ priceUsd,
+ submit: vi.fn(),
+ balances,
+ ...over,
+ }
+}
+
+const limit = (over: Record = {}) => ({
+ kind: 'limit' as const,
+ amount: '1',
+ tokenIn: 'SOL',
+ tokenOut: 'USDC',
+ limitPrice: '110',
+ ...over,
+})
+
+let current: ChainModule | undefined
+vi.mock('../chains/registry', () => ({
+ chainModule: (chain: string) => (chain === 'solana' ? current : undefined),
+}))
+
+import { GET, POST } from '../../app/api/chain/[chain]/orders/route'
+
+const ACCOUNT = '11111111111111111111111111111111'
+
+beforeEach(() => {
+ venuePrepare.mockReset()
+ priceUsd.mockReset().mockResolvedValue(100)
+ balances.mockReset().mockRejectedValue(new Error('no read'))
+ openOrders.mockReset()
+ prepareExit.mockReset()
+ current = module()
+})
+
+describe('limit orders are checked before any venue is called', () => {
+ it('refuses a chain with no trustworthy venue, in its own words', async () => {
+ const mon = module({ chain: 'monad', tokens: [MON, USDC] })
+ await expect(prepareChainAction(mon, limit({ tokenIn: 'MON' }), ACCOUNT)).rejects.toThrow(
+ LIMIT_NOT_HERE.monad!
+ )
+ expect(LIMIT_NOT_HERE.monad).toMatch(/^Monad has no limit order venue I would trust/)
+ expect(venuePrepare).not.toHaveBeenCalled()
+ })
+
+ it('refuses a price that would fill at once', async () => {
+ await expect(
+ prepareChainAction(module(), limit({ limitPrice: '90' }), ACCOUNT)
+ ).rejects.toThrow(/would sell below/)
+ expect(venuePrepare).not.toHaveBeenCalled()
+ })
+
+ it('refuses when there is no live price', async () => {
+ for (const bad of [undefined, Number.NaN, 0]) {
+ priceUsd.mockResolvedValue(bad)
+ await expect(prepareChainAction(module(), limit(), ACCOUNT)).rejects.toThrow(
+ "I have no live price for SOL, so I can't check that price. Nothing was signed or sent."
+ )
+ }
+ expect(venuePrepare).not.toHaveBeenCalled()
+ })
+
+ it('refuses a pair with no stablecoin', async () => {
+ const m = module({ tokens: [SOL, USDC, { symbol: 'JUP', id: 'jup', decimals: 6 }] })
+ await expect(prepareChainAction(m, limit({ tokenOut: 'JUP' }), ACCOUNT)).rejects.toThrow(
+ 'Limit orders here need USDC or USDT on one side. Nothing was signed or sent.'
+ )
+ expect(venuePrepare).not.toHaveBeenCalled()
+ })
+
+ it('refuses when no venue does limit orders', async () => {
+ const m = module({ venues: [], defaultVenue: {} })
+ await expect(prepareChainAction(m, limit(), ACCOUNT)).rejects.toThrow(ChainActionRefused)
+ expect(venuePrepare).not.toHaveBeenCalled()
+ })
+
+ it('hands a good order to the venue', async () => {
+ venuePrepare.mockResolvedValue({
+ signing: { family: 'solana', transaction: 'x' },
+ })
+ await prepareChainAction(module(), limit(), ACCOUNT)
+ expect(venuePrepare).toHaveBeenCalledTimes(1)
+ })
+})
+
+describe('a limit order needs what it gives up in the wallet', () => {
+ const row = (symbol: string, id: string, amount: string, native = false) => ({
+ symbol,
+ id,
+ amount,
+ decimals: id === 'so11' ? 9 : 6,
+ native,
+ listed: true,
+ })
+ const buy = limit({ amount: '2', tokenIn: 'USDC', tokenOut: 'SOL', limitPrice: '90' })
+ const built = { signing: { family: 'solana', transaction: 'x' } }
+
+ it('refuses less than the order needs, before any venue is called', async () => {
+ balances.mockResolvedValue({
+ tokens: [row('SOL', 'so11', '5', true), row('USDC', 'usdc', '1.5')],
+ complete: true,
+ })
+ await expect(prepareChainAction(module(), buy, ACCOUNT)).rejects.toThrow(
+ 'You hold 1.5 USDC, and this order needs 2. Nothing was signed or sent.'
+ )
+ expect(venuePrepare).not.toHaveBeenCalled()
+ })
+
+ it('holds a sale of the native coin to the same check', async () => {
+ balances.mockResolvedValue({ tokens: [row('SOL', 'so11', '0.5', true)], complete: true })
+ await expect(prepareChainAction(module(), limit(), ACCOUNT)).rejects.toThrow(
+ 'You hold 0.5 SOL, and this order needs 1. Nothing was signed or sent.'
+ )
+ expect(venuePrepare).not.toHaveBeenCalled()
+ })
+
+ it('matches on the token id, so a copied symbol counts for nothing', async () => {
+ balances.mockResolvedValue({
+ tokens: [row('SOL', 'so11', '5', true), row('USDC', 'not-usdc', '100')],
+ complete: true,
+ })
+ await expect(prepareChainAction(module(), buy, ACCOUNT)).rejects.toThrow(
+ 'You hold 0 USDC, and this order needs 2. Nothing was signed or sent.'
+ )
+ expect(venuePrepare).not.toHaveBeenCalled()
+ })
+
+ it('passes exactly what the order needs', async () => {
+ venuePrepare.mockResolvedValue(built)
+ balances.mockResolvedValue({
+ tokens: [row('SOL', 'so11', '5', true), row('USDC', 'usdc', '2')],
+ complete: true,
+ })
+ await prepareChainAction(module(), buy, ACCOUNT)
+ expect(venuePrepare).toHaveBeenCalledTimes(1)
+ })
+
+ it.each<[string, () => void]>([
+ ['a read that fails', () => balances.mockRejectedValue(new Error('no read'))],
+ [
+ 'a list that may be missing the token',
+ () =>
+ balances.mockResolvedValue({ tokens: [row('SOL', 'so11', '5', true)], complete: false }),
+ ],
+ [
+ 'a token that could not be read',
+ () =>
+ balances.mockResolvedValue({
+ tokens: [row('SOL', 'so11', '5', true)],
+ complete: true,
+ unread: ['USDC'],
+ }),
+ ],
+ ])('does not refuse on %s', async (_name, arrange) => {
+ arrange()
+ venuePrepare.mockResolvedValue(built)
+ await prepareChainAction(module(), buy, ACCOUNT)
+ expect(venuePrepare).toHaveBeenCalledTimes(1)
+ })
+
+ it('reads nothing for an order refused on its price', async () => {
+ await expect(
+ prepareChainAction(module(), limit({ limitPrice: '90' }), ACCOUNT)
+ ).rejects.toThrow(/would sell below/)
+ expect(balances).not.toHaveBeenCalled()
+ })
+})
+
+describe('the native reserve counts a venue setup cost', () => {
+ const held = {
+ tokens: [
+ { symbol: 'SOL', id: 'so11', amount: '0.0148', decimals: 9, native: true, listed: true },
+ ],
+ complete: true,
+ }
+ const costly = () =>
+ module({ venues: [venue({ limitCost: { amount: '0.00684', symbol: 'SOL' } })] })
+
+ it('refuses a limit that leaves too little for the cost, and names it', async () => {
+ balances.mockResolvedValue(held)
+ await expect(
+ assertNativeReserve(costly(), limit({ amount: '0.005' }), ACCOUNT)
+ ).rejects.toThrow(/0\.00684 SOL/)
+ })
+
+ it('does not add the cost to a request that is not a limit', async () => {
+ balances.mockResolvedValue(held)
+ const swap = { kind: 'swap' as const, amount: '0.005', tokenIn: 'SOL', tokenOut: 'USDC' }
+ await expect(assertNativeReserve(costly(), swap, ACCOUNT)).resolves.toBeUndefined()
+ })
+
+ it('counts the cost when the token sold is not the native coin', async () => {
+ balances.mockResolvedValue({
+ ...held,
+ tokens: [{ ...held.tokens[0]!, amount: '0.001' }],
+ })
+ const buy = limit({ amount: '100', tokenIn: 'USDC', tokenOut: 'SOL', limitPrice: '90' })
+ await expect(assertNativeReserve(costly(), buy, ACCOUNT)).rejects.toThrow(/0\.00684 SOL/)
+ await expect(assertNativeReserve(module(), buy, ACCOUNT)).resolves.toBeUndefined()
+ })
+
+ it('allows the same limit where the venue has no cost', async () => {
+ balances.mockResolvedValue(held)
+ await expect(
+ assertNativeReserve(module(), limit({ amount: '0.005' }), ACCOUNT)
+ ).resolves.toBeUndefined()
+ })
+})
+
+const get = (chain: string, account = ACCOUNT) =>
+ GET(new Request(`http://localhost/api/chain/${chain}/orders?account=${account}`), {
+ params: Promise.resolve({ chain }),
+ })
+
+const post = (chain: string, body: unknown) =>
+ POST(
+ new Request(`http://localhost/api/chain/${chain}/orders`, {
+ method: 'POST',
+ body: JSON.stringify(body),
+ }),
+ { params: Promise.resolve({ chain }) }
+ )
+
+describe('GET orders', () => {
+ it('404s an unknown chain', async () => {
+ expect((await get('dogecoin')).status).toBe(404)
+ })
+
+ it('answers an empty list from a module with no limit book', async () => {
+ const res = await get('solana')
+ expect(res.status).toBe(200)
+ await expect(res.json()).resolves.toEqual({ orders: [] })
+ })
+
+ it('returns the book', async () => {
+ openOrders.mockResolvedValue([{ id: '1' }])
+ current = module({ limitBook: { openOrders, prepareExit } })
+ await expect((await get('solana')).json()).resolves.toEqual({ orders: [{ id: '1' }] })
+ })
+
+ it('400s a refused account and 503s a failed read', async () => {
+ current = module({ limitBook: { openOrders, prepareExit } })
+ openOrders.mockRejectedValue(new ChainActionRefused('bad'))
+ expect((await get('solana')).status).toBe(400)
+ openOrders.mockRejectedValue(new Error('down'))
+ const res = await get('solana')
+ expect(res.status).toBe(503)
+ await expect(res.json()).resolves.toEqual({ error: 'orders_unavailable' })
+ })
+})
+
+describe('POST orders', () => {
+ const body = { account: ACCOUNT, orderId: 'o1', action: 'cancel' }
+
+ it('404s an unknown chain and 400s a bad account or action', async () => {
+ expect((await post('dogecoin', body)).status).toBe(404)
+ expect((await post('solana', { ...body, account: '0xnope' })).status).toBe(400)
+ expect((await post('solana', { ...body, action: 'close' })).status).toBe(400)
+ })
+
+ it('422s a module with no limit book', async () => {
+ const res = await post('solana', body)
+ expect(res.status).toBe(422)
+ expect(((await res.json()) as { error: string }).error).toContain('Nothing was signed or sent.')
+ })
+
+ it('prepares a cancel or a collect', async () => {
+ current = module({ limitBook: { openOrders, prepareExit } })
+ prepareExit.mockResolvedValue({
+ venueId: 'book',
+ signing: { family: 'solana', transaction: 'x' },
+ })
+ const res = await post('solana', { ...body, action: 'collect' })
+ expect(res.status).toBe(200)
+ await expect(res.json()).resolves.toMatchObject({ prepared: { venueId: 'book' } })
+ expect(prepareExit).toHaveBeenCalledWith(ACCOUNT, 'o1', 'collect')
+ })
+
+ it('runs the venue assertSafe on what an exit builds', async () => {
+ const assertSafe = vi.fn()
+ current = module({
+ venues: [venue({ assertSafe })],
+ limitBook: { openOrders, prepareExit },
+ })
+ prepareExit.mockResolvedValue({
+ venueId: 'book',
+ signing: { family: 'solana', transaction: 'x' },
+ context: { a: 'b' },
+ })
+ expect((await post('solana', body)).status).toBe(200)
+ expect(assertSafe).toHaveBeenCalledWith({ family: 'solana', transaction: 'x' }, ACCOUNT, {
+ a: 'b',
+ })
+ })
+
+ it('refuses an exit the venue finds unsafe, or from no known venue', async () => {
+ const unsafe = () => {
+ throw new ChainActionRefused('pays a third party. Nothing was signed or sent.')
+ }
+ current = module({
+ venues: [venue({ assertSafe: unsafe })],
+ limitBook: { openOrders, prepareExit },
+ })
+ prepareExit.mockResolvedValue({
+ venueId: 'book',
+ signing: { family: 'solana', transaction: 'x' },
+ })
+ expect((await post('solana', body)).status).toBe(422)
+ prepareExit.mockResolvedValue({
+ venueId: 'ghost',
+ signing: { family: 'solana', transaction: 'x' },
+ })
+ expect((await post('solana', body)).status).toBe(422)
+ })
+
+ it('422s a refusal from the venue', async () => {
+ current = module({ limitBook: { openOrders, prepareExit } })
+ prepareExit.mockRejectedValue(
+ new ChainActionRefused('Already filled. Nothing was signed or sent.')
+ )
+ const res = await post('solana', body)
+ expect(res.status).toBe(422)
+ await expect(res.json()).resolves.toEqual({
+ error: 'Already filled. Nothing was signed or sent.',
+ })
+ })
+})
diff --git a/apps/dapp/lib/__tests__/chain-limit.test.ts b/apps/dapp/lib/__tests__/chain-limit.test.ts
new file mode 100644
index 00000000..45bc599c
--- /dev/null
+++ b/apps/dapp/lib/__tests__/chain-limit.test.ts
@@ -0,0 +1,158 @@
+import { describe, expect, it } from 'vitest'
+import { assertRestingPrice, limitOutput, limitPair } from '../chains/limit'
+import { limitSaid, parseChainLimit } from '../chains/parse-limit'
+
+describe('limitOutput', () => {
+ it('sells at the stated price', () => {
+ expect(limitOutput('1', '110', true, 9, 6)).toBe(BigInt('110000000'))
+ })
+ it('buys at the stated price, rounding so the price is never worse', () => {
+ expect(limitOutput('50', '90', false, 6, 9)).toBe(BigInt('555555556'))
+ })
+ it('keeps precision a float would lose', () => {
+ expect(limitOutput('0.1', '0.3', true, 9, 6)).toBe(BigInt('30000'))
+ })
+})
+
+describe('limitPair', () => {
+ it('reads the side from the token given up', () => {
+ expect(limitPair('SOL', 'USDC')).toEqual({ base: 'SOL', quote: 'USDC', selling: true })
+ expect(limitPair('USDC', 'SOL')).toEqual({ base: 'SOL', quote: 'USDC', selling: false })
+ })
+ it('has no pair without exactly one stablecoin', () => {
+ expect(limitPair('SOL', 'JUP')).toBeUndefined()
+ expect(limitPair('USDC', 'USDT')).toBeUndefined()
+ })
+ it('reads case and chain names', () => {
+ expect(limitPair('sol', 'usdt')).toEqual({ base: 'SOL', quote: 'USDT', selling: true })
+ expect(limitPair('solana', 'USDC')).toEqual({ base: 'SOL', quote: 'USDC', selling: true })
+ })
+})
+
+describe('assertRestingPrice', () => {
+ it('refuses a sale below the market, which would fill at once at a loss', () => {
+ expect(() => assertRestingPrice('90', 100, true, 'SOL')).toThrow(/below/)
+ })
+ it('refuses a purchase above the market', () => {
+ expect(() => assertRestingPrice('110', 100, false, 'SOL')).toThrow(/above/)
+ })
+ it.each([
+ ['1.10', 100, true],
+ ['1000', 100, true],
+ ['1', 100, false],
+ ])('refuses %s against a market of %s as a likely typo', (price, market, selling) => {
+ expect(() => assertRestingPrice(price, market, selling, 'SOL')).toThrow(/Nothing was signed/)
+ })
+ it('takes a price on the right side, within half the market either way', () => {
+ expect(() => assertRestingPrice('110', 100, true, 'SOL')).not.toThrow()
+ expect(() => assertRestingPrice('149', 100, true, 'SOL')).not.toThrow()
+ expect(() => assertRestingPrice('90', 100, false, 'SOL')).not.toThrow()
+ expect(() => assertRestingPrice('51', 100, false, 'SOL')).not.toThrow()
+ })
+ it('refuses a decimal comma', () => {
+ expect(() => assertRestingPrice('1,10', 100, true, 'SOL')).toThrow(/Nothing was signed/)
+ })
+})
+
+describe('parseChainLimit', () => {
+ it.each([
+ ['sell 1 SOL at $110', { amount: '1', tokenIn: 'SOL', tokenOut: 'USDC', limitPrice: '110' }],
+ [
+ 'sell 1 sol for usdt at 110',
+ { amount: '1', tokenIn: 'sol', tokenOut: 'usdt', limitPrice: '110' },
+ ],
+ [
+ 'limit sell 1 SOL at 110',
+ { amount: '1', tokenIn: 'SOL', tokenOut: 'USDC', limitPrice: '110' },
+ ],
+ [
+ 'swap 1 SOL to USDC at $110',
+ { amount: '1', tokenIn: 'SOL', tokenOut: 'USDC', limitPrice: '110' },
+ ],
+ [
+ 'swap 1 SOL to USDC when it hits 110.50',
+ { amount: '1', tokenIn: 'SOL', tokenOut: 'USDC', limitPrice: '110.50' },
+ ],
+ [
+ 'swap 50 USDC to SOL at 90',
+ { amount: '50', tokenIn: 'USDC', tokenOut: 'SOL', limitPrice: '90' },
+ ],
+ [
+ 'buy SOL with 50 USDC at $90',
+ { amount: '50', tokenIn: 'USDC', tokenOut: 'SOL', limitPrice: '90' },
+ ],
+ [
+ 'buy SOL at $90 with 50 USDC',
+ { amount: '50', tokenIn: 'USDC', tokenOut: 'SOL', limitPrice: '90' },
+ ],
+ ['sell 1 solana at 110', { amount: '1', tokenIn: 'SOL', tokenOut: 'USDC', limitPrice: '110' }],
+ ])('reads %s', (typed, want) => {
+ expect(parseChainLimit(typed)).toEqual({ kind: 'limit', venue: undefined, ...want })
+ })
+
+ it.each([
+ 'sell 1 SOL',
+ 'swap 1 SOL to USDC',
+ 'sell 1 SOL at 1,10',
+ 'sell $5 of SOL at 110',
+ 'buy 2 SOL at 90',
+ 'sell 1 SOL at 110 then cash out',
+ ])('leaves %s alone', (typed) => {
+ expect(parseChainLimit(typed)).toBeNull()
+ })
+})
+
+describe('limitSaid', () => {
+ const asked = {
+ kind: 'limit' as const,
+ amount: '1',
+ tokenIn: 'SOL',
+ tokenOut: 'USDC',
+ limitPrice: '110',
+ }
+ it('wants the amount by its token and the price after it, in the persons own words', () => {
+ expect(limitSaid(asked, ['sell 1 SOL at $110'])).toBe(true)
+ expect(limitSaid(asked, ['sell 1 SOL', 'at 110'])).toBe(true)
+ })
+ it('refuses a figure they never said', () => {
+ expect(limitSaid({ ...asked, limitPrice: '120' }, ['sell 1 SOL at $110'])).toBe(false)
+ expect(limitSaid({ ...asked, amount: '2' }, ['sell 1 SOL at $110'])).toBe(false)
+ })
+ it('refuses a token received they did not name', () => {
+ const usdt = { ...asked, tokenOut: 'USDT' }
+ expect(limitSaid(usdt, ['sell 1 SOL for USDC at 110'])).toBe(false)
+ expect(limitSaid(usdt, ['sell 1 SOL for USDT at 110'])).toBe(true)
+ })
+ it('refuses a sale when they said buy', () => {
+ expect(limitSaid({ ...asked, limitPrice: '100' }, ['buy 1 SOL at 100'])).toBe(false)
+ })
+ it('refuses a purchase when they said sell', () => {
+ const buying = {
+ kind: 'limit' as const,
+ amount: '50',
+ tokenIn: 'USDC',
+ tokenOut: 'SOL',
+ limitPrice: '90',
+ }
+ expect(limitSaid(buying, ['sell 50 USDC at 90'])).toBe(false)
+ })
+ it('lets USDC go unnamed after sell, and takes swap and buy wordings', () => {
+ expect(limitSaid(asked, ['sell 1 SOL at 110'])).toBe(true)
+ expect(limitSaid(asked, ['limit sell 1 SOL at 110'])).toBe(true)
+ expect(limitSaid(asked, ['swap 1 SOL at 110'])).toBe(false)
+ const buying = {
+ kind: 'limit' as const,
+ amount: '50',
+ tokenIn: 'USDC',
+ tokenOut: 'SOL',
+ limitPrice: '90',
+ }
+ expect(limitSaid(buying, ['swap 50 USDC to SOL at 90'])).toBe(true)
+ expect(limitSaid(buying, ['buy SOL with 50 USDC at $90'])).toBe(true)
+ })
+ it('refuses price and amount swapped round', () => {
+ expect(limitSaid({ ...asked, amount: '110', limitPrice: '1' }, ['sell 1 SOL at $110'])).toBe(
+ false
+ )
+ })
+})
diff --git a/apps/dapp/lib/__tests__/chain-orders-view.test.ts b/apps/dapp/lib/__tests__/chain-orders-view.test.ts
new file mode 100644
index 00000000..d19d58b8
--- /dev/null
+++ b/apps/dapp/lib/__tests__/chain-orders-view.test.ts
@@ -0,0 +1,228 @@
+import { describe, expect, it } from 'vitest'
+
+import { LIMIT_NOT_HERE } from '../chains/prepare'
+import { readChainMessage } from '../chains/parse-sequence'
+import { chainHelp } from '../chains/explain'
+import {
+ exitsFor,
+ freshExit,
+ GONE,
+ limitSettledNote,
+ orderLine,
+ ordersAsked,
+} from '../chains/orders-view'
+import type { RestingOrder } from '../chains/limit'
+import {
+ continueSource,
+ earlierAttempt,
+ recordAttempt,
+ type AttemptStorage,
+} from '../chains/signing-flow'
+
+function memory(): AttemptStorage {
+ const data = new Map()
+ return {
+ getItem: (key) => data.get(key) ?? null,
+ setItem: (key, value) => void data.set(key, value),
+ }
+}
+
+const order = (over: Partial = {}): RestingOrder => ({
+ id: 'o1',
+ venueId: 'limo',
+ tokenIn: 'SOL',
+ tokenOut: 'USDC',
+ amountIn: '1',
+ remainingIn: '1',
+ receivedOut: '0',
+ limitPrice: '110',
+ status: 'open',
+ ...over,
+})
+
+describe('reading a message off Stellar', () => {
+ it('reads a limit order as one', () => {
+ const read = readChainMessage('sell 1 SOL at $110', 'solana', 'Solana')
+ expect(read).toMatchObject({ kind: 'limit', action: { kind: 'limit', limitPrice: '110' } })
+ })
+
+ it('leaves a sequence and a plain swap as they were', () => {
+ expect(readChainMessage('swap 1 SOL to USDC then cash out', 'solana', 'Solana').kind).toBe(
+ 'sequence'
+ )
+ expect(readChainMessage('swap 1 SOL to USDC', 'solana', 'Solana').kind).toBe('action')
+ })
+
+ it('reads a limit before a swap that would take its price for a venue', () => {
+ const read = readChainMessage('swap 50 USDC to SOL at 90', 'solana', 'Solana')
+ expect(read.kind).toBe('limit')
+ })
+
+ it('refuses a limit on Monad with the reason', () => {
+ const read = readChainMessage('sell 1 MON at $5', 'monad', 'Monad')
+ expect(read).toEqual({ kind: 'refusal', refusal: LIMIT_NOT_HERE.monad })
+ })
+
+ it('asks for the spend when a purchase names only what it gets', () => {
+ const read = readChainMessage('buy 2 SOL at 90', 'solana', 'Solana')
+ expect(read.kind).toBe('refusal')
+ expect((read as { refusal: string }).refusal).toContain(
+ 'Say how much you will spend, for example "buy SOL with 180 USDC at $90".'
+ )
+ })
+
+ it('asks for a count when the amount is dollars', () => {
+ const read = readChainMessage('sell $5 of SOL at $110', 'solana', 'Solana')
+ expect((read as { refusal: string }).refusal).toContain(
+ 'Say how much SOL, for example "sell 0.05 SOL at $110".'
+ )
+ })
+
+ it('reads a question about orders', () => {
+ expect(readChainMessage('my orders', 'solana', 'Solana').kind).toBe('orders')
+ })
+})
+
+describe('ordersAsked', () => {
+ it.each(['my orders', 'show my open orders', 'cancel my order', 'limit orders'])(
+ 'is true for %s',
+ (text) => expect(ordersAsked(text)).toBe(true)
+ )
+ it.each(['order 5 SOL', 'in order to swap'])('is false for %s', (text) =>
+ expect(ordersAsked(text)).toBe(false)
+ )
+})
+
+describe('exitsFor', () => {
+ it('offers cancel on an open order', () => {
+ expect(exitsFor(order())).toEqual(['cancel'])
+ })
+ it('offers cancel and collect on a part-filled one with something to collect', () => {
+ const partly = order({
+ status: 'partly',
+ collectable: [{ amount: '44', symbol: 'USDC' }],
+ })
+ expect(exitsFor(partly)).toEqual(['cancel', 'collect'])
+ expect(exitsFor(order({ status: 'partly' }))).toEqual(['cancel'])
+ })
+ it('offers collect only on a filled order, and nothing on a done one', () => {
+ expect(exitsFor(order({ status: 'filled' }))).toEqual(['collect'])
+ expect(exitsFor(order({ status: 'done' }))).toEqual([])
+ })
+})
+
+describe('freshExit', () => {
+ it('turns a cancel into a collect when the order has since filled', () => {
+ expect(freshExit(order(), order({ status: 'filled' }), 'cancel')).toBe('collect')
+ })
+ it('says gone when the order is no longer listed', () => {
+ expect(freshExit(order(), undefined, 'cancel')).toBe('gone')
+ expect(GONE).toBe('That order is no longer open. Nothing was signed or sent.')
+ })
+ it('keeps a press that still holds', () => {
+ expect(freshExit(order(), order(), 'cancel')).toBe('cancel')
+ })
+ it('does not call a filled order gone when its money is on the row', () => {
+ const waiting = order({
+ amountIn: '0',
+ status: 'filled',
+ collectable: [{ amount: '12', symbol: 'USDC' }],
+ })
+ expect(freshExit(waiting, waiting, 'collect')).toBe('collect')
+ expect(freshExit(order(), waiting, 'cancel')).toBe('collect')
+ })
+ it('is gone when a collect has nothing left to take', () => {
+ expect(freshExit(order({ status: 'filled' }), order({ status: 'done' }), 'collect')).toBe(
+ 'gone'
+ )
+ })
+})
+
+describe('orderLine', () => {
+ it('reads like a sentence', () => {
+ const line = orderLine(
+ order({ remainingIn: '0.6', expiresAt: Date.UTC(2026, 10, 12, 12), status: 'partly' })
+ )
+ expect(line).toBe('Sell 1 SOL at $110, 0.4 filled, expires Nov 12')
+ })
+ it('reads a purchase', () => {
+ expect(
+ orderLine(
+ order({
+ tokenIn: 'USDC',
+ tokenOut: 'SOL',
+ amountIn: '180',
+ remainingIn: '180',
+ limitPrice: '90',
+ })
+ )
+ ).toBe('Buy SOL with 180 USDC at $90')
+ })
+ it('leads with what can be collected', () => {
+ const line = orderLine(
+ order({
+ status: 'filled',
+ remainingIn: '0',
+ collectable: [{ amount: '110', symbol: 'USDC' }],
+ })
+ )
+ expect(line.startsWith('110 USDC ready to collect')).toBe(true)
+ })
+ it('leads with the money of a waiting row that has no amount', () => {
+ const line = orderLine(
+ order({
+ amountIn: '0',
+ remainingIn: '0',
+ status: 'filled',
+ collectable: [{ amount: '12', symbol: 'USDC' }],
+ })
+ )
+ expect(line).toBe('12 USDC ready to collect')
+ })
+})
+
+describe('what is said after a limit order settles', () => {
+ it('names how to see it, and the second signature on Sui and NEAR', () => {
+ expect(limitSettledNote('solana')).toContain('my orders')
+ expect(limitSettledNote('solana')).not.toContain('another signature')
+ expect(limitSettledNote('sui')).toContain('another signature')
+ expect(limitSettledNote('near')).toContain('another signature')
+ })
+})
+
+describe('a setup step', () => {
+ const source = {
+ kind: 'request' as const,
+ request: { kind: 'limit' as const, amount: '1', tokenIn: 'SOL' },
+ asked: 'x',
+ }
+ it('keeps the request and offers a press once settled, however many rounds', () => {
+ expect(continueSource('settled', { thenPrepareAgain: true }, source)).toBe(source)
+ expect(continueSource('settled', { thenPrepareAgain: true }, source)).toBe(source)
+ })
+ it('does nothing while building, or without a setup step', () => {
+ expect(continueSource('building', { thenPrepareAgain: true }, source)).toBeUndefined()
+ expect(continueSource('settled', {}, source)).toBeUndefined()
+ expect(continueSource('settled', undefined, source)).toBeUndefined()
+ })
+})
+
+describe('earlier attempts of an exit', () => {
+ it('keys an exit by its order and action', () => {
+ const storage = memory()
+ const base = { chain: 'solana', account: 'a'.repeat(44), tokenIn: 'SOL', amount: '0' }
+ recordAttempt(storage, { ...base, at: Date.now(), exit: 'cancel:o1' })
+ expect(earlierAttempt(storage, { ...base, exit: 'cancel:o2' })).toBeUndefined()
+ expect(earlierAttempt(storage, { ...base, exit: 'collect:o1' })).toBeUndefined()
+ expect(earlierAttempt(storage, base)).toBeUndefined()
+ expect(earlierAttempt(storage, { ...base, exit: 'cancel:o1' })).toBeDefined()
+ })
+})
+
+describe('chainHelp', () => {
+ it('names limit orders where a venue exists', () => {
+ const help = chainHelp('Solana', [{ kind: 'limit', venue: 'Limo', symbol: 'SOL' }])
+ expect(help).toContain('sell 1 SOL at $110')
+ expect(help).toContain('my orders')
+ })
+})
diff --git a/apps/dapp/lib/__tests__/curvance.test.ts b/apps/dapp/lib/__tests__/curvance.test.ts
index 3c4dc6a1..b9338e9f 100644
--- a/apps/dapp/lib/__tests__/curvance.test.ts
+++ b/apps/dapp/lib/__tests__/curvance.test.ts
@@ -569,6 +569,8 @@ function fakeClient(receipt: Partial, input: string = deposit()): Ev
return {
tokenBalance: async () => BigInt(0),
nativeBalance: async () => BigInt(0),
+ allowance: async () => BigInt(0),
+ code: async () => undefined,
waitForReceipt: async () =>
({
status: 'success',
diff --git a/apps/dapp/lib/__tests__/evm-call.test.ts b/apps/dapp/lib/__tests__/evm-call.test.ts
index 40621294..3f2bc1c4 100644
--- a/apps/dapp/lib/__tests__/evm-call.test.ts
+++ b/apps/dapp/lib/__tests__/evm-call.test.ts
@@ -195,6 +195,8 @@ function fakeClient(
return {
tokenBalance: async () => BigInt(0),
nativeBalance: async () => BigInt(0),
+ allowance: async () => BigInt(0),
+ code: async () => undefined,
waitForReceipt: async () => {
if (receipt instanceof Error) throw receipt
return { status: 'success', from: ACCOUNT, to: CONTRACT, logs: [], ...receipt } as EvmReceipt
diff --git a/apps/dapp/lib/__tests__/evm-cow.live.test.ts b/apps/dapp/lib/__tests__/evm-cow.live.test.ts
new file mode 100644
index 00000000..6c665762
--- /dev/null
+++ b/apps/dapp/lib/__tests__/evm-cow.live.test.ts
@@ -0,0 +1,222 @@
+import { createPublicClient, hashDomain, http, parseAbi, type Address } from 'viem'
+import { avalanche } from 'viem/chains'
+import { beforeEach, describe, expect, it } from 'vitest'
+
+import { cowBook, cowVenue } from '../chains/evm/cow'
+import {
+ COW_APP_DATA,
+ COW_APP_DATA_HASH,
+ COW_BUY_NATIVE,
+ COW_DOMAIN,
+ COW_SETTLEMENT,
+ COW_VAULT_RELAYER,
+ cowOrderUid,
+ WAVAX,
+ type CowOrder,
+} from '../chains/evm/cow-rules'
+import { ChainActionRefused, type ChainActionRequest } from '../chains/types'
+
+/**
+ * Against Avalanche mainnet and CoW's API, reading and quoting only. A quote creates no order, and
+ * nothing here signs or posts one. The owner named is public, from CoW's own auction data, and its
+ * key isn't held here. Run by this exact file name.
+ */
+
+const API = 'https://api.cow.fi/avalanche/api/v1'
+const USDC = '0xB97EF9Ef8734C71904D8002F8b6Bc66Dd9c48a6E'
+/** A Trust Wallet user whose USDC-to-AVAX order filled on 5 November 2026. */
+const OWNER = '0x234bdf42ab52da50ae8bd226a7429abf5d1b8626'
+const FILLED =
+ '0x9615813c04ab2ec6d73868776996f1324f04aedd25b7db3d69309831d562bd75234bdf42ab52da50ae8bd226a7429abf5d1b86266aece9c6'
+/** Holds nothing anywhere. */
+const NOBODY = '0x00000000000000000000000000000000c0ffee01'
+const SLOW_MS = 60_000
+
+const settlement = parseAbi([
+ 'function domainSeparator() view returns (bytes32)',
+ 'function vaultRelayer() view returns (address)',
+])
+
+const client = createPublicClient({
+ chain: avalanche,
+ transport: http(process.env['AVALANCHE_RPC_URL']?.trim() || undefined, { timeout: 15_000 }),
+})
+
+async function quote(body: Record): Promise {
+ return fetch(`${API}/quote`, {
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ body: JSON.stringify({
+ from: NOBODY,
+ receiver: NOBODY,
+ kind: 'sell',
+ appData: COW_APP_DATA,
+ appDataHash: COW_APP_DATA_HASH,
+ partiallyFillable: true,
+ sellTokenBalance: 'erc20',
+ buyTokenBalance: 'erc20',
+ signingScheme: 'eip712',
+ ...body,
+ }),
+ })
+}
+
+describe.skipIf(process.env['SKIP_LIVE'] === '1')('CoW on Avalanche, read and quoted only', () => {
+ // CoW answers a burst with 429, so each test waits its turn.
+ beforeEach(() => new Promise((resolve) => setTimeout(resolve, 4_000)))
+
+ it(
+ 'has the settlement contract, domain and vault relayer the rules are pinned to',
+ async () => {
+ const [separator, relayer] = await Promise.all([
+ client.readContract({
+ address: COW_SETTLEMENT as Address,
+ abi: settlement,
+ functionName: 'domainSeparator',
+ }),
+ client.readContract({
+ address: COW_SETTLEMENT as Address,
+ abi: settlement,
+ functionName: 'vaultRelayer',
+ }),
+ ])
+ expect(separator).toBe(
+ hashDomain({
+ domain: {
+ ...COW_DOMAIN,
+ chainId: BigInt(COW_DOMAIN.chainId),
+ verifyingContract: COW_SETTLEMENT as Address,
+ },
+ types: {
+ EIP712Domain: [
+ { name: 'name', type: 'string' },
+ { name: 'version', type: 'string' },
+ { name: 'chainId', type: 'uint256' },
+ { name: 'verifyingContract', type: 'address' },
+ ],
+ },
+ })
+ )
+ expect(relayer.toLowerCase()).toBe(COW_VAULT_RELAYER.toLowerCase())
+ },
+ SLOW_MS
+ )
+
+ it(
+ 'finds an owner’s orders at /account/{owner}/orders, and not at /orders?owner=',
+ async () => {
+ const listed = await fetch(`${API}/account/${OWNER}/orders?limit=5`)
+ expect(listed.status).toBe(200)
+ const rows = (await listed.json()) as { owner: string; uid: string }[]
+ expect(rows.length).toBeGreaterThan(0)
+ expect(rows.every((r) => r.owner.toLowerCase() === OWNER)).toBe(true)
+ const byQuery = await fetch(`${API}/orders?owner=${OWNER}`)
+ console.log(`GET /orders?owner= answered ${byQuery.status}`)
+ expect(byQuery.status).not.toBe(200)
+ },
+ SLOW_MS
+ )
+
+ it(
+ 'computes the same order id as CoW for a real filled order',
+ async () => {
+ const response = await fetch(`${API}/orders/${FILLED}`)
+ expect(response.status).toBe(200)
+ const row = (await response.json()) as Record
+ const order = {
+ sellToken: row['sellToken'],
+ buyToken: row['buyToken'],
+ receiver: row['receiver'],
+ sellAmount: row['sellAmount'],
+ buyAmount: row['buyAmount'],
+ validTo: row['validTo'],
+ appData: row['appData'],
+ feeAmount: row['feeAmount'],
+ kind: row['kind'],
+ partiallyFillable: row['partiallyFillable'],
+ sellTokenBalance: row['sellTokenBalance'],
+ buyTokenBalance: row['buyTokenBalance'],
+ } as unknown as CowOrder
+ expect(cowOrderUid(order, String(row['owner']))).toBe(FILLED)
+ expect(String(row['buyToken']).toLowerCase()).toBe(COW_BUY_NATIVE.toLowerCase())
+ expect(row['status']).toBe('fulfilled')
+ },
+ SLOW_MS
+ )
+
+ it(
+ 'quotes buying native AVAX through 0xEeee…, so a purchase pays out in AVAX',
+ async () => {
+ const response = await quote({
+ sellToken: USDC,
+ buyToken: COW_BUY_NATIVE,
+ sellAmountBeforeFee: '10000000',
+ })
+ const body = (await response.json()) as { quote?: { buyAmount: string; buyToken: string } }
+ console.log(`USDC to native AVAX quote: ${response.status} ${JSON.stringify(body.quote)}`)
+ expect(response.status).toBe(200)
+ expect(body.quote?.buyToken.toLowerCase()).toBe(COW_BUY_NATIVE.toLowerCase())
+ expect(BigInt(body.quote?.buyAmount ?? '0') > BigInt(0)).toBe(true)
+ },
+ SLOW_MS
+ )
+
+ it(
+ 'quotes selling WAVAX for USDC with the empty app data this app signs',
+ async () => {
+ const response = await quote({
+ sellToken: WAVAX,
+ buyToken: USDC,
+ sellAmountBeforeFee: '1000000000000000000',
+ })
+ const body = (await response.json()) as { quote?: { appDataHash?: string } }
+ console.log(`WAVAX to USDC quote: ${response.status} ${JSON.stringify(body.quote)}`)
+ expect(response.status).toBe(200)
+ expect(body.quote?.appDataHash?.toLowerCase()).toBe(COW_APP_DATA_HASH.toLowerCase())
+ },
+ SLOW_MS
+ )
+
+ const sale = (amount: string): ChainActionRequest => ({
+ kind: 'limit',
+ amount,
+ tokenIn: 'AVAX',
+ tokenOut: 'USDC',
+ limitPrice: '20',
+ })
+
+ it(
+ 'prepares only the wrap for a wallet with no WAVAX, building nothing but the step',
+ async () => {
+ const prepared = await cowVenue.prepare(sale('2'), NOBODY)
+ expect(prepared.thenPrepareAgain).toBe(true)
+ expect(prepared.context?.['step']).toBe('wrap')
+ expect(prepared.signing).toMatchObject({ family: 'evm', to: WAVAX, data: '0xd0e30db0' })
+ cowVenue.assertSafe(prepared.signing, NOBODY, prepared.context)
+ },
+ SLOW_MS
+ )
+
+ it(
+ 'refuses an order too small for CoW’s solvers in the brief’s words',
+ async () => {
+ const refusal = await cowVenue.prepare(sale('0.000001'), NOBODY).catch((e: unknown) => e)
+ expect(refusal).toBeInstanceOf(ChainActionRefused)
+ expect((refusal as Error).message).toBe(
+ "That is too small for CoW's solvers to fill. Try a few dollars more. Nothing was signed or sent."
+ )
+ },
+ SLOW_MS
+ )
+
+ it(
+ 'reads a real owner’s book, and refuses to cancel an order that has filled',
+ async () => {
+ const orders = await cowBook().openOrders(OWNER)
+ console.log(`book for ${OWNER}: ${JSON.stringify(orders.slice(0, 3))}`)
+ expect(orders.every((o) => o.venueId === 'cow-limit')).toBe(true)
+ await expect(cowBook().prepareExit(OWNER, FILLED, 'cancel')).rejects.toThrow(/no longer open/)
+ },
+ SLOW_MS
+ )
+})
diff --git a/apps/dapp/lib/__tests__/evm-cow.test.ts b/apps/dapp/lib/__tests__/evm-cow.test.ts
new file mode 100644
index 00000000..ea960555
--- /dev/null
+++ b/apps/dapp/lib/__tests__/evm-cow.test.ts
@@ -0,0 +1,1187 @@
+import {
+ encodeFunctionData,
+ erc20Abi,
+ hashTypedData,
+ keccak256,
+ parseAbi,
+ toHex,
+ type Address,
+} from 'viem'
+import { privateKeyToAccount } from 'viem/accounts'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+import { avalancheCapabilities, monadCapabilities } from '../chains/evm/capabilities'
+import { COW_VENUE_ID, cowBook, cowVenue } from '../chains/evm/cow'
+
+import {
+ COW_APP_DATA,
+ COW_APP_DATA_HASH,
+ COW_BUY_NATIVE,
+ COW_DOMAIN,
+ COW_ORDER_TYPES,
+ COW_SETTLEMENT,
+ COW_VAULT_RELAYER,
+ cowAssertSafe,
+ cowOrder,
+ cowOrderUid,
+ cowTypedData,
+ WAVAX,
+ type CowOrder,
+ viemTypedData,
+} from '../chains/evm/cow-rules'
+import { evmDeposit } from '../chains/evm/deposit'
+import { avalancheModule, buildEvmVenues, monadModule } from '../chains/evm/module'
+import { signTypedOn, type TypedWallet } from '../chains/evm/sign-typed'
+import { evmVenues } from '../chains/evm/venues'
+import { limitOutput } from '../chains/limit'
+import { LIMIT_NOT_HERE } from '../chains/prepare'
+import {
+ ChainActionRefused,
+ type ChainActionRequest,
+ type PreparedAction,
+ type SigningPayload,
+ type SubmitResult,
+} from '../chains/types'
+import { afterWallet, walletSends } from '../weave/cashout-view'
+import { avalancheDeposit } from '../weave/deposit/avalanche'
+
+vi.mock('../server/analytics', () => ({ logExecution: async () => undefined }))
+vi.mock('../server/rate-limit', () => ({ enforceRateLimit: async () => undefined }))
+vi.mock('../server/report', () => ({ reportError: () => undefined }))
+
+const chain = vi.hoisted(() => ({
+ tokenBalance: (async () => BigInt(0)) as (token: string, account: string) => Promise,
+ allowance: (async () => BigInt(0)) as (
+ token: string,
+ owner: string,
+ spender: string
+ ) => Promise,
+ waitForReceipt: (async () => {
+ throw new Error('no receipt')
+ }) as (hash: string) => Promise,
+ transaction: (async () => ({ value: BigInt(0), input: '0x' })) as (
+ hash: string
+ ) => Promise<{ value: bigint; input: string }>,
+ code: (async () => '0x') as (account: string) => Promise,
+ reads: [] as string[],
+}))
+
+vi.mock('../chains/evm/client', async (original) => {
+ const actual = await original()
+ return {
+ ...actual,
+ evmClient: () => ({
+ tokenBalance: (token: string, account: string) => {
+ chain.reads.push(`balance ${token}`)
+ return chain.tokenBalance(token, account)
+ },
+ allowance: (token: string, owner: string, spender: string) => {
+ chain.reads.push(`allowance ${token} ${spender}`)
+ return chain.allowance(token, owner, spender)
+ },
+ nativeBalance: async () => BigInt(0),
+ code: async (account: string) => chain.code(account),
+ waitForReceipt: (hash: string) => chain.waitForReceipt(hash),
+ transaction: (hash: string) => chain.transaction(hash),
+ }),
+ }
+})
+
+const KEY = `0x${'59'.repeat(32)}` as const
+const SIGNER = privateKeyToAccount(KEY)
+const ME = SIGNER.address
+const THIEF = '0x3333333333333333333333333333333333333333'
+const ZERO = '0x0000000000000000000000000000000000000000'
+const USDC = '0xB97EF9Ef8734C71904D8002F8b6Bc66Dd9c48a6E'
+const USDT = '0x9702230A8Ea53601f5cD2dc00fDBc13d4dF4A8c7'
+const NOW = Date.parse('2026-11-05T12:00:00Z')
+const NOW_S = Math.floor(NOW / 1000)
+const DAY = 86_400
+const ONE_AVAX = '1000000000000000000'
+
+const SALE: CowOrder = cowOrder({
+ sellToken: WAVAX,
+ buyToken: USDC,
+ receiver: ME,
+ sellAmount: ONE_AVAX,
+ buyAmount: '9500000',
+ validTo: NOW_S + 7 * DAY,
+})
+
+function typed(message: CowOrder, account: string = ME): SigningPayload {
+ return { family: 'evm-typed', chainId: 43114, account, typedData: cowTypedData(message) }
+}
+
+function orderContext(order: CowOrder): Record {
+ return {
+ step: 'order',
+ sellToken: order.sellToken,
+ buyToken: order.buyToken,
+ sellAmount: order.sellAmount,
+ buyAmount: order.buyAmount,
+ validTo: String(order.validTo),
+ }
+}
+
+function refusal(work: () => void): string {
+ try {
+ work()
+ } catch (e) {
+ expect(e).toBeInstanceOf(ChainActionRefused)
+ return (e as Error).message
+ }
+ throw new Error('expected a refusal')
+}
+
+const check = (payload: SigningPayload, context: Record, account = ME): void =>
+ cowAssertSafe(payload, account, context, NOW)
+
+const evm = (to: string, data: string, value = '0', chainId = 43114): SigningPayload => ({
+ family: 'evm',
+ chainId,
+ to,
+ data,
+ value,
+})
+
+const approve = (spender: string, amount: bigint): string =>
+ encodeFunctionData({ abi: erc20Abi, functionName: 'approve', args: [spender as Address, amount] })
+
+const INVALIDATE = parseAbi(['function invalidateOrder(bytes orderUid)'])
+
+describe('the pinned CoW values', () => {
+ it('uses the settlement, relayer and wrapped AVAX from the research', () => {
+ expect(COW_SETTLEMENT).toBe('0x9008D19f58AAbD9eD0D60971565AA8510560ab41')
+ expect(COW_VAULT_RELAYER).toBe('0xC92E8bdf79f0507f65a392b0ab4667716BFE0110')
+ expect(WAVAX).toBe('0xB31f66AA3C1e785363F0875A1B74E27b85FD66c7')
+ expect(COW_BUY_NATIVE).toBe('0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE')
+ expect(COW_DOMAIN).toEqual({
+ name: 'Gnosis Protocol',
+ version: 'v2',
+ chainId: 43114,
+ verifyingContract: '0x9008D19f58AAbD9eD0D60971565AA8510560ab41',
+ })
+ })
+
+ it('has the Order type whose hash the research pins', () => {
+ const fields = COW_ORDER_TYPES.Order.map((f) => `${f.type} ${f.name}`).join(',')
+ expect(keccak256(toHex(`Order(${fields})`))).toBe(
+ '0xd5a25ba2e97094ad7d83dc28a6572da797d6b3e7fc6663bd93efb789fc17e489'
+ )
+ })
+
+ it('signs the empty app data, with no hooks and no partner fee', () => {
+ expect(COW_APP_DATA).toBe('{}')
+ expect(COW_APP_DATA_HASH).toBe(keccak256(toHex('{}')))
+ expect(SALE.appData).toBe(COW_APP_DATA_HASH)
+ })
+
+ it('fills the fixed fields of an order', () => {
+ expect(SALE).toEqual({
+ sellToken: WAVAX,
+ buyToken: USDC,
+ receiver: ME,
+ sellAmount: ONE_AVAX,
+ buyAmount: '9500000',
+ validTo: NOW_S + 7 * DAY,
+ appData: COW_APP_DATA_HASH,
+ feeAmount: '0',
+ kind: 'sell',
+ partiallyFillable: true,
+ sellTokenBalance: 'erc20',
+ buyTokenBalance: 'erc20',
+ })
+ })
+
+ it('computes the order id from the digest, the owner and validTo', () => {
+ const digest = hashTypedData({
+ domain: { ...COW_DOMAIN, verifyingContract: COW_SETTLEMENT as Address },
+ types: COW_ORDER_TYPES,
+ primaryType: 'Order',
+ message: {
+ ...SALE,
+ sellToken: SALE.sellToken as Address,
+ buyToken: SALE.buyToken as Address,
+ receiver: SALE.receiver as Address,
+ appData: SALE.appData as Address,
+ sellAmount: BigInt(SALE.sellAmount),
+ buyAmount: BigInt(SALE.buyAmount),
+ feeAmount: BigInt(0),
+ },
+ })
+ const uid = cowOrderUid(SALE, ME)
+ expect(uid).toBe(
+ `${digest}${ME.slice(2).toLowerCase()}${(NOW_S + 7 * DAY).toString(16).padStart(8, '0')}`
+ )
+ expect(uid).toHaveLength(2 + 112)
+ })
+})
+
+describe('cowAssertSafe on an order', () => {
+ it('passes the order prepare builds', () => {
+ expect(() => check(typed(SALE), orderContext(SALE))).not.toThrow()
+ })
+
+ it('passes a purchase paid out in native AVAX', () => {
+ const buy = cowOrder({
+ sellToken: USDC,
+ buyToken: COW_BUY_NATIVE,
+ receiver: ME,
+ sellAmount: '9000000',
+ buyAmount: ONE_AVAX,
+ validTo: NOW_S + 7 * DAY,
+ })
+ expect(() => check(typed(buy), orderContext(buy))).not.toThrow()
+ })
+
+ it('ends every refusal with nothing signed or sent', () => {
+ const message = refusal(() => check(typed({ ...SALE, receiver: THIEF }), orderContext(SALE)))
+ expect(message).toMatch(/Nothing was signed or sent\.$/)
+ })
+
+ const domainCases: [string, Record][] = [
+ ['another verifying contract', { ...COW_DOMAIN, verifyingContract: THIEF }],
+ ['another chain id', { ...COW_DOMAIN, chainId: 1 }],
+ ['another domain name', { ...COW_DOMAIN, name: 'Gnosis Protocol v3' }],
+ ['another version', { ...COW_DOMAIN, version: 'v1' }],
+ ['an extra domain field', { ...COW_DOMAIN, salt: `0x${'0'.repeat(64)}` }],
+ ]
+ for (const [what, domain] of domainCases) {
+ it(`refuses ${what}`, () => {
+ const payload = typed(SALE) as Extract
+ refusal(() =>
+ check({ ...payload, typedData: { ...payload.typedData, domain } }, orderContext(SALE))
+ )
+ })
+ }
+
+ it('refuses a payload for another chain', () => {
+ refusal(() => check({ ...typed(SALE), chainId: 1 } as SigningPayload, orderContext(SALE)))
+ })
+
+ it('refuses a payload for another account', () => {
+ refusal(() => check(typed(SALE, THIEF), orderContext(SALE)))
+ })
+
+ it('refuses another primary type or changed types', () => {
+ const payload = typed(SALE) as Extract
+ refusal(() =>
+ check(
+ {
+ ...payload,
+ typedData: { ...payload.typedData, primaryType: 'Permit' as 'Order' },
+ },
+ orderContext(SALE)
+ )
+ )
+ refusal(() =>
+ check(
+ {
+ ...payload,
+ typedData: {
+ ...payload.typedData,
+ types: { Order: COW_ORDER_TYPES.Order.slice(0, 11) },
+ },
+ },
+ orderContext(SALE)
+ )
+ )
+ })
+
+ it('refuses another receiver, or the zero address', () => {
+ refusal(() => check(typed({ ...SALE, receiver: THIEF }), orderContext(SALE)))
+ refusal(() => check(typed({ ...SALE, receiver: ZERO }), orderContext(SALE)))
+ })
+
+ it('refuses a changed token or amount', () => {
+ refusal(() => check(typed({ ...SALE, sellToken: USDT }), orderContext(SALE)))
+ refusal(() => check(typed({ ...SALE, buyToken: USDT }), orderContext(SALE)))
+ refusal(() => check(typed({ ...SALE, sellAmount: '2000000000000000000' }), orderContext(SALE)))
+ refusal(() => check(typed({ ...SALE, buyAmount: '9499999' }), orderContext(SALE)))
+ })
+
+ it('refuses a token this app does not trade there, even when the context agrees', () => {
+ const odd = { ...SALE, sellToken: THIEF }
+ refusal(() => check(typed(odd), orderContext(odd)))
+ })
+
+ it('refuses validTo past 8 days or in the past', () => {
+ const late = { ...SALE, validTo: NOW_S + 8 * DAY + 1 }
+ refusal(() => check(typed(late), orderContext(late)))
+ const gone = { ...SALE, validTo: NOW_S - 1 }
+ refusal(() => check(typed(gone), orderContext(gone)))
+ })
+
+ it('refuses a fee other than "0"', () => {
+ refusal(() => check(typed({ ...SALE, feeAmount: '1' }), orderContext(SALE)))
+ })
+
+ it('refuses a balance other than erc20', () => {
+ refusal(() =>
+ check(typed({ ...SALE, sellTokenBalance: 'external' as 'erc20' }), orderContext(SALE))
+ )
+ refusal(() =>
+ check(typed({ ...SALE, buyTokenBalance: 'internal' as 'erc20' }), orderContext(SALE))
+ )
+ })
+
+ it('refuses non-empty app data', () => {
+ refusal(() =>
+ check(typed({ ...SALE, appData: keccak256(toHex('{"hooks":{}}')) }), orderContext(SALE))
+ )
+ })
+
+ it('refuses a buy order, or one that cannot fill in parts', () => {
+ refusal(() => check(typed({ ...SALE, kind: 'buy' as 'sell' }), orderContext(SALE)))
+ refusal(() => check(typed({ ...SALE, partiallyFillable: false }), orderContext(SALE)))
+ })
+
+ it('refuses an extra field in the message', () => {
+ refusal(() => check(typed({ ...SALE, hooks: 'x' } as unknown as CowOrder), orderContext(SALE)))
+ })
+
+ it('refuses a transaction where an order was prepared', () => {
+ refusal(() => check(evm(WAVAX, '0xd0e30db0', '1'), orderContext(SALE)))
+ })
+})
+
+describe('cowAssertSafe on setup and cancel', () => {
+ const wrapContext = { step: 'wrap', sellToken: WAVAX, amount: ONE_AVAX }
+ const approveContext = { step: 'approve', sellToken: WAVAX, amount: ONE_AVAX }
+
+ it('passes a deposit into WAVAX of no more than the sale', () => {
+ expect(() => check(evm(WAVAX, '0xd0e30db0', ONE_AVAX), wrapContext)).not.toThrow()
+ expect(() => check(evm(WAVAX, '0xd0e30db0', '400000000000000000'), wrapContext)).not.toThrow()
+ })
+
+ it('refuses a deposit anywhere else, of more, or with other data', () => {
+ refusal(() => check(evm(THIEF, '0xd0e30db0', ONE_AVAX), wrapContext))
+ refusal(() => check(evm(WAVAX, '0xd0e30db0', '1000000000000000001'), wrapContext))
+ refusal(() => check(evm(WAVAX, '0xd0e30db0', '0'), wrapContext))
+ refusal(() => check(evm(WAVAX, approve(THIEF, BigInt(1)), ONE_AVAX), wrapContext))
+ refusal(() => check(evm(WAVAX, '0xd0e30db0', ONE_AVAX, 143), wrapContext))
+ })
+
+ it('passes an exact approval to the vault relayer', () => {
+ expect(() =>
+ check(evm(WAVAX, approve(COW_VAULT_RELAYER, BigInt(ONE_AVAX))), approveContext)
+ ).not.toThrow()
+ })
+
+ it('refuses an approval to another spender or of a higher amount', () => {
+ refusal(() => check(evm(WAVAX, approve(THIEF, BigInt(ONE_AVAX))), approveContext))
+ refusal(() =>
+ check(evm(WAVAX, approve(COW_VAULT_RELAYER, BigInt(ONE_AVAX) + BigInt(1))), approveContext)
+ )
+ refusal(() =>
+ check(
+ evm(WAVAX, approve(COW_VAULT_RELAYER, BigInt(2) ** BigInt(256) - BigInt(1))),
+ approveContext
+ )
+ )
+ })
+
+ it('refuses an approval on another token, carrying value, or on another chain', () => {
+ refusal(() => check(evm(USDC, approve(COW_VAULT_RELAYER, BigInt(ONE_AVAX))), approveContext))
+ refusal(() =>
+ check(evm(WAVAX, approve(COW_VAULT_RELAYER, BigInt(ONE_AVAX)), '1'), approveContext)
+ )
+ refusal(() =>
+ check(evm(WAVAX, approve(COW_VAULT_RELAYER, BigInt(ONE_AVAX)), '0', 143), approveContext)
+ )
+ })
+
+ it('refuses a step it does not know, and a typed message where a transaction was prepared', () => {
+ refusal(() => check(evm(WAVAX, '0xd0e30db0', ONE_AVAX), { step: 'other' }))
+ refusal(() => check(typed(SALE), wrapContext))
+ })
+
+ const uid = cowOrderUid(SALE, ME)
+ const cancel = encodeFunctionData({
+ abi: INVALIDATE,
+ functionName: 'invalidateOrder',
+ args: [uid as `0x${string}`],
+ })
+
+ it('passes invalidateOrder on the settlement contract for this account’s order', () => {
+ expect(() => check(evm(COW_SETTLEMENT, cancel), { orderId: uid })).not.toThrow()
+ })
+
+ it('refuses a cancel of another order, elsewhere, or with value', () => {
+ const theirs = cowOrderUid(SALE, THIEF)
+ refusal(() => check(evm(COW_SETTLEMENT, cancel), { orderId: theirs }))
+ refusal(() => check(evm(THIEF, cancel), { orderId: uid }))
+ refusal(() => check(evm(COW_SETTLEMENT, cancel, '1'), { orderId: uid }))
+ const other = encodeFunctionData({
+ abi: INVALIDATE,
+ functionName: 'invalidateOrder',
+ args: [theirs as `0x${string}`],
+ })
+ refusal(() => check(evm(COW_SETTLEMENT, other), { orderId: uid }))
+ })
+})
+
+const API = 'https://api.cow.fi/avalanche/api/v1'
+const NOTES = [
+ 'Your funds stay in your wallet until a solver fills this at your price or better.',
+ 'Expires in 7 days.',
+ 'Avalanche has few CoW solvers trading right now, so it may not fill.',
+ 'Cancelling costs a small network fee.',
+]
+const APPROVAL_NOTE =
+ 'This lets CoW move exactly this amount and no more. Cancelling doesn’t remove it.'
+const TOO_SMALL =
+ "That is too small for CoW's solvers to fill. Try a few dollars more. Nothing was signed or sent."
+const TX = `0x${'a'.repeat(64)}`
+
+interface Call {
+ method: string
+ url: string
+ body: unknown
+}
+
+let calls: Call[] = []
+let answer: (call: Call) => { status: number; body: unknown } = () => ({ status: 404, body: {} })
+
+const QUOTE_OK = {
+ status: 200,
+ body: { quote: { sellAmount: ONE_AVAX, buyAmount: '8700000', feeAmount: '1000' } },
+}
+
+beforeEach(() => {
+ vi.useFakeTimers({ toFake: ['Date'] })
+ vi.setSystemTime(NOW)
+ calls = []
+ chain.reads = []
+ chain.tokenBalance = async () => BigInt(0)
+ chain.allowance = async () => BigInt(0)
+ chain.code = async () => '0x'
+ chain.waitForReceipt = async () => {
+ throw new Error('no receipt')
+ }
+ answer = (call) =>
+ call.url === `${API}/quote`
+ ? QUOTE_OK
+ : call.url.startsWith(`${API}/account/`)
+ ? { status: 200, body: [] }
+ : { status: 404, body: {} }
+ vi.stubGlobal('fetch', async (input: string | URL, init?: RequestInit) => {
+ const call: Call = {
+ method: init?.method ?? 'GET',
+ url: String(input),
+ body: typeof init?.body === 'string' ? JSON.parse(init.body) : undefined,
+ }
+ calls.push(call)
+ const { status, body } = answer(call)
+ return new Response(JSON.stringify(body), {
+ status,
+ headers: { 'content-type': 'application/json' },
+ })
+ })
+})
+
+afterEach(() => {
+ vi.useRealTimers()
+ vi.unstubAllGlobals()
+})
+
+const sale = (over: Partial = {}): ChainActionRequest => ({
+ kind: 'limit',
+ amount: '1',
+ tokenIn: 'AVAX',
+ tokenOut: 'USDC',
+ limitPrice: '9.5',
+ ...over,
+})
+
+const purchase = (): ChainActionRequest =>
+ sale({ amount: '9', tokenIn: 'USDC', tokenOut: 'AVAX', limitPrice: '8.5' })
+
+async function prepareRefusal(request: ChainActionRequest): Promise {
+ try {
+ await cowVenue.prepare(request, ME)
+ } catch (e) {
+ expect(e).toBeInstanceOf(ChainActionRefused)
+ return (e as Error).message
+ }
+ throw new Error('expected a refusal')
+}
+
+const safe = (prepared: PreparedAction): void =>
+ cowVenue.assertSafe(prepared.signing, ME, prepared.context)
+
+describe('prepare', () => {
+ it('is the CoW Protocol venue, for limit orders only', async () => {
+ expect(cowVenue.id).toBe('cow-limit')
+ expect(COW_VENUE_ID).toBe('cow-limit')
+ expect(cowVenue.name).toBe('CoW Protocol')
+ expect(cowVenue.kinds).toEqual(['limit'])
+ expect(await prepareRefusal(sale({ kind: 'swap' }))).toMatch(/Nothing was signed or sent\.$/)
+ })
+
+ it('wraps AVAX first when the wallet has no WAVAX, and asks to prepare again', async () => {
+ const prepared = await cowVenue.prepare(sale(), ME)
+ expect(prepared.signing).toEqual({
+ family: 'evm',
+ chainId: 43114,
+ to: WAVAX,
+ data: '0xd0e30db0',
+ value: ONE_AVAX,
+ })
+ expect(prepared.thenPrepareAgain).toBe(true)
+ expect(prepared.pay).toEqual({ amount: '1', symbol: 'AVAX' })
+ expect(prepared.context).toEqual({ step: 'wrap', sellToken: WAVAX, amount: ONE_AVAX })
+ safe(prepared)
+ })
+
+ it('refuses a smart-contract wallet before anything is wrapped or approved', async () => {
+ chain.code = async () => '0x6080604052'
+ const refusal = await prepareRefusal(sale())
+ expect(refusal).toBe(
+ 'This wallet is a smart contract, and CoW orders here need an ordinary wallet’s signature. Nothing was signed or sent.'
+ )
+ expect(chain.reads).toEqual([])
+ })
+
+ it('does not take a delegated ordinary wallet for a smart contract', async () => {
+ chain.code = async () => '0xef0100' + '11'.repeat(20)
+ const prepared = await cowVenue.prepare(sale(), ME)
+ expect(prepared.kind).toBe('limit')
+ })
+
+ it('wraps only what the WAVAX already held falls short by', async () => {
+ chain.tokenBalance = async (token) =>
+ token.toLowerCase() === WAVAX.toLowerCase() ? BigInt('400000000000000000') : BigInt(0)
+ const prepared = await cowVenue.prepare(sale(), ME)
+ expect(prepared.signing).toMatchObject({ value: '600000000000000000' })
+ expect(prepared.pay).toEqual({ amount: '0.6', symbol: 'AVAX' })
+ safe(prepared)
+ })
+
+ it('quotes before any setup, so an order too small to fill builds nothing', async () => {
+ answer = (call) =>
+ call.url === `${API}/quote`
+ ? { status: 400, body: { errorType: 'SellAmountDoesNotCoverFee', description: 'x' } }
+ : { status: 404, body: {} }
+ expect(await prepareRefusal(sale({ amount: '0.0001' }))).toBe(TOO_SMALL)
+ expect(chain.reads).toEqual([])
+ })
+
+ it('sends the quote what the order will sign', async () => {
+ await cowVenue.prepare(sale(), ME)
+ expect(calls[0]).toEqual({
+ method: 'POST',
+ url: `${API}/quote`,
+ body: {
+ sellToken: WAVAX,
+ buyToken: USDC,
+ from: ME,
+ receiver: ME,
+ kind: 'sell',
+ sellAmountBeforeFee: ONE_AVAX,
+ appData: '{}',
+ appDataHash: COW_APP_DATA_HASH,
+ partiallyFillable: true,
+ sellTokenBalance: 'erc20',
+ buyTokenBalance: 'erc20',
+ signingScheme: 'eip712',
+ },
+ })
+ })
+
+ const withBook = (rows: Record[]) => (call: Call) =>
+ call.url === `${API}/quote`
+ ? QUOTE_OK
+ : call.url.startsWith(`${API}/account/${ME}/orders`)
+ ? { status: 200, body: rows }
+ : { status: 404, body: {} }
+
+ it('refuses a new order while one selling the same token is open', async () => {
+ chain.tokenBalance = async () => BigInt(ONE_AVAX)
+ chain.allowance = async () => BigInt(ONE_AVAX)
+ const blocked =
+ 'You already have an open CoW order selling AVAX. Cancel it or wait for it to fill before placing another. Nothing was signed or sent.'
+ answer = withBook([apiOrder()])
+ expect(await prepareRefusal(sale())).toBe(blocked)
+ answer = withBook([apiOrder({ executedSellAmount: '1', executedBuyAmount: '1' })])
+ expect(await prepareRefusal(sale())).toBe(blocked)
+ answer = withBook([apiOrder({ status: 'presignaturePending' })])
+ expect(await prepareRefusal(sale())).toBe(blocked)
+ expect(chain.reads).toEqual([])
+ })
+
+ it('refuses at every step, not only at the order, so no setup leans on a shared allowance', async () => {
+ answer = withBook([apiOrder()])
+ expect(await prepareRefusal(sale())).toMatch(/^You already have an open CoW order/)
+ chain.tokenBalance = async () => BigInt(ONE_AVAX)
+ expect(await prepareRefusal(sale())).toMatch(/^You already have an open CoW order/)
+ })
+
+ it('places an order beside closed ones, or beside one selling another token', async () => {
+ chain.tokenBalance = async () => BigInt(ONE_AVAX)
+ chain.allowance = async () => BigInt(ONE_AVAX)
+ answer = withBook([
+ apiOrder({ status: 'fulfilled' }),
+ apiOrder({ status: 'cancelled' }),
+ apiOrder({ status: 'expired' }),
+ apiOrder({ sellToken: USDC.toLowerCase(), buyToken: COW_BUY_NATIVE.toLowerCase() }),
+ ])
+ expect((await cowVenue.prepare(sale(), ME)).signing.family).toBe('evm-typed')
+ })
+
+ it('refuses when the open orders can’t be read, since it can’t tell', async () => {
+ answer = (call) =>
+ call.url === `${API}/quote`
+ ? QUOTE_OK
+ : call.url.startsWith(`${API}/account/`)
+ ? { status: 503, body: {} }
+ : { status: 404, body: {} }
+ expect(await prepareRefusal(sale())).toMatch(
+ /couldn’t check your open CoW orders.*Nothing was signed or sent\.$/
+ )
+ expect(chain.reads).toEqual([])
+ })
+
+ it('refuses in words when CoW cannot be reached', async () => {
+ answer = () => ({ status: 503, body: {} })
+ expect(await prepareRefusal(sale())).toMatch(/CoW.*Nothing was signed or sent\.$/)
+ })
+
+ it('asks for an exact approval to the vault relayer when the allowance is short', async () => {
+ chain.tokenBalance = async () => BigInt(ONE_AVAX)
+ chain.allowance = async () => BigInt(5)
+ const prepared = await cowVenue.prepare(sale(), ME)
+ expect(prepared.signing).toEqual({
+ family: 'evm',
+ chainId: 43114,
+ to: WAVAX,
+ data: approve(COW_VAULT_RELAYER, BigInt(ONE_AVAX)),
+ value: '0',
+ })
+ expect(prepared.thenPrepareAgain).toBe(true)
+ expect(prepared.notes.slice(0, 5)).toEqual([APPROVAL_NOTE, ...NOTES])
+ expect(prepared.context).toEqual({ step: 'approve', sellToken: WAVAX, amount: ONE_AVAX })
+ expect(chain.reads).toContain(`allowance ${WAVAX} ${COW_VAULT_RELAYER}`)
+ safe(prepared)
+ })
+
+ it('returns the order to sign once the chain shows it is funded and approved', async () => {
+ chain.tokenBalance = async () => BigInt(ONE_AVAX)
+ chain.allowance = async () => BigInt(ONE_AVAX)
+ const prepared = await cowVenue.prepare(sale(), ME)
+ const buyAmount = limitOutput('1', '9.5', true, 18, 6).toString()
+ expect(prepared.signing).toEqual({
+ family: 'evm-typed',
+ chainId: 43114,
+ account: ME,
+ typedData: cowTypedData(
+ cowOrder({
+ sellToken: WAVAX,
+ buyToken: USDC,
+ receiver: ME,
+ sellAmount: ONE_AVAX,
+ buyAmount,
+ validTo: NOW_S + 7 * DAY,
+ })
+ ),
+ })
+ expect(prepared.thenPrepareAgain).toBeUndefined()
+ expect(prepared.notes.slice(0, 4)).toEqual(NOTES)
+ expect(prepared.receive).toEqual({ amount: '9.5', symbol: 'USDC', approximate: true })
+ expect(prepared.venueId).toBe('cow-limit')
+ safe(prepared)
+ })
+
+ it('buys native AVAX with USDC, with no wrap', async () => {
+ chain.allowance = async () => BigInt('9000000')
+ const prepared = await cowVenue.prepare(purchase(), ME)
+ const message = (prepared.signing as Extract).typedData
+ .message
+ expect(message).toMatchObject({
+ sellToken: USDC,
+ buyToken: COW_BUY_NATIVE,
+ sellAmount: '9000000',
+ buyAmount: limitOutput('9', '8.5', false, 6, 18).toString(),
+ })
+ expect(prepared.notes).toEqual(NOTES)
+ expect(chain.reads).not.toContain(`balance ${WAVAX}`)
+ safe(prepared)
+ })
+
+ it('never asks for more than the order sells', async () => {
+ chain.tokenBalance = async () => BigInt(ONE_AVAX)
+ const prepared = await cowVenue.prepare(sale(), ME)
+ const data = (prepared.signing as Extract).data
+ expect(data).toBe(approve(COW_VAULT_RELAYER, BigInt(ONE_AVAX)))
+ })
+})
+
+async function signed(order: CowOrder, key: `0x${string}` = KEY): Promise {
+ return privateKeyToAccount(key).signTypedData(viemTypedData(order))
+}
+
+const READY = cowOrder({
+ sellToken: WAVAX,
+ buyToken: USDC,
+ receiver: ME,
+ sellAmount: ONE_AVAX,
+ buyAmount: '9500000',
+ validTo: NOW_S + 7 * DAY,
+})
+
+async function submit(
+ signature: string,
+ context: Record = orderContext(READY)
+): Promise {
+ const run = cowVenue.submit
+ if (run === undefined) throw new Error('CoW must submit for itself')
+ return run({
+ account: ME,
+ venueId: 'cow-limit',
+ signed: { family: 'evm-typed', signature },
+ context,
+ })
+}
+
+describe('submit', () => {
+ const uid = cowOrderUid(READY, ME)
+
+ it('posts the signed order and returns its id with the CoW explorer link', async () => {
+ answer = (call) =>
+ call.url === `${API}/orders` ? { status: 201, body: uid } : { status: 404, body: {} }
+ const signature = await signed(READY)
+ expect(await submit(signature)).toEqual({
+ ok: true,
+ hash: uid,
+ explorerUrl: `https://explorer.cow.fi/avax/orders/${uid}`,
+ })
+ expect(calls).toEqual([
+ {
+ method: 'POST',
+ url: `${API}/orders`,
+ body: {
+ ...READY,
+ appData: '{}',
+ appDataHash: COW_APP_DATA_HASH,
+ signingScheme: 'eip712',
+ signature,
+ from: ME,
+ },
+ },
+ ])
+ })
+
+ it('refuses a signature from another key, and says why a contract wallet fails', async () => {
+ const other = `0x${'42'.repeat(32)}` as const
+ const out = await submit(await signed(READY, other))
+ expect(out).toMatchObject({ ok: false })
+ expect((out as { error: string }).error).toMatch(/smart-contract wallet/)
+ expect((out as { pending?: boolean }).pending).toBeUndefined()
+ expect(calls).toEqual([])
+ })
+
+ it('refuses a context changed after signing, since the signature no longer matches', async () => {
+ const out = await submit(await signed(READY), {
+ ...orderContext(READY),
+ buyAmount: '1',
+ })
+ expect(out).toMatchObject({ ok: false })
+ expect(calls).toEqual([])
+ })
+
+ it('refuses a signature that is not 65 bytes of hex', async () => {
+ expect(await submit('0x1234')).toMatchObject({ ok: false })
+ expect(calls).toEqual([])
+ })
+
+ it('refuses when CoW answers with another order id', async () => {
+ answer = () => ({ status: 201, body: cowOrderUid({ ...READY, buyAmount: '1' }, ME) })
+ const out = await submit(await signed(READY))
+ expect(out).toMatchObject({ ok: false, pending: true })
+ })
+
+ it('reads CoW’s too-small answer in words', async () => {
+ answer = () => ({
+ status: 400,
+ body: { errorType: 'SellAmountDoesNotCoverFee', description: 'x' },
+ })
+ const out = await submit(await signed(READY))
+ expect(out).toMatchObject({ ok: false })
+ expect((out as { error: string }).error).toMatch(/^That is too small for CoW's solvers to fill/)
+ expect((out as { pending?: boolean }).pending).toBeUndefined()
+ })
+
+ it('reads CoW’s DuplicatedOrder as the order already resting, under this app’s id', async () => {
+ answer = () => ({ status: 400, body: { errorType: 'DuplicatedOrder', description: 'x' } })
+ expect(await submit(await signed(READY))).toEqual({
+ ok: true,
+ hash: uid,
+ explorerUrl: `https://explorer.cow.fi/avax/orders/${uid}`,
+ })
+ })
+
+ it('says it may have gone through when CoW does not answer', async () => {
+ answer = () => ({ status: 502, body: {} })
+ expect(await submit(await signed(READY))).toMatchObject({ ok: false, pending: true })
+ })
+
+ it('follows a setup transaction the wallet sent, from this account to CoW’s contracts', async () => {
+ chain.waitForReceipt = async () => ({ status: 'success', from: ME, to: WAVAX, logs: [] })
+ const run = cowVenue.submit as NonNullable
+ const out = await run({
+ account: ME,
+ venueId: 'cow-limit',
+ signed: { family: 'evm', hash: TX },
+ context: { step: 'wrap', sellToken: WAVAX, amount: ONE_AVAX },
+ })
+ expect(out).toEqual({ ok: true, hash: TX, explorerUrl: `https://snowtrace.io/tx/${TX}` })
+ })
+
+ it('reads a receipt from someone else, or to another contract, as pending', async () => {
+ const run = cowVenue.submit as NonNullable
+ const input = {
+ account: ME,
+ venueId: 'cow-limit',
+ signed: { family: 'evm' as const, hash: TX },
+ context: { step: 'wrap', sellToken: WAVAX, amount: ONE_AVAX },
+ }
+ chain.waitForReceipt = async () => ({ status: 'success', from: THIEF, to: WAVAX, logs: [] })
+ expect(await run(input)).toMatchObject({ ok: false, pending: true })
+ chain.waitForReceipt = async () => ({ status: 'success', from: ME, to: THIEF, logs: [] })
+ expect(await run(input)).toMatchObject({ ok: false, pending: true })
+ })
+
+ it('reads a reverted setup transaction as failed, with only gas spent', async () => {
+ chain.waitForReceipt = async () => ({ status: 'reverted', from: ME, to: WAVAX, logs: [] })
+ chain.transaction = async () => ({ value: BigInt(ONE_AVAX), input: '0xd0e30db0' })
+ const run = cowVenue.submit as NonNullable
+ const out = await run({
+ account: ME,
+ venueId: 'cow-limit',
+ signed: { family: 'evm', hash: TX },
+ context: { step: 'wrap', sellToken: WAVAX, amount: ONE_AVAX },
+ })
+ expect(out).toMatchObject({ ok: false })
+ expect((out as { pending?: boolean }).pending).toBeUndefined()
+ })
+})
+
+function apiOrder(over: Record = {}): Record {
+ return {
+ uid: cowOrderUid(READY, ME),
+ owner: ME.toLowerCase(),
+ sellToken: WAVAX.toLowerCase(),
+ buyToken: USDC.toLowerCase(),
+ receiver: ME.toLowerCase(),
+ sellAmount: ONE_AVAX,
+ buyAmount: '9500000',
+ validTo: NOW_S + 7 * DAY,
+ executedSellAmount: '0',
+ executedBuyAmount: '0',
+ status: 'open',
+ invalidated: false,
+ kind: 'sell',
+ ...over,
+ }
+}
+
+describe('the book', () => {
+ const uid = cowOrderUid(READY, ME)
+
+ it('cancels on chain with invalidateOrder on the settlement contract', async () => {
+ answer = (call) =>
+ call.url === `${API}/orders/${uid}`
+ ? { status: 200, body: apiOrder() }
+ : { status: 404, body: {} }
+ const prepared = await cowBook().prepareExit(ME, uid, 'cancel')
+ expect(prepared.signing).toEqual({
+ family: 'evm',
+ chainId: 43114,
+ to: COW_SETTLEMENT,
+ data: encodeFunctionData({
+ abi: INVALIDATE,
+ functionName: 'invalidateOrder',
+ args: [uid as `0x${string}`],
+ }),
+ value: '0',
+ })
+ expect(prepared.context).toEqual({ orderId: uid })
+ expect(prepared.venueId).toBe('cow-limit')
+ expect(prepared.notes.join(' ')).toMatch(/network fee/)
+ expect(prepared.thenPrepareAgain).toBeUndefined()
+ safe(prepared)
+ })
+
+ it('refuses an order owned by someone else, before asking CoW', async () => {
+ const theirs = cowOrderUid(READY, THIEF)
+ await expect(cowBook().prepareExit(ME, theirs, 'cancel')).rejects.toThrow(/another wallet/)
+ expect(calls).toEqual([])
+ })
+
+ it('refuses when CoW names another owner', async () => {
+ answer = () => ({ status: 200, body: apiOrder({ owner: THIEF }) })
+ await expect(cowBook().prepareExit(ME, uid, 'cancel')).rejects.toThrow(/another wallet/)
+ })
+
+ it('refuses an order that is no longer open', async () => {
+ answer = () => ({ status: 200, body: apiOrder({ status: 'fulfilled' }) })
+ await expect(cowBook().prepareExit(ME, uid, 'cancel')).rejects.toThrow(ChainActionRefused)
+ })
+
+ it('has nothing to collect, as fills arrive in the wallet', async () => {
+ await expect(cowBook().prepareExit(ME, uid, 'collect')).rejects.toThrow(
+ /straight to your wallet.*Nothing was signed or sent\.$/
+ )
+ })
+
+ it('reads the account’s orders and maps CoW’s statuses', async () => {
+ const uids = [1, 2, 3, 4, 5].map((n) =>
+ cowOrderUid({ ...READY, buyAmount: String(9_500_000 + n) }, ME)
+ )
+ answer = (call) =>
+ call.url.startsWith(`${API}/account/${ME}/orders`)
+ ? {
+ status: 200,
+ body: [
+ apiOrder({ uid: uids[0] }),
+ apiOrder({
+ uid: uids[1],
+ executedSellAmount: '250000000000000000',
+ executedBuyAmount: '2400000',
+ }),
+ apiOrder({
+ uid: uids[2],
+ status: 'fulfilled',
+ executedSellAmount: ONE_AVAX,
+ executedBuyAmount: '9600000',
+ }),
+ apiOrder({ uid: uids[3], status: 'cancelled' }),
+ apiOrder({
+ uid: uids[4],
+ sellToken: USDC.toLowerCase(),
+ buyToken: COW_BUY_NATIVE.toLowerCase(),
+ sellAmount: '9000000',
+ buyAmount: '1058823529411764706',
+ status: 'expired',
+ }),
+ apiOrder({ uid: `0x${'1'.repeat(112)}`, sellToken: THIEF }),
+ ],
+ }
+ : { status: 404, body: {} }
+ const orders = await cowBook().openOrders(ME)
+ expect(orders.map((o) => [o.status, o.tokenIn, o.tokenOut])).toEqual([
+ ['open', 'WAVAX', 'USDC'],
+ ['partly', 'WAVAX', 'USDC'],
+ ['filled', 'WAVAX', 'USDC'],
+ ['done', 'WAVAX', 'USDC'],
+ ['done', 'USDC', 'AVAX'],
+ ])
+ expect(orders[1]).toMatchObject({
+ id: uids[1],
+ venueId: 'cow-limit',
+ amountIn: '1',
+ remainingIn: '0.75',
+ receivedOut: '2.4',
+ limitPrice: '9.5',
+ expiresAt: (NOW_S + 7 * DAY) * 1000,
+ })
+ expect(orders[2]).toMatchObject({ remainingIn: '0', receivedOut: '9.6' })
+ expect(orders[4]).toMatchObject({ limitPrice: '8.5' })
+ expect(orders.every((o) => o.collectable === undefined)).toBe(true)
+ })
+
+ it('refuses an account that is not an address', async () => {
+ await expect(cowBook().openOrders('alice.near')).rejects.toThrow(ChainActionRefused)
+ })
+})
+
+describe('where CoW runs', () => {
+ it('is on Avalanche, with or without 1Click, and never on Monad', () => {
+ expect(buildEvmVenues('avalanche', {}).map((v) => v.id)).toContain('cow-limit')
+ expect(buildEvmVenues('monad', {}).map((v) => v.id)).not.toContain('cow-limit')
+ expect(avalancheModule.venues.some((v) => v.id === 'cow-limit')).toBe(true)
+ expect(monadModule.venues.some((v) => v.id === 'cow-limit')).toBe(false)
+ expect(avalancheModule.defaultVenue.limit).toBe('cow-limit')
+ expect(avalancheModule.limitBook).toBeDefined()
+ expect(monadModule.limitBook).toBeUndefined()
+ expect(LIMIT_NOT_HERE.avalanche).toBeUndefined()
+ expect(LIMIT_NOT_HERE.monad).toBeDefined()
+ })
+
+ it('is listed on Avalanche and says what it can do', () => {
+ const row = evmVenues.find((v) => v.id === 'cow-limit')
+ expect(row).toMatchObject({
+ family: 'evm',
+ name: 'CoW Protocol',
+ category: 'orderbook',
+ chains: ['Avalanche'],
+ integration: 'executes',
+ })
+ expect(avalancheCapabilities).toContainEqual({
+ kind: 'limit',
+ venue: 'CoW Protocol',
+ symbol: 'AVAX',
+ })
+ expect(monadCapabilities.some((c) => c.kind === 'limit')).toBe(false)
+ })
+})
+
+describe('a typed message meets the rest of the app', () => {
+ const payload = typed(READY)
+
+ it('is never something a wallet sends for itself', () => {
+ expect(walletSends('evm-typed')).toBe(false)
+ expect(afterWallet({ ok: false, reason: 'rejected' }, 'evm-typed')).toEqual({ next: 'review' })
+ })
+
+ it('is refused by every other EVM venue’s check', () => {
+ const expectation = {
+ account: ME,
+ depositAddress: THIEF,
+ memo: undefined,
+ amount: '1',
+ }
+ expect(() => evmDeposit('avalanche').assertSafe(payload, expectation)).toThrow(
+ ChainActionRefused
+ )
+ expect(() => evmDeposit('monad').assertSafe(payload, expectation)).toThrow(ChainActionRefused)
+ expect(() => avalancheDeposit.assertSafe(payload, expectation)).toThrow(ChainActionRefused)
+ const env = { AUTH_SECRET: 'x'.repeat(48) }
+ for (const venue of [...buildEvmVenues('avalanche', env), ...buildEvmVenues('monad', env)]) {
+ if (venue.id === 'cow-limit') continue
+ expect(() => venue.assertSafe(payload, ME, {})).toThrow()
+ }
+ })
+
+ it('is refused, not left pending, by every other EVM venue’s submit', async () => {
+ const env = { AUTH_SECRET: 'x'.repeat(48) }
+ const others = [...buildEvmVenues('avalanche', env), ...buildEvmVenues('monad', env)].filter(
+ (v) => v.id !== 'cow-limit' && v.submit !== undefined
+ )
+ expect(others.map((v) => v.id)).toEqual(
+ expect.arrayContaining(['1click-avalanche', 'send', '1click-monad'])
+ )
+ for (const venue of others) {
+ const out = await (venue.submit as NonNullable)({
+ account: ME,
+ venueId: venue.id,
+ signed: { family: 'evm-typed', signature: `0x${'ab'.repeat(65)}` },
+ })
+ expect(out).toEqual({
+ ok: false,
+ error: 'That is a signed message, not a transaction, so nothing was sent.',
+ })
+ }
+ })
+
+ async function postSubmit(chainSlug: string, body: unknown): Promise {
+ const { POST } = await import('../../app/api/chain/[chain]/submit/route')
+ return POST(
+ new Request(`http://localhost/api/chain/${chainSlug}/submit`, {
+ method: 'POST',
+ body: JSON.stringify(body),
+ }),
+ { params: Promise.resolve({ chain: chainSlug }) }
+ )
+ }
+
+ it('reaches CoW through the submit route on Avalanche', async () => {
+ const uid = cowOrderUid(READY, ME)
+ answer = (call) =>
+ call.url === `${API}/orders` ? { status: 201, body: uid } : { status: 404, body: {} }
+ const res = await postSubmit('avalanche', {
+ account: ME,
+ venueId: 'cow-limit',
+ signed: { family: 'evm-typed', signature: await signed(READY) },
+ context: orderContext(READY),
+ })
+ expect(res.status).toBe(200)
+ expect(await res.json()).toMatchObject({ ok: true, hash: uid })
+ }, 30_000)
+
+ it('is refused on a chain that is not EVM', async () => {
+ const res = await postSubmit('solana', {
+ account: ME,
+ venueId: 'jupiter',
+ signed: { family: 'evm-typed', signature: '0x00' },
+ })
+ expect(res.status).toBeGreaterThanOrEqual(400)
+ expect(calls).toEqual([])
+ })
+})
+
+describe('signing a typed order in the wallet', () => {
+ let asked: unknown[][] = []
+ const wallet = (over: Partial = {}): TypedWallet => ({
+ account: () => ({ status: 'connected', address: ME, chainId: 43114 }),
+ switchChain: async (id) => {
+ asked.push(['switch', id])
+ },
+ signTypedData: async (args) => {
+ asked.push(['sign', args])
+ return `0x${'ab'.repeat(65)}`
+ },
+ ...over,
+ })
+
+ beforeEach(() => {
+ asked = []
+ })
+
+ const payload = typed(READY)
+ const sign = (p: SigningPayload, w: TypedWallet, chainId = 43114) =>
+ signTypedOn('Avalanche', chainId, p, w)
+
+ it('passes the checked order to signTypedData, from the account it names', async () => {
+ expect(await sign(payload, wallet())).toEqual({
+ ok: true,
+ signed: { family: 'evm-typed', signature: `0x${'ab'.repeat(65)}` },
+ })
+ expect(asked).toEqual([['sign', { account: ME, ...viemTypedData(READY) }]])
+ })
+
+ it('switches to Avalanche first when the wallet is elsewhere', async () => {
+ const out = await sign(
+ payload,
+ wallet({ account: () => ({ status: 'connected', address: ME, chainId: 143 }) })
+ )
+ expect(out.ok).toBe(true)
+ expect(asked.map((a) => a[0])).toEqual(['switch', 'sign'])
+ })
+
+ it('refuses a typed payload for another chain without touching the wallet', async () => {
+ expect(await sign({ ...payload, chainId: 143 } as SigningPayload, wallet())).toMatchObject({
+ ok: false,
+ reason: 'wallet_error',
+ })
+ expect(await sign(payload, wallet(), 143)).toMatchObject({ ok: false, reason: 'wallet_error' })
+ expect(asked).toEqual([])
+ })
+
+ it('refuses a typed payload for another account without touching the wallet', async () => {
+ const out = await sign(
+ payload,
+ wallet({ account: () => ({ status: 'connected', address: THIEF, chainId: 43114 }) })
+ )
+ expect(out).toMatchObject({ ok: false, reason: 'wallet_error' })
+ expect(asked).toEqual([])
+ })
+
+ it('refuses a message that is not a plain CoW order for this account', async () => {
+ const evil = typed({ ...READY, receiver: THIEF })
+ expect(await sign(evil, wallet())).toMatchObject({ ok: false, reason: 'wallet_error' })
+ expect(asked).toEqual([])
+ })
+
+ it('reads a declined signature as rejected', async () => {
+ const out = await sign(
+ payload,
+ wallet({
+ signTypedData: async () => {
+ throw new Error('User rejected the request.')
+ },
+ })
+ )
+ expect(out).toEqual({ ok: false, reason: 'rejected' })
+ })
+})
diff --git a/apps/dapp/lib/__tests__/evm-module.test.ts b/apps/dapp/lib/__tests__/evm-module.test.ts
index 730b1c74..8307e0d3 100644
--- a/apps/dapp/lib/__tests__/evm-module.test.ts
+++ b/apps/dapp/lib/__tests__/evm-module.test.ts
@@ -338,6 +338,7 @@ describe('the chain modules', () => {
'aave-avalanche',
'sablier-avalanche',
'bulla',
+ 'cow-limit',
'send',
])
expect(monadModule?.venues.map((v) => v.id)).toEqual([
@@ -388,7 +389,9 @@ describe('the chain modules', () => {
expect(ids('avalanche', {})).not.toContain('1click-avalanche')
expect(ids('avalanche', {})).not.toContain('send')
expect(ids('avalanche', {})).toContain('aave-avalanche')
+ expect(ids('avalanche', {})).toContain('cow-limit')
expect(ids('monad', { AUTH_SECRET: 'short' })).not.toContain('1click-monad')
+ expect(ids('monad', { AUTH_SECRET: 'short' })).not.toContain('cow-limit')
expect(ids('monad', { AUTH_SECRET: 'short' })).toContain('aave-monad')
expect(ids('monad', { AUTH_SECRET: 'x'.repeat(32) })).toEqual([
'1click-monad',
@@ -654,6 +657,7 @@ describe('the Apps page rows', () => {
it('shows each chain its own row and no venue that does not name it', () => {
expect(on('avalanche')).toEqual([
'1click-avalanche',
+ 'cow-limit',
'uniswap-avalanche',
'pharaoh',
'aave-avalanche',
@@ -1469,7 +1473,9 @@ describe('submit: what the chain says the wallet sent', () => {
const deposit = (receiptTo: string, status: string, transaction: () => Promise) =>
createEvmDeposit('avalanche', () => ({
tokenBalance: async () => BigInt(0),
+ allowance: async () => BigInt(0),
nativeBalance: async () => BigInt(0),
+ code: async () => '0x',
waitForReceipt: async () => receipt({ status, to: receiptTo }) as unknown as EvmReceipt,
transaction: transaction as EvmClient['transaction'],
}))
diff --git a/apps/dapp/lib/__tests__/fastlane.test.ts b/apps/dapp/lib/__tests__/fastlane.test.ts
index 92d5cd18..19b42573 100644
--- a/apps/dapp/lib/__tests__/fastlane.test.ts
+++ b/apps/dapp/lib/__tests__/fastlane.test.ts
@@ -121,6 +121,8 @@ function fakeClient(
return {
tokenBalance: async () => BigInt(0),
nativeBalance: async () => BigInt(0),
+ allowance: async () => BigInt(0),
+ code: async () => undefined,
waitForReceipt: async () =>
({ status, from: ACCOUNT, to: SHMON, logs: [] }) as unknown as EvmReceipt,
transaction: async () => sent,
diff --git a/apps/dapp/lib/__tests__/market-context-evm.test.ts b/apps/dapp/lib/__tests__/market-context-evm.test.ts
index 463849b2..72c1b8d4 100644
--- a/apps/dapp/lib/__tests__/market-context-evm.test.ts
+++ b/apps/dapp/lib/__tests__/market-context-evm.test.ts
@@ -27,9 +27,11 @@ describe('the market on EVM chains that share a family', () => {
const ids = (chain: string): string[] =>
buildMarketContext(chain, {}, 'mainnet').venues.map((v) => v.id)
expect(ids('avalanche')).toContain('on-avalanche')
+ expect(ids('avalanche')).toContain('cow-limit')
expect(ids('avalanche')).not.toContain('on-monad')
expect(ids('avalanche')).not.toContain('aave-monad')
expect(ids('monad')).toContain('on-monad')
+ expect(ids('monad')).not.toContain('cow-limit')
expect(ids('monad')).not.toContain('on-avalanche')
expect(ids('monad')).not.toContain('aave-avalanche')
})
diff --git a/apps/dapp/lib/__tests__/near-rhea-dcl.live.test.ts b/apps/dapp/lib/__tests__/near-rhea-dcl.live.test.ts
new file mode 100644
index 00000000..49d53115
--- /dev/null
+++ b/apps/dapp/lib/__tests__/near-rhea-dcl.live.test.ts
@@ -0,0 +1,268 @@
+import { describe, expect, it } from 'vitest'
+
+import { rheaBook, rheaVenue } from '../chains/near/rhea-dcl'
+import {
+ DCL_CONTRACT,
+ DCL_POOL,
+ DCL_STORAGE_CAP,
+ pointForPrice,
+ priceAtPoint,
+} from '../chains/near/rhea-dcl-rules'
+import { viewCall } from '../chains/near/rpc'
+import { USDC_NEAR, WRAP_NEAR } from '../chains/near/tokens'
+import type { ChainActionRequest, NearTransaction, SigningPayload } from '../chains/types'
+
+/**
+ * Against mainnet, reading only: NEAR has no simulation for a contract call that changes state, so
+ * nothing here runs a transaction. Each account named is public, and its key isn't held here. Run by
+ * this exact file name.
+ */
+
+/** A Ref operator, with an order resting far from the market since February 2026. */
+const OWNER = 'doublerose.near'
+const ORDER = `${DCL_POOL}#8665`
+const ORDER_POINT = 415439
+const STRANGER = 'nobody-has-this-account-at-all.near'
+const SLOW_MS = 60_000
+
+interface PoolRow {
+ token_x: string
+ token_y: string
+ fee: number
+ point_delta: number
+ current_point: number
+ state: string
+ tax: unknown
+}
+
+interface OrderRow {
+ order_id: string
+ owner_id: string
+ pool_id: string
+ point: number
+ sell_token: string
+ buy_token: string
+ original_amount: string
+ remain_amount: string
+ bought_amount: string
+ unclaimed_amount: string | null
+}
+
+const view = (method: string, args: Record): Promise =>
+ viewCall(DCL_CONTRACT, method, args)
+const readPool = (): Promise => view('get_pool', { pool_id: DCL_POOL })
+
+const txs = (signing: SigningPayload): NearTransaction[] =>
+ (signing as Extract).transactions
+
+describe.skipIf(process.env['SKIP_LIVE'] === '1')('Rhea DCL on mainnet', () => {
+ it(
+ 'has the pool the rules are pinned to',
+ async () => {
+ const pool = await readPool()
+ console.log('pool', pool)
+ expect(pool.token_x).toBe(USDC_NEAR)
+ expect(pool.token_y).toBe(WRAP_NEAR)
+ expect(pool.fee).toBe(100)
+ expect(pool.point_delta).toBe(1)
+ expect(pool.state).toBe('Running')
+ expect(pool.tax).toBeNull()
+ const metadata = await view<{ state: string; version: string }>('get_metadata', {})
+ console.log('metadata', metadata)
+ expect(metadata.state).toBe('Running')
+ },
+ SLOW_MS
+ )
+
+ it(
+ 'reads a real order through get_order, with every field the venue relies on',
+ async () => {
+ const order = await view('get_order', { order_id: ORDER })
+ console.log('order', order)
+ expect(order.order_id).toBe(ORDER)
+ expect(order.owner_id).toBe(OWNER)
+ expect(order.pool_id).toBe(DCL_POOL)
+ expect(order.point).toBe(ORDER_POINT)
+ expect(order.sell_token).toBe(USDC_NEAR)
+ expect(order.buy_token).toBe(WRAP_NEAR)
+ for (const field of ['original_amount', 'remain_amount', 'bought_amount']) {
+ expect(order[field as keyof OrderRow]).toMatch(/^\d+$/)
+ }
+ expect(order.unclaimed_amount).toMatch(/^\d+$/)
+ expect(await view('get_order', { order_id: `${DCL_POOL}#1` })).toBeNull()
+ expect(
+ await view('find_order', {
+ account_id: OWNER,
+ pool_id: DCL_POOL,
+ point: ORDER_POINT,
+ })
+ ).toMatchObject({ order_id: ORDER })
+ expect(
+ await view('find_order', {
+ account_id: OWNER,
+ pool_id: DCL_POOL,
+ point: ORDER_POINT + 1,
+ })
+ ).toBeNull()
+ },
+ SLOW_MS
+ )
+
+ it(
+ 'finds no sign flip: on this pool a buy of NEAR rests above the market and a sale below it',
+ async () => {
+ const pool = await readPool()
+ const market = priceAtPoint(pool.current_point)
+ console.log('market', { point: pool.current_point, usdcPerNear: market })
+ expect(ORDER_POINT).toBeGreaterThan(pool.current_point)
+ const price = priceAtPoint(ORDER_POINT)
+ console.log('order price', price)
+ expect(Math.abs(pointForPrice(price * 1.000001, false, 1) - ORDER_POINT)).toBeLessThan(10)
+
+ const book = await view>(
+ 'get_pointorder_range',
+ {
+ pool_id: DCL_POOL,
+ left_point: pool.current_point - 2000,
+ right_point: pool.current_point + 2000,
+ }
+ )
+ const entries = Object.entries(book).map(([point, side]) => ({
+ point: Number(point),
+ sellsUsdc: BigInt(side.amount_x) > BigInt(0),
+ sellsNear: BigInt(side.amount_y) > BigInt(0),
+ }))
+ console.log('book', entries)
+ expect(entries.length).toBeGreaterThan(0)
+ // Read at one finality and the pool at another, so the market point itself proves nothing.
+ for (const { point, sellsUsdc, sellsNear } of entries) {
+ if (point === pool.current_point) continue
+ if (sellsNear) expect(point).toBeLessThan(pool.current_point + 50)
+ if (sellsUsdc) expect(point).toBeGreaterThan(pool.current_point - 50)
+ }
+ },
+ SLOW_MS
+ )
+
+ it(
+ 'reads storage: a record for a real user, nothing for a stranger, and a bound under the cap',
+ async () => {
+ const detail = await view>('get_user_storage_detail', {
+ user_id: OWNER,
+ })
+ console.log('storage', detail)
+ for (const field of ['max_slots', 'cur_order_slots', 'cur_liquidity_slots']) {
+ expect(typeof detail[field]).toBe('number')
+ }
+ expect(await view('get_user_storage_detail', { user_id: STRANGER })).toBeNull()
+ const bounds = await view<{ min: string }>('storage_balance_bounds', {})
+ console.log('bounds', bounds)
+ expect(BigInt(bounds.min) <= DCL_STORAGE_CAP).toBe(true)
+ },
+ SLOW_MS
+ )
+
+ const away = async (above: boolean): Promise => {
+ const { current_point: point } = await readPool()
+ const price = priceAtPoint(point) * (above ? 1.05 : 0.95)
+ return price.toFixed(4)
+ }
+
+ it(
+ 'builds an order for an account with its record, which passes its own check',
+ async () => {
+ const requests: ChainActionRequest[] = [
+ {
+ kind: 'limit',
+ amount: '1',
+ tokenIn: 'NEAR',
+ tokenOut: 'USDC',
+ limitPrice: await away(true),
+ },
+ {
+ kind: 'limit',
+ amount: '2',
+ tokenIn: 'USDC',
+ tokenOut: 'NEAR',
+ limitPrice: await away(false),
+ },
+ ]
+ for (const request of requests) {
+ const prepared = await rheaVenue.prepare(request, OWNER)
+ console.log(prepared.summary, prepared.context)
+ console.log(JSON.stringify(txs(prepared.signing)))
+ rheaVenue.assertSafe(prepared.signing, OWNER, prepared.context)
+ expect(prepared.thenPrepareAgain).toBeUndefined()
+ expect(txs(prepared.signing)).toHaveLength(1)
+ }
+ },
+ SLOW_MS
+ )
+
+ it(
+ 'asks a stranger to set up first, and nothing else',
+ async () => {
+ const prepared = await rheaVenue.prepare(
+ {
+ kind: 'limit',
+ amount: '2',
+ tokenIn: 'USDC',
+ tokenOut: 'NEAR',
+ limitPrice: await away(false),
+ },
+ STRANGER
+ )
+ console.log(prepared.summary, JSON.stringify(txs(prepared.signing)))
+ expect(prepared.thenPrepareAgain).toBe(true)
+ expect(txs(prepared.signing).map((t) => t.receiverId)).toEqual([DCL_CONTRACT, WRAP_NEAR])
+ rheaVenue.assertSafe(prepared.signing, STRANGER, prepared.context)
+ },
+ SLOW_MS
+ )
+
+ it(
+ 'refuses a price past the pool, whichever side',
+ async () => {
+ const refusal = (request: ChainActionRequest) =>
+ rheaVenue.prepare(request, OWNER).then(
+ () => undefined,
+ (e: unknown) => (e instanceof Error ? e.message : String(e))
+ )
+ const sale = await refusal({
+ kind: 'limit',
+ amount: '1',
+ tokenIn: 'NEAR',
+ tokenOut: 'USDC',
+ limitPrice: await away(false),
+ })
+ const purchase = await refusal({
+ kind: 'limit',
+ amount: '2',
+ tokenIn: 'USDC',
+ tokenOut: 'NEAR',
+ limitPrice: await away(true),
+ })
+ console.log(sale, purchase)
+ expect(sale).toMatch(/fill at once.*Nothing was signed or sent\.$/)
+ expect(purchase).toMatch(/fill at once.*Nothing was signed or sent\.$/)
+ },
+ SLOW_MS
+ )
+
+ it(
+ 'lists a real order, builds its cancel, and refuses it to anyone else',
+ async () => {
+ const rows = await rheaBook().openOrders(OWNER)
+ console.log('rows', rows)
+ expect(rows.map((r) => r.id)).toContain(ORDER)
+ const prepared = await rheaBook().prepareExit(OWNER, ORDER, 'cancel')
+ console.log(prepared.summary, JSON.stringify(txs(prepared.signing)))
+ rheaVenue.assertSafe(prepared.signing, OWNER, prepared.context)
+ await expect(rheaBook().prepareExit(STRANGER, ORDER, 'cancel')).rejects.toThrow(
+ /belongs to another wallet/
+ )
+ expect(await rheaBook().openOrders(STRANGER)).toEqual([])
+ },
+ SLOW_MS
+ )
+})
diff --git a/apps/dapp/lib/__tests__/near-rhea-dcl.test.ts b/apps/dapp/lib/__tests__/near-rhea-dcl.test.ts
new file mode 100644
index 00000000..b4ac1287
--- /dev/null
+++ b/apps/dapp/lib/__tests__/near-rhea-dcl.test.ts
@@ -0,0 +1,1129 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+import { nearCapabilities } from '../chains/near/capabilities'
+import { nearModule } from '../chains/near/module'
+import { rheaBook, rheaVenue } from '../chains/near/rhea-dcl'
+import { assertRestingPoint, pointForPrice } from '../chains/near/rhea-dcl-rules'
+import {
+ assertDclExitSafe,
+ assertDclOrderSafe,
+ assertMetapoolSafe,
+ assertOneClickSafe,
+ assertRnearSafe,
+ assertTokenTransferSafe,
+ STORAGE_DEPOSIT_YOCTO,
+} from '../chains/near/safety'
+import { nearVenues } from '../chains/near/venues'
+import { assertLimitCanRun, assertNativeReserve, prepareChainAction } from '../chains/prepare'
+import {
+ ChainActionRefused,
+ type ChainActionRequest,
+ type ChainModule,
+ type NearTransaction,
+ type PreparedAction,
+ type SigningPayload,
+} from '../chains/types'
+
+const ME = 'alice.near'
+const THIEF = 'mallory.near'
+const HASH = 'G6RQqbkHNF1JVMwmq9BiLnZ3vvUeYoNm7z4dYVEKgScA'
+const HASH_2 = 'H6RQqbkHNF1JVMwmq9BiLnZ3vvUeYoNm7z4dYVEKgScA'
+const DCL = 'dclv2.ref-labs.near'
+const USDC = '17208628f84f5d6ad33f0da3bbbeb27ffcb398eac501a31bd6ad2011e36133a1'
+const USDT = 'usdt.tether-token.near'
+const WRAP = 'wrap.near'
+const POOL = `${USDC}|${WRAP}|100`
+const ORDER = `${POOL}#8665`
+const ONE_NEAR = '1000000000000000000000000'
+const HALF_NEAR = '500000000000000000000000'
+const TG30 = '30000000000000'
+const TG180 = '180000000000000'
+const TG250 = '250000000000000'
+const SALE_POINT = 400134
+const BUY_POINT = 400876
+
+const NOTES = [
+ 'When it fills, press Collect to receive it. That needs a second signature.',
+ 'First use sets aside up to 0.5 NEAR for storage on Rhea. I could not confirm it comes back.',
+ 'A purchase pays out in NEAR.',
+ 'This stays open until it fills or you cancel it.',
+]
+
+type Action = NearTransaction['actions'][number]
+
+function call(
+ methodName: string,
+ args: Record,
+ deposit: string,
+ gas = TG30
+): Action {
+ return { type: 'FunctionCall', params: { methodName, args, gas, deposit } }
+}
+
+const storage = (deposit = STORAGE_DEPOSIT_YOCTO, account = ME): Action =>
+ call('storage_deposit', { account_id: account, registration_only: true }, deposit)
+
+const msg = (buyToken: string, point: number): string =>
+ JSON.stringify({
+ LimitOrderWithSwap: { client_id: '', pool_id: POOL, buy_token: buyToken, point },
+ })
+
+const place = (amount: string, buyToken: string, point: number): Action =>
+ call('ft_transfer_call', { receiver_id: DCL, amount, msg: msg(buyToken, point) }, '1', TG250)
+
+const payload = (...transactions: NearTransaction[]): SigningPayload => ({
+ family: 'near',
+ transactions,
+})
+
+/** The two token transactions the app builds, each as `expected` describes it. */
+const SALE = { sellToken: WRAP, amount: ONE_NEAR, point: SALE_POINT }
+const BUY = { sellToken: USDC, amount: '10000000', point: BUY_POINT }
+const sale = (actions?: Action[]): NearTransaction => ({
+ receiverId: WRAP,
+ actions: actions ?? [call('near_deposit', {}, ONE_NEAR), place(ONE_NEAR, USDC, SALE_POINT)],
+})
+const buy = (actions?: Action[]): NearTransaction => ({
+ receiverId: USDC,
+ actions: actions ?? [place('10000000', WRAP, BUY_POINT)],
+})
+
+const STORAGE_DETAIL = {
+ sponsor_id: ME,
+ locked_near: HALF_NEAR,
+ storage_for_asset: '100000000000000000000000',
+ slot_price: '10000000000000000000000',
+ max_slots: 40,
+ cur_order_slots: 1,
+ cur_liquidity_slots: 0,
+}
+
+const OPEN_ORDER = {
+ client_id: '',
+ order_id: ORDER,
+ owner_id: ME,
+ pool_id: POOL,
+ point: BUY_POINT,
+ sell_token: USDC,
+ buy_token: WRAP,
+ original_deposit_amount: '10000000',
+ swap_earn_amount: '0',
+ original_amount: '10000000',
+ cancel_amount: '0',
+ created_at: '1770886735876414598',
+ remain_amount: '10000000',
+ bought_amount: '0',
+ unclaimed_amount: '0',
+}
+
+interface Rpc {
+ views: Record
+ down: boolean
+ /** Views that throw, by `contract.method`. */
+ downViews: string[]
+ tx: Record
+}
+
+let rpc: Rpc
+
+function poolRow(over: Record = {}): Record {
+ return {
+ pool_id: POOL,
+ token_x: USDC,
+ token_y: WRAP,
+ fee: 100,
+ point_delta: 1,
+ current_point: 400630,
+ state: 'Running',
+ whitelist: null,
+ tax: null,
+ ...over,
+ }
+}
+
+function install(): void {
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn(async (_input: string | URL, init?: RequestInit) => {
+ const json = (data: unknown): Response => new Response(JSON.stringify(data))
+ if (rpc.down) throw new Error('down')
+ const body = JSON.parse(init?.body as string) as {
+ method: string
+ params: {
+ tx_hash?: string
+ account_id?: string
+ method_name?: string
+ args_base64?: string
+ }
+ }
+ if (body.method === 'tx') {
+ const result = rpc.tx[body.params.tx_hash ?? '']
+ return json(
+ result === undefined ? { error: { cause: { name: 'UNKNOWN_TRANSACTION' } } } : { result }
+ )
+ }
+ const key = `${body.params.account_id}.${body.params.method_name}`
+ if (rpc.downViews.includes(key)) return json({ result: { error: `no ${key}` } })
+ if (!(key in rpc.views)) return json({ result: { error: `no ${key}` } })
+ return json({ result: { result: [...Buffer.from(JSON.stringify(rpc.views[key]))] } })
+ })
+ )
+}
+
+/** An account that has done all of the setup. */
+function settled(): Rpc['views'] {
+ return {
+ [`${DCL}.get_pool`]: poolRow(),
+ [`${DCL}.get_metadata`]: { state: 'Running' },
+ [`${DCL}.get_user_storage_detail`]: STORAGE_DETAIL,
+ [`${DCL}.storage_balance_bounds`]: { min: HALF_NEAR, max: null },
+ [`${DCL}.find_order`]: null,
+ [`${DCL}.get_order`]: OPEN_ORDER,
+ [`${DCL}.list_active_orders`]: [OPEN_ORDER],
+ [`${USDC}.storage_balance_of`]: { total: STORAGE_DEPOSIT_YOCTO, available: '0' },
+ [`${WRAP}.storage_balance_of`]: { total: STORAGE_DEPOSIT_YOCTO, available: '0' },
+ }
+}
+
+beforeEach(() => {
+ rpc = { views: settled(), down: false, downViews: [], tx: {} }
+ install()
+})
+
+afterEach(() => {
+ vi.useRealTimers()
+ vi.unstubAllGlobals()
+})
+
+const selling = (over: Partial = {}): ChainActionRequest => ({
+ kind: 'limit',
+ amount: '1',
+ tokenIn: 'NEAR',
+ tokenOut: 'USDC',
+ limitPrice: '4.2',
+ ...over,
+})
+const buying = (over: Partial = {}): ChainActionRequest => ({
+ kind: 'limit',
+ amount: '10',
+ tokenIn: 'USDC',
+ tokenOut: 'NEAR',
+ limitPrice: '3.9',
+ ...over,
+})
+
+function refused(fn: () => void): void {
+ expect(fn).toThrow(ChainActionRefused)
+ expect(fn).toThrow(/Nothing was signed or sent\.$/)
+}
+
+function txs(prepared: PreparedAction): NearTransaction[] {
+ return (prepared.signing as Extract).transactions
+}
+
+function firstUse(): void {
+ rpc.views[`${DCL}.get_user_storage_detail`] = null
+ rpc.views[`${USDC}.storage_balance_of`] = null
+ rpc.views[`${WRAP}.storage_balance_of`] = null
+}
+
+describe('pointForPrice', () => {
+ // USDC per NEAR = 1e18 / 1.0001^point, read from orders resting on this pool on 2026-11-05.
+ it('sells NEAR at a point below the market, and no cheaper than asked', () => {
+ const point = pointForPrice(4.29, true, 1)
+ expect(Math.abs(point - 399922)).toBeLessThan(10)
+ expect(1e18 / 1.0001 ** point).toBeGreaterThanOrEqual(4.29)
+ })
+ it('buys NEAR at a point above the market, and no dearer than asked', () => {
+ const point = pointForPrice(0.909, false, 1)
+ expect(Math.abs(point - 415439)).toBeLessThan(10)
+ expect(1e18 / 1.0001 ** point).toBeLessThanOrEqual(0.909)
+ })
+ it('keeps to the pool step', () => {
+ expect(pointForPrice(4.29, true, 40) % 40).toBe(0)
+ })
+ it('stays on the right side of the price on a wide step', () => {
+ const sale = pointForPrice(4.29, true, 40)
+ const purchase = pointForPrice(4.29, false, 40)
+ expect(1e18 / 1.0001 ** sale).toBeGreaterThanOrEqual(4.29)
+ expect(1e18 / 1.0001 ** purchase).toBeLessThanOrEqual(4.29)
+ expect(purchase - sale).toBe(40)
+ })
+ it.each([0, -1, Number.NaN, Number.POSITIVE_INFINITY])('refuses a price of %s', (price) => {
+ refused(() => pointForPrice(price, true, 1))
+ })
+})
+
+describe('an order must never be marketable', () => {
+ it('passes a sale below the current point and a purchase above it', () => {
+ expect(() => assertRestingPoint(400629, 400630, true)).not.toThrow()
+ expect(() => assertRestingPoint(400631, 400630, false)).not.toThrow()
+ })
+ it('refuses a sale at or above the current point', () => {
+ refused(() => assertRestingPoint(400630, 400630, true))
+ refused(() => assertRestingPoint(400631, 400630, true))
+ })
+ it('refuses a purchase at or below the current point', () => {
+ refused(() => assertRestingPoint(400630, 400630, false))
+ refused(() => assertRestingPoint(400629, 400630, false))
+ })
+ it('stops a price that has crossed the pool, whatever the other market said', async () => {
+ rpc.views[`${DCL}.get_pool`] = poolRow({ current_point: SALE_POINT - 1 })
+ await expect(rheaVenue.prepare(selling(), ME)).rejects.toThrow(/Nothing was signed or sent\.$/)
+ rpc.views[`${DCL}.get_pool`] = poolRow({ current_point: BUY_POINT + 1 })
+ await expect(rheaVenue.prepare(buying(), ME)).rejects.toThrow(/Nothing was signed or sent\.$/)
+ })
+ it('asks for no storage on an order it would refuse anyway', async () => {
+ firstUse()
+ rpc.views[`${DCL}.get_pool`] = poolRow({ current_point: SALE_POINT })
+ await expect(rheaVenue.prepare(selling(), ME)).rejects.toThrow(/Nothing was signed or sent\.$/)
+ })
+})
+
+describe('first use', () => {
+ it('pays for storage first, on its own, in order, then asks to be prepared again', async () => {
+ firstUse()
+ const prepared = await rheaVenue.prepare(selling(), ME)
+ expect(prepared.thenPrepareAgain).toBe(true)
+ expect(txs(prepared).map((t) => t.receiverId)).toEqual([DCL, USDC, WRAP])
+ expect(txs(prepared)[0]?.actions).toEqual([
+ call('storage_deposit', { account_id: ME, registration_only: true }, HALF_NEAR),
+ ])
+ expect(txs(prepared)[1]?.actions).toEqual([storage()])
+ expect(txs(prepared)[2]?.actions).toEqual([storage()])
+ expect(prepared.pay).toEqual({ amount: '0.5025', symbol: 'NEAR' })
+ expect(() => rheaVenue.assertSafe(prepared.signing, ME, prepared.context)).not.toThrow()
+ })
+
+ it('walks by what the chain says, until only the order is left', async () => {
+ firstUse()
+ expect(txs(await rheaVenue.prepare(buying(), ME)).map((t) => t.receiverId)).toEqual([DCL, WRAP])
+
+ rpc.views[`${DCL}.get_user_storage_detail`] = STORAGE_DETAIL
+ const buyToken = await rheaVenue.prepare(buying(), ME)
+ expect(txs(buyToken).map((t) => t.receiverId)).toEqual([WRAP])
+ expect(buyToken.thenPrepareAgain).toBe(true)
+
+ rpc.views[`${WRAP}.storage_balance_of`] = { total: STORAGE_DEPOSIT_YOCTO, available: '0' }
+ const order = await rheaVenue.prepare(buying(), ME)
+ expect(order.thenPrepareAgain).toBeUndefined()
+ expect(txs(order).map((t) => t.receiverId)).toEqual([USDC])
+ })
+
+ it('wraps native NEAR before selling it, in the one token transaction', async () => {
+ const prepared = await rheaVenue.prepare(selling(), ME)
+ expect(prepared.thenPrepareAgain).toBeUndefined()
+ expect(txs(prepared)).toEqual([sale()])
+ expect(() => rheaVenue.assertSafe(prepared.signing, ME, prepared.context)).not.toThrow()
+ })
+
+ it('never carries storage and the order in one signature', async () => {
+ firstUse()
+ const prepared = await rheaVenue.prepare(selling(), ME)
+ const methods = txs(prepared).flatMap((t) => t.actions.map((a) => a.params.methodName))
+ expect(methods).not.toContain('ft_transfer_call')
+ expect(methods).not.toContain('near_deposit')
+ })
+
+ it('refuses when Rhea asks for more storage than the cap', async () => {
+ firstUse()
+ rpc.views[`${DCL}.storage_balance_bounds`] = { min: '500000000000000000000001', max: null }
+ await expect(rheaVenue.prepare(selling(), ME)).rejects.toThrow(/Nothing was signed or sent\.$/)
+ })
+})
+
+describe('the order', () => {
+ it('builds a purchase from exactly what was asked', async () => {
+ const prepared = await rheaVenue.prepare(buying(), ME)
+ expect(txs(prepared)).toEqual([buy()])
+ expect(prepared.venueId).toBe('rhea-limit')
+ expect(prepared.venueName).toBe('Rhea')
+ expect(prepared.kind).toBe('limit')
+ expect(prepared.pay).toEqual({ amount: '10', symbol: 'USDC' })
+ expect(prepared.receive).toEqual({ amount: '2.564102', symbol: 'NEAR', approximate: true })
+ expect(prepared.notes).toEqual(NOTES)
+ expect(() => rheaVenue.assertSafe(prepared.signing, ME, prepared.context)).not.toThrow()
+ })
+
+ it('pays a sale out in USDC', async () => {
+ const prepared = await rheaVenue.prepare(selling(), ME)
+ expect(prepared.pay).toEqual({ amount: '1', symbol: 'NEAR' })
+ expect(prepared.receive).toEqual({ amount: '4.2', symbol: 'USDC', approximate: true })
+ expect(prepared.notes).toEqual(NOTES)
+ })
+
+ it.each([
+ ['USDT', { tokenOut: 'USDT' }],
+ ['SOL', { tokenIn: 'SOL' }],
+ ])('only trades USDC and NEAR, not %s', async (_name, over) => {
+ await expect(rheaVenue.prepare(selling(over), ME)).rejects.toThrow(
+ /only trades NEAR against USDC\. Nothing was signed or sent\.$/
+ )
+ })
+
+ it('refuses another kind of action', async () => {
+ await expect(rheaVenue.prepare(selling({ kind: 'swap' }), ME)).rejects.toThrow(
+ /Nothing was signed or sent\.$/
+ )
+ })
+
+ it('refuses an account that is not a NEAR account', async () => {
+ await expect(rheaVenue.prepare(selling(), 'NOT A NEAR ACCOUNT!')).rejects.toThrow(
+ /Nothing was signed or sent\.$/
+ )
+ })
+
+ it('refuses more decimals than the token has, in the same words', async () => {
+ await expect(rheaVenue.prepare(buying({ amount: '1.1234567' }), ME)).rejects.toThrow(
+ /decimal places at most\. Nothing was signed or sent\.$/
+ )
+ })
+
+ it.each([
+ ['paused', { state: 'Paused' }],
+ ['with a tax', { tax: { rate: 1 } }],
+ ['with another step', { point_delta: 40 }],
+ ['with other tokens', { token_x: USDT }],
+ ])('refuses a pool that is %s', async (_name, over) => {
+ rpc.views[`${DCL}.get_pool`] = poolRow(over)
+ await expect(rheaVenue.prepare(selling(), ME)).rejects.toThrow(/Nothing was signed or sent\.$/)
+ })
+
+ it('refuses while the contract is not running', async () => {
+ rpc.views[`${DCL}.get_metadata`] = { state: 'Paused' }
+ await expect(rheaVenue.prepare(selling(), ME)).rejects.toThrow(/Nothing was signed or sent\.$/)
+ })
+
+ it('refuses in words when NEAR cannot be read', async () => {
+ rpc.down = true
+ await expect(rheaVenue.prepare(selling(), ME)).rejects.toThrow(
+ /I couldn’t read Rhea just now, so try again in a moment\. Nothing was signed or sent\.$/
+ )
+ })
+
+ it('refuses when the account has no slot left', async () => {
+ rpc.views[`${DCL}.get_user_storage_detail`] = {
+ ...STORAGE_DETAIL,
+ cur_order_slots: 30,
+ cur_liquidity_slots: 10,
+ }
+ await expect(rheaVenue.prepare(selling(), ME)).rejects.toThrow(/Nothing was signed or sent\.$/)
+ })
+
+ it('refuses a second order at the same price', async () => {
+ rpc.views[`${DCL}.find_order`] = OPEN_ORDER
+ await expect(rheaVenue.prepare(buying(), ME)).rejects.toThrow(/Nothing was signed or sent\.$/)
+ })
+})
+
+describe('assertDclOrderSafe', () => {
+ it('passes each step the app builds', () => {
+ expect(() => assertDclOrderSafe(payload(sale()), ME, SALE)).not.toThrow()
+ expect(() => assertDclOrderSafe(payload(buy()), ME, BUY)).not.toThrow()
+ expect(() =>
+ assertDclOrderSafe(
+ payload(
+ { receiverId: DCL, actions: [storage(HALF_NEAR)] },
+ { receiverId: USDC, actions: [storage()] },
+ { receiverId: WRAP, actions: [storage()] }
+ ),
+ ME,
+ SALE
+ )
+ ).not.toThrow()
+ expect(() =>
+ assertDclOrderSafe(payload({ receiverId: DCL, actions: [storage(HALF_NEAR)] }), ME, BUY)
+ ).not.toThrow()
+ })
+
+ const withTransfer = (over: Record, deposit = '1', gas = TG250): Action =>
+ call(
+ 'ft_transfer_call',
+ { receiver_id: DCL, amount: '10000000', msg: msg(WRAP, BUY_POINT), ...over },
+ deposit,
+ gas
+ )
+ const withMsg = (inner: Record): Action =>
+ withTransfer({
+ msg: JSON.stringify({
+ LimitOrderWithSwap: {
+ client_id: '',
+ pool_id: POOL,
+ buy_token: WRAP,
+ point: BUY_POINT,
+ ...inner,
+ },
+ }),
+ })
+
+ it.each<[string, NearTransaction]>([
+ ['another contract', buy([withTransfer({ receiver_id: 'evil.near' })])],
+ ['another pool', buy([withMsg({ pool_id: `${USDC}|${WRAP}|2000` })])],
+ ['another buy token', buy([withMsg({ buy_token: USDT })])],
+ ['a changed amount', buy([withTransfer({ amount: '10000001' })])],
+ ['a changed point', buy([withMsg({ point: BUY_POINT + 1 })])],
+ ['an extra key in msg', buy([withMsg({ extra: 1 })])],
+ ['a client id', buy([withMsg({ client_id: 'x' })])],
+ [
+ 'a different kind of msg',
+ buy([
+ withTransfer({
+ msg: JSON.stringify({ Deposit: {} }),
+ }),
+ ]),
+ ],
+ ['a msg that is not JSON', buy([withTransfer({ msg: 'not json' })])],
+ ['a deposit of nothing', buy([withTransfer({}, '0')])],
+ ['a deposit of more than 1 yocto', buy([withTransfer({}, '2')])],
+ ['gas above 250 TGas', buy([withTransfer({}, '1', '250000000000001')])],
+ ['an extra argument', buy([withTransfer({ memo: 'x' })])],
+ ['a call on another token', { receiverId: USDT, actions: [withTransfer({})] }],
+ ['a call on an unknown contract', { receiverId: 'evil.near', actions: [withTransfer({})] }],
+ ['a wrap beside a USDC order', buy([call('near_deposit', {}, ONE_NEAR), withTransfer({})])],
+ ['a second transfer', buy([withTransfer({}), withTransfer({})])],
+ ['a plain transfer out', buy([call('ft_transfer', { receiver_id: THIEF, amount: '1' }, '1')])],
+ ])('refuses %s', (_name, transaction) => {
+ refused(() => assertDclOrderSafe(payload(transaction), ME, BUY))
+ })
+
+ it('refuses a sale that does not wrap what it sells', () => {
+ refused(() => assertDclOrderSafe(payload(sale([place(ONE_NEAR, USDC, SALE_POINT)])), ME, SALE))
+ refused(() =>
+ assertDclOrderSafe(
+ payload(sale([call('near_deposit', {}, HALF_NEAR), place(ONE_NEAR, USDC, SALE_POINT)])),
+ ME,
+ SALE
+ )
+ )
+ })
+
+ it('refuses storage above 0.5 NEAR', () => {
+ refused(() =>
+ assertDclOrderSafe(
+ payload({ receiverId: DCL, actions: [storage('500000000000000000000001')] }),
+ ME,
+ BUY
+ )
+ )
+ })
+
+ it.each<[string, NearTransaction]>([
+ ['for someone else', { receiverId: DCL, actions: [storage(HALF_NEAR, THIEF)] }],
+ ['of nothing', { receiverId: DCL, actions: [storage('0')] }],
+ ['on another contract', { receiverId: 'evil.near', actions: [storage()] }],
+ ['on a token the order does not touch', { receiverId: USDT, actions: [storage()] }],
+ [
+ 'beside something else',
+ { receiverId: DCL, actions: [storage(HALF_NEAR), call('cancel_order', {}, '0')] },
+ ],
+ [
+ 'that is not registration only',
+ {
+ receiverId: DCL,
+ actions: [call('storage_deposit', { account_id: ME }, HALF_NEAR)],
+ },
+ ],
+ [
+ 'with too much gas',
+ {
+ receiverId: DCL,
+ actions: [
+ call(
+ 'storage_deposit',
+ { account_id: ME, registration_only: true },
+ HALF_NEAR,
+ '30000000000001'
+ ),
+ ],
+ },
+ ],
+ ['above the token amount', { receiverId: WRAP, actions: [storage(HALF_NEAR)] }],
+ ])('refuses a storage payment %s', (_name, transaction) => {
+ refused(() => assertDclOrderSafe(payload(transaction), ME, BUY))
+ })
+
+ it('refuses storage beside the order, in any order', () => {
+ const paid: NearTransaction = { receiverId: DCL, actions: [storage(HALF_NEAR)] }
+ refused(() => assertDclOrderSafe(payload(paid, buy()), ME, BUY))
+ refused(() => assertDclOrderSafe(payload(buy(), paid), ME, BUY))
+ refused(() => assertDclOrderSafe(payload(buy(), buy()), ME, BUY))
+ })
+
+ it('refuses storage out of order, or twice', () => {
+ const on = (receiverId: string, deposit = STORAGE_DEPOSIT_YOCTO): NearTransaction => ({
+ receiverId,
+ actions: [storage(deposit)],
+ })
+ refused(() => assertDclOrderSafe(payload(on(USDC), on(DCL, HALF_NEAR)), ME, SALE))
+ refused(() => assertDclOrderSafe(payload(on(USDC), on(USDC)), ME, SALE))
+ })
+
+ it('refuses a token this order does not sell, and a point that is not one', () => {
+ refused(() => assertDclOrderSafe(payload(buy()), ME, { ...BUY, sellToken: USDT }))
+ refused(() => assertDclOrderSafe(payload(buy()), ME, { ...BUY, point: BUY_POINT + 0.5 }))
+ })
+
+ it('refuses another family, a bad account, and no transactions', () => {
+ refused(() =>
+ assertDclOrderSafe({ family: 'solana', transaction: 'AAAA' } as SigningPayload, ME, BUY)
+ )
+ refused(() => assertDclOrderSafe(payload(buy()), 'NOT A NEAR ACCOUNT!', BUY))
+ refused(() => assertDclOrderSafe(payload(), ME, BUY))
+ })
+
+ it('refuses a transaction with another shape', () => {
+ const odd = { ...buy(), signerId: THIEF } as NearTransaction
+ refused(() => assertDclOrderSafe(payload(odd), ME, BUY))
+ })
+})
+
+describe('ft_transfer_call stays refused everywhere else', () => {
+ it.each<[string, (p: SigningPayload) => void]>([
+ ['1Click', (p) => assertOneClickSafe(p, ME)],
+ ['a token transfer', (p) => assertTokenTransferSafe(p, ME, /^.*$/)],
+ ['rNEAR', (p) => assertRnearSafe(p, ME)],
+ ['Meta Pool', (p) => assertMetapoolSafe(p, ME)],
+ ])('%s refuses a Rhea order', (_name, check) => {
+ expect(() => check(payload(buy()))).toThrow(ChainActionRefused)
+ expect(() => check(payload(sale()))).toThrow(ChainActionRefused)
+ })
+})
+
+describe('assertDclExitSafe', () => {
+ const exit = (actions: Action[], receiverId = DCL): SigningPayload =>
+ payload({ receiverId, actions })
+ const cancel = (
+ args: Record = { order_id: ORDER, amount: '10000000' },
+ deposit = '0',
+ gas = TG180
+ ): Action => call('cancel_order', args, deposit, gas)
+
+ it('passes a cancel and a collect', () => {
+ expect(() => assertDclExitSafe(exit([cancel()]), ME, ORDER)).not.toThrow()
+ expect(() =>
+ assertDclExitSafe(exit([cancel({ order_id: ORDER, amount: '0' })]), ME, ORDER)
+ ).not.toThrow()
+ })
+
+ it.each<[string, SigningPayload]>([
+ ['another contract', exit([cancel()], 'evil.near')],
+ ['another order', exit([cancel({ order_id: `${POOL}#1`, amount: '0' })])],
+ ['a deposit', exit([cancel(undefined, '1')])],
+ ['gas above 180 TGas', exit([cancel(undefined, '0', '180000000000001')])],
+ ['an extra argument', exit([cancel({ order_id: ORDER, amount: '0', skip_unwrap_near: true })])],
+ ['no amount', exit([cancel({ order_id: ORDER })])],
+ ['an amount that is not one', exit([cancel({ order_id: ORDER, amount: '-1' })])],
+ ['another method', exit([call('storage_withdraw', {}, '1', TG180)])],
+ ['a second call', exit([cancel(), cancel()])],
+ [
+ 'a second transaction',
+ payload({ receiverId: DCL, actions: [cancel()] }, { receiverId: DCL, actions: [cancel()] }),
+ ],
+ ])('refuses %s', (_name, signing) => {
+ refused(() => assertDclExitSafe(signing, ME, ORDER))
+ })
+
+ it('refuses an order on another pool, even when it is the one named', () => {
+ const other = `${USDC}|${WRAP}|2000#1`
+ refused(() => assertDclExitSafe(exit([cancel({ order_id: other, amount: '0' })]), ME, other))
+ })
+})
+
+describe('openOrders', () => {
+ it('reads what rests, and only on this pool', async () => {
+ rpc.views[`${DCL}.list_active_orders`] = [
+ OPEN_ORDER,
+ { ...OPEN_ORDER, order_id: `${USDT}|${WRAP}|100#1`, pool_id: `${USDT}|${WRAP}|100` },
+ ]
+ const rows = await rheaBook().openOrders(ME)
+ expect(rows).toEqual([
+ {
+ id: ORDER,
+ venueId: 'rhea-limit',
+ tokenIn: 'USDC',
+ tokenOut: 'NEAR',
+ amountIn: '10',
+ remainingIn: '10',
+ receivedOut: '0',
+ limitPrice: '3.89984',
+ status: 'open',
+ },
+ ])
+ })
+
+ it('marks what waits to be collected', async () => {
+ rpc.views[`${DCL}.list_active_orders`] = [
+ {
+ ...OPEN_ORDER,
+ remain_amount: '4000000',
+ bought_amount: ONE_NEAR,
+ unclaimed_amount: ONE_NEAR,
+ },
+ ]
+ const [row] = await rheaBook().openOrders(ME)
+ expect(row?.status).toBe('partly')
+ expect(row?.remainingIn).toBe('4')
+ expect(row?.receivedOut).toBe('1')
+ expect(row?.collectable).toEqual([{ amount: '1', symbol: 'NEAR' }])
+ expect(row?.note).toMatch(/Collect/)
+ })
+
+ it('shows a filled order as filled, with its money still to collect', async () => {
+ rpc.views[`${DCL}.list_active_orders`] = [
+ {
+ ...OPEN_ORDER,
+ sell_token: WRAP,
+ buy_token: USDC,
+ original_amount: ONE_NEAR,
+ remain_amount: '0',
+ bought_amount: '4200000',
+ unclaimed_amount: '4200000',
+ },
+ ]
+ const [row] = await rheaBook().openOrders(ME)
+ expect(row?.status).toBe('filled')
+ expect(row?.tokenIn).toBe('NEAR')
+ expect(row?.tokenOut).toBe('USDC')
+ expect(row?.collectable).toEqual([{ amount: '4.2', symbol: 'USDC' }])
+ })
+
+ it('collects nothing where the chain says null', async () => {
+ rpc.views[`${DCL}.list_active_orders`] = [{ ...OPEN_ORDER, unclaimed_amount: null }]
+ const [row] = await rheaBook().openOrders(ME)
+ expect(row?.collectable).toBeUndefined()
+ })
+
+ it('answers an account with nothing as nothing', async () => {
+ rpc.views[`${DCL}.list_active_orders`] = []
+ expect(await rheaBook().openOrders(ME)).toEqual([])
+ })
+
+ it('refuses an account that is not a NEAR account', async () => {
+ await expect(rheaBook().openOrders('NOT A NEAR ACCOUNT!')).rejects.toThrow(ChainActionRefused)
+ })
+})
+
+describe('prepareExit', () => {
+ it('cancels with the remaining amount, no deposit, and 180 TGas', async () => {
+ const prepared = await rheaBook().prepareExit(ME, ORDER, 'cancel')
+ expect(txs(prepared)).toEqual([
+ {
+ receiverId: DCL,
+ actions: [call('cancel_order', { order_id: ORDER, amount: '10000000' }, '0', TG180)],
+ },
+ ])
+ expect(prepared.venueId).toBe('rhea-limit')
+ expect(prepared.pay).toEqual({ amount: '0', symbol: 'USDC' })
+ expect(prepared.receive).toEqual({ amount: '10', symbol: 'USDC', approximate: true })
+ expect(() => rheaVenue.assertSafe(prepared.signing, ME, prepared.context)).not.toThrow()
+ })
+
+ it('collects with an amount of zero', async () => {
+ rpc.views[`${DCL}.get_order`] = {
+ ...OPEN_ORDER,
+ remain_amount: '4000000',
+ bought_amount: ONE_NEAR,
+ unclaimed_amount: ONE_NEAR,
+ }
+ const prepared = await rheaBook().prepareExit(ME, ORDER, 'collect')
+ expect(txs(prepared)).toEqual([
+ {
+ receiverId: DCL,
+ actions: [call('cancel_order', { order_id: ORDER, amount: '0' }, '0', TG180)],
+ },
+ ])
+ expect(prepared.receive).toEqual({ amount: '1', symbol: 'NEAR', approximate: true })
+ expect(() => rheaVenue.assertSafe(prepared.signing, ME, prepared.context)).not.toThrow()
+ })
+
+ it('refuses an order that belongs to another wallet', async () => {
+ await expect(rheaBook().prepareExit(THIEF, ORDER, 'cancel')).rejects.toThrow(
+ /belongs to another wallet\. Nothing was signed or sent\.$/
+ )
+ await expect(rheaBook().prepareExit(THIEF, ORDER, 'collect')).rejects.toThrow(
+ /belongs to another wallet\. Nothing was signed or sent\.$/
+ )
+ })
+
+ it.each([
+ 'not-an-order',
+ `${USDT}|${WRAP}|100#1`,
+ `${USDC}|${WRAP}|2000#1`,
+ `${POOL}#`,
+ `${POOL}#1#2`,
+ `${POOL}#abc`,
+ ])('refuses %s', async (orderId) => {
+ await expect(rheaBook().prepareExit(ME, orderId, 'cancel')).rejects.toThrow(
+ /Nothing was signed or sent\.$/
+ )
+ })
+
+ it('refuses an order the chain no longer has', async () => {
+ rpc.views[`${DCL}.get_order`] = null
+ await expect(rheaBook().prepareExit(ME, ORDER, 'cancel')).rejects.toThrow(
+ /no longer open\. Nothing was signed or sent\.$/
+ )
+ })
+
+ it('refuses a cancel of a filled order, and says to collect', async () => {
+ rpc.views[`${DCL}.get_order`] = {
+ ...OPEN_ORDER,
+ remain_amount: '0',
+ bought_amount: ONE_NEAR,
+ unclaimed_amount: ONE_NEAR,
+ }
+ await expect(rheaBook().prepareExit(ME, ORDER, 'cancel')).rejects.toThrow(/Collect/)
+ })
+
+ it('refuses a collect with nothing to collect', async () => {
+ await expect(rheaBook().prepareExit(ME, ORDER, 'collect')).rejects.toThrow(
+ /nothing to collect yet\. Nothing was signed or sent\.$/
+ )
+ })
+
+ it('refuses in words when the order cannot be read', async () => {
+ rpc.down = true
+ await expect(rheaBook().prepareExit(ME, ORDER, 'cancel')).rejects.toThrow(
+ /Nothing was signed or sent\.$/
+ )
+ })
+})
+
+describe('assertSafe', () => {
+ it('fails closed without what prepare issued', async () => {
+ const prepared = await rheaVenue.prepare(buying(), ME)
+ refused(() => rheaVenue.assertSafe(prepared.signing, ME))
+ refused(() => rheaVenue.assertSafe(prepared.signing, ME, {}))
+ refused(() =>
+ rheaVenue.assertSafe(prepared.signing, ME, { ...prepared.context, point: 'twelve' })
+ )
+ })
+
+ it('holds a cancel to its own order', async () => {
+ const prepared = await rheaBook().prepareExit(ME, ORDER, 'cancel')
+ refused(() => rheaVenue.assertSafe(prepared.signing, ME, { orderId: `${POOL}#1` }))
+ refused(() => rheaVenue.assertSafe(prepared.signing, ME, { ...prepared.context, orderId: '' }))
+ })
+
+ it('does not let a cancel pass as an order, or an order as a cancel', async () => {
+ const order = await rheaVenue.prepare(buying(), ME)
+ const exit = await rheaBook().prepareExit(ME, ORDER, 'cancel')
+ refused(() => rheaVenue.assertSafe(exit.signing, ME, order.context))
+ refused(() => rheaVenue.assertSafe(order.signing, ME, exit.context))
+ })
+})
+
+describe('the reserve', () => {
+ const holding = (amount: string): ChainModule => ({
+ ...nearModule,
+ priceUsd: async () => 4,
+ balances: async () => ({
+ tokens: [{ symbol: 'NEAR', id: 'near', amount, decimals: 24, native: true, listed: true }],
+ complete: true,
+ }),
+ })
+
+ it('keeps back first-use storage for an account with no record', async () => {
+ firstUse()
+ await expect(assertNativeReserve(holding('0.3'), buying(), ME)).rejects.toThrow(
+ /Keep about 0\.55125 NEAR for network fees and the 0\.50125 NEAR this order costs to set up/
+ )
+ })
+
+ it('keeps it back on top of what a sale spends', async () => {
+ firstUse()
+ await expect(assertNativeReserve(holding('1.3'), selling(), ME)).rejects.toThrow(
+ /You can spend up to 0\.7475 NEAR here\. Nothing was signed or sent\.$/
+ )
+ })
+
+ it('counts only the storage still owed', async () => {
+ rpc.views[`${USDC}.storage_balance_of`] = null
+ await expect(
+ assertNativeReserve(holding('0.05'), selling({ amount: '0.001' }), ME)
+ ).rejects.toThrow(/0\.00125 NEAR this order costs to set up/)
+ })
+
+ it('keeps nothing back once the account has its record', async () => {
+ await expect(assertNativeReserve(holding('0.3'), buying(), ME)).resolves.toBeUndefined()
+ await expect(assertNativeReserve(holding('1.3'), selling(), ME)).resolves.toBeUndefined()
+ })
+
+ it('still keeps the fee reserve on a sale', async () => {
+ await expect(assertNativeReserve(holding('1.01'), selling(), ME)).rejects.toThrow(
+ /Keep about 0\.05 NEAR for network fees/
+ )
+ })
+
+ it('leaves a failed read to prepare, which refuses', async () => {
+ rpc.downViews = [`${DCL}.get_user_storage_detail`]
+ await expect(assertNativeReserve(holding('0.3'), buying(), ME)).resolves.toBeUndefined()
+ await expect(rheaVenue.prepare(buying(), ME)).rejects.toThrow(/Nothing was signed or sent\.$/)
+ })
+
+ it('does not wait on a slow read of what is owed, and leaves it to prepare', async () => {
+ vi.useFakeTimers()
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn(() => new Promise(() => undefined))
+ )
+ const answer = assertNativeReserve(holding('0.3'), buying(), ME)
+ await vi.advanceTimersByTimeAsync(3_000)
+ await expect(answer).resolves.toBeUndefined()
+ })
+
+ it('names the most first use can cost, for anyone showing it before an account is known', () => {
+ expect(rheaVenue.limitCost).toEqual({ amount: '0.5', symbol: 'NEAR' })
+ })
+})
+
+describe('what the wallet holds', () => {
+ const row = (symbol: string, id: string, amount: string, decimals: number, native = false) => ({
+ symbol,
+ id,
+ amount,
+ decimals,
+ native,
+ listed: true,
+ })
+ const near = (amount: string) => row('NEAR', 'near', amount, 24, true)
+ const wnear = (amount: string) => row('wNEAR', WRAP, amount, 24)
+ const usdc = (amount: string) => row('USDC', USDC, amount, 6)
+ const spy = vi.fn()
+ const holding = (...tokens: ReturnType[]): ChainModule => ({
+ ...nearModule,
+ venues: nearModule.venues.map((v) => (v.id === 'rhea-limit' ? { ...v, prepare: spy } : v)),
+ priceUsd: async () => 4,
+ balances: async () => ({ tokens, complete: true }),
+ })
+
+ beforeEach(() => {
+ spy.mockReset()
+ firstUse()
+ })
+
+ it('stops a purchase with no USDC before it can pay for storage', async () => {
+ await expect(
+ prepareChainAction(holding(near('1'), wnear('0'), usdc('0')), buying(), ME)
+ ).rejects.toThrow('You hold 0 USDC, and this order needs 10. Nothing was signed or sent.')
+ expect(spy).not.toHaveBeenCalled()
+ })
+
+ it('counts NEAR and wNEAR together on a sale', async () => {
+ await expect(
+ assertLimitCanRun(holding(near('0.3'), wnear('0.3'), usdc('0')), selling(), ME)
+ ).rejects.toThrow('You hold 0.6 NEAR, and this order needs 1. Nothing was signed or sent.')
+ await expect(
+ assertLimitCanRun(holding(near('0.6'), wnear('0.6'), usdc('0')), selling(), ME)
+ ).resolves.toBeUndefined()
+ })
+
+ it('does not count wNEAR or NEAR toward USDC', async () => {
+ await expect(
+ assertLimitCanRun(holding(near('100'), wnear('100'), usdc('9')), buying(), ME)
+ ).rejects.toThrow('You hold 9 USDC, and this order needs 10. Nothing was signed or sent.')
+ })
+})
+
+describe('submit', () => {
+ const encoded = (args: Record): string =>
+ Buffer.from(JSON.stringify(args)).toString('base64')
+
+ function executed(
+ receiver: string,
+ calls: { method: string; args: Record; deposit?: string }[],
+ over: { signer?: string; status?: Record } = {}
+ ): unknown {
+ return {
+ status: over.status ?? { SuccessValue: '' },
+ transaction: {
+ signer_id: over.signer ?? ME,
+ receiver_id: receiver,
+ actions: calls.map((c) => ({
+ FunctionCall: {
+ method_name: c.method,
+ args: encoded(c.args),
+ deposit: c.deposit ?? '0',
+ },
+ })),
+ },
+ }
+ }
+
+ const placed = (point = BUY_POINT): unknown =>
+ executed(USDC, [
+ {
+ method: 'ft_transfer_call',
+ args: { receiver_id: DCL, amount: '10000000', msg: msg(WRAP, point) },
+ deposit: '1',
+ },
+ ])
+ const stored = (receiver = DCL): unknown =>
+ executed(receiver, [
+ { method: 'storage_deposit', args: { account_id: ME, registration_only: true } },
+ ])
+ const send = (hashes: string[]) =>
+ rheaVenue.submit?.({
+ account: ME,
+ venueId: 'rhea-limit',
+ signed: { family: 'near', hashes },
+ })
+
+ const FAILURE = { Failure: { ActionError: {} } }
+
+ it('confirms storage, on every hash, and hands back the last', async () => {
+ rpc.tx = { [HASH]: stored(), [HASH_2]: stored(WRAP) }
+ expect(await send([HASH, HASH_2])).toEqual({
+ ok: true,
+ hash: HASH_2,
+ explorerUrl: `https://nearblocks.io/txns/${HASH_2}`,
+ })
+ })
+
+ it('confirms an order once it can be seen resting', async () => {
+ rpc.tx = { [HASH]: placed() }
+ rpc.views[`${DCL}.find_order`] = OPEN_ORDER
+ expect(await send([HASH])).toMatchObject({ ok: true, hash: HASH })
+ })
+
+ it('does not call a transaction a resting order until the order is there', async () => {
+ vi.useFakeTimers()
+ rpc.tx = { [HASH]: placed() }
+ const answer = send([HASH])
+ await vi.advanceTimersByTimeAsync(60_000)
+ const result = await answer
+ expect(result).toMatchObject({ ok: false, pending: true })
+ expect(result && 'error' in result ? result.error : '').toMatch(/can.t see/)
+ })
+
+ it('confirms a cancel', async () => {
+ rpc.tx = {
+ [HASH]: executed(DCL, [
+ { method: 'cancel_order', args: { order_id: ORDER, amount: '10000000' } },
+ ]),
+ }
+ expect(await send([HASH])).toMatchObject({ ok: true, hash: HASH })
+ })
+
+ it('says a failed setup step cost only gas, and lets it be tried again', async () => {
+ rpc.tx = {
+ [HASH]: executed(
+ DCL,
+ [{ method: 'storage_deposit', args: { account_id: ME, registration_only: true } }],
+ { status: FAILURE }
+ ),
+ }
+ const result = await send([HASH])
+ expect(result).toMatchObject({ ok: false })
+ expect(result).not.toHaveProperty('pending')
+ })
+
+ it('reads a failed order as something to check, not as nothing moved', async () => {
+ rpc.tx = {
+ [HASH]: executed(
+ USDC,
+ [
+ {
+ method: 'ft_transfer_call',
+ args: { receiver_id: DCL, amount: '10000000', msg: msg(WRAP, BUY_POINT) },
+ },
+ ],
+ { status: FAILURE }
+ ),
+ }
+ expect(await send([HASH])).toMatchObject({ ok: false, pending: true })
+ })
+
+ it.each<[string, () => unknown]>([
+ [
+ 'another signer',
+ () =>
+ executed(DCL, [{ method: 'cancel_order', args: { order_id: ORDER } }], { signer: THIEF }),
+ ],
+ ['another contract', () => executed('evil.near', [{ method: 'storage_deposit', args: {} }])],
+ ['another method', () => executed(DCL, [{ method: 'storage_withdraw', args: {} }])],
+ [
+ 'another pool',
+ () =>
+ executed(DCL, [{ method: 'cancel_order', args: { order_id: `${USDT}|${WRAP}|100#1` } }]),
+ ],
+ [
+ 'a transfer to someone else',
+ () =>
+ executed(USDC, [
+ {
+ method: 'ft_transfer_call',
+ args: { receiver_id: 'evil.near', msg: msg(WRAP, BUY_POINT) },
+ },
+ ]),
+ ],
+ [
+ 'an order on another pool',
+ () =>
+ executed(USDC, [
+ {
+ method: 'ft_transfer_call',
+ args: {
+ receiver_id: DCL,
+ msg: JSON.stringify({
+ LimitOrderWithSwap: { pool_id: `${USDT}|${WRAP}|100`, point: 1 },
+ }),
+ },
+ },
+ ]),
+ ],
+ [
+ 'a msg that is not JSON',
+ () =>
+ executed(USDC, [{ method: 'ft_transfer_call', args: { receiver_id: DCL, msg: 'nope' } }]),
+ ],
+ ])('will not report %s as this order', async (_name, outcome) => {
+ rpc.tx = { [HASH]: outcome() }
+ expect(await send([HASH])).toMatchObject({ ok: false, pending: true })
+ })
+
+ it('waits, then says not to send again, when NEAR has not seen it', async () => {
+ vi.useFakeTimers()
+ const answer = send([HASH])
+ await vi.advanceTimersByTimeAsync(60_000)
+ expect(await answer).toMatchObject({ ok: false, pending: true })
+ })
+
+ it('reads a malformed hash, or none, as pending', async () => {
+ expect(await send(['bad'])).toMatchObject({ ok: false, pending: true })
+ expect(await send([])).toMatchObject({ ok: false, pending: true })
+ })
+
+ it('refuses another family', async () => {
+ expect(
+ await rheaVenue.submit?.({
+ account: ME,
+ venueId: 'rhea-limit',
+ signed: { family: 'evm', hash: '0x1' },
+ })
+ ).toEqual({ ok: false, error: 'That is not a NEAR transaction.' })
+ })
+})
+
+describe('wiring', () => {
+ it('is the limit venue on NEAR, and carries the book', () => {
+ expect(nearModule.defaultVenue.limit).toBe('rhea-limit')
+ expect(nearModule.limitBook).toBeDefined()
+ const venue = nearModule.venues.find((v) => v.id === 'rhea-limit')
+ expect(venue?.name).toBe('Rhea')
+ expect(venue?.kinds).toEqual(['limit'])
+ })
+
+ it('claims what it does on the Apps list, and for help', () => {
+ const row = nearVenues.find((v) => v.id === 'rhea-limit')
+ expect(row?.integration).toBe('executes')
+ expect(row?.category).toBe('orderbook')
+ expect(nearCapabilities).toContainEqual({ kind: 'limit', venue: 'Rhea', symbol: 'NEAR' })
+ })
+
+ it('changes nothing about who answers for the other kinds', () => {
+ expect(nearModule.defaultVenue.swap).toBe('rhea-dex')
+ expect(nearModule.defaultVenue.stake).toBe('metapool')
+ expect(nearModule.defaultVenue.supply).toBe('1click-earn')
+ })
+})
diff --git a/apps/dapp/lib/__tests__/near-venues.test.ts b/apps/dapp/lib/__tests__/near-venues.test.ts
index 6a2d717f..85f8908e 100644
--- a/apps/dapp/lib/__tests__/near-venues.test.ts
+++ b/apps/dapp/lib/__tests__/near-venues.test.ts
@@ -26,7 +26,7 @@ const CATEGORIES: readonly VenueCategory[] = [
describe('the NEAR venue list', () => {
it('has every row of the report, with the fields filled in', () => {
- expect(nearVenues).toHaveLength(14)
+ expect(nearVenues).toHaveLength(15)
for (const venue of nearVenues) {
expect(venue.family).toBe('near')
expect(venue.chains).toEqual(['NEAR'])
diff --git a/apps/dapp/lib/__tests__/sablier.test.ts b/apps/dapp/lib/__tests__/sablier.test.ts
index 4a079be6..0fdcc51d 100644
--- a/apps/dapp/lib/__tests__/sablier.test.ts
+++ b/apps/dapp/lib/__tests__/sablier.test.ts
@@ -614,6 +614,8 @@ describe('submit', () => {
return {
tokenBalance: async () => BigInt(0),
nativeBalance: async () => BigInt(0),
+ allowance: async () => BigInt(0),
+ code: async () => undefined,
waitForReceipt: async () =>
({ status: 'success', from: ME, to: LOCKUP, logs: [], ...receipt }) as EvmReceipt,
transaction: async () => ({ value: BigInt(0), input: data }),
diff --git a/apps/dapp/lib/__tests__/solana-limo.live.test.ts b/apps/dapp/lib/__tests__/solana-limo.live.test.ts
new file mode 100644
index 00000000..99d149b3
--- /dev/null
+++ b/apps/dapp/lib/__tests__/solana-limo.live.test.ts
@@ -0,0 +1,331 @@
+import { existsSync, writeFileSync } from 'node:fs'
+import { join } from 'node:path'
+
+import { getBase58Decoder, type Address } from '@solana/kit'
+import { beforeAll, describe, expect, it, vi } from 'vitest'
+
+import { decodeSolanaTransaction } from '../chains/solana/assert-safe'
+import { rpc as rawRpc } from '../chains/solana/balances'
+import { createKaminoLimitVenue, limoBook } from '../chains/solana/limo'
+import {
+ decodeGlobalConfig,
+ decodeOrder,
+ inputVault,
+ pdaAuthority,
+} from '../chains/solana/limo-rules'
+import { LIMO_GLOBAL_CONFIG, LIMO_PROGRAM } from '../chains/solana/programs'
+import type { PreparedAction } from '../chains/types'
+
+/**
+ * Against mainnet, reading and simulating only. Nothing here signs for any account: a simulation
+ * runs with `sigVerify: false`, and the only key involved is the throwaway order key, which owns
+ * nothing. Run by this exact file name. The account named is a public exchange wallet.
+ *
+ * The first test writes `limo-order.json` if it is missing, or if `CAPTURE_LIMO_FIXTURE=1`.
+ */
+
+const FUNDED = 'H8sMJSCQxfKiFTCfDR3DUMLPwcRbM61LGFJ8N4dK3WjS'
+const SOL = 'So11111111111111111111111111111111111111112'
+const USDC = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'
+const SYSTEM = '11111111111111111111111111111111'
+const FIXTURE = join(__dirname, '../chains/solana/__fixtures__/limo-order.json')
+const SLOW_MS = 180_000
+
+const signal = (): AbortSignal => AbortSignal.timeout(120_000)
+const wait = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms))
+const pause = (): Promise => wait(4_000)
+
+/** The public endpoint rate limits by address, so a refusal for that alone waits and goes again. */
+async function patient(run: () => Promise): Promise {
+ for (let attempt = 0; ; attempt += 1) {
+ try {
+ return await run()
+ } catch (e) {
+ if (attempt === 4 || !/answered 429|couldn’t read/.test(String(e))) throw e
+ await wait(12_000)
+ }
+ }
+}
+
+const rpc = (method: string, params: unknown[], given: AbortSignal): Promise =>
+ patient(() => rawRpc(method, params, given))
+
+interface Listed {
+ pubkey: string
+ account: { data: [string, string]; lamports: number; owner: string }
+}
+
+/** Order addresses for a status, read by filter so the whole book isn't fetched. */
+async function ordersWithStatus(status: number): Promise {
+ const statusByte = getBase58Decoder().decode(Uint8Array.of(status))
+ const listed = (await rpc(
+ 'getProgramAccounts',
+ [
+ LIMO_PROGRAM,
+ {
+ encoding: 'base64',
+ commitment: 'confirmed',
+ dataSlice: { offset: 0, length: 0 },
+ filters: [
+ { dataSize: 424 },
+ { memcmp: { offset: 8, bytes: LIMO_GLOBAL_CONFIG, encoding: 'base58' } },
+ { memcmp: { offset: 72, bytes: SOL, encoding: 'base58' } },
+ { memcmp: { offset: 136, bytes: USDC, encoding: 'base58' } },
+ { memcmp: { offset: 249, bytes: statusByte, encoding: 'base58' } },
+ ],
+ },
+ ],
+ signal()
+ )) as Listed[]
+ if (listed.length === 0) throw new Error(`No order with status ${status}.`)
+ // Most lamports first, so a newer order that holds its fee is the one tried.
+ return [...listed]
+ .sort((a, b) => b.account.lamports - a.account.lamports)
+ .map((order) => order.pubkey)
+}
+
+/** An order whose maker can pay a fee, since a simulation fails for a fee payer with no SOL. */
+async function orderOfFundedMaker(status: number): Promise<{ order: string; maker: string }> {
+ const addresses = (await ordersWithStatus(status)).slice(0, 60)
+ await pause()
+ const orders = (await rpc(
+ 'getMultipleAccounts',
+ [addresses, { encoding: 'base64', commitment: 'confirmed' }],
+ signal()
+ )) as { value: ({ data: [string, string] } | null)[] }
+ const makers = orders.value.map((account) =>
+ account === null ? undefined : decodeOrder(Buffer.from(account.data[0], 'base64'))?.maker
+ )
+ await pause()
+ const known = makers.filter((maker): maker is string => maker !== undefined)
+ const wallets = (await rpc(
+ 'getMultipleAccounts',
+ [known, { encoding: 'base64', commitment: 'confirmed', dataSlice: { offset: 0, length: 0 } }],
+ signal()
+ )) as { value: ({ lamports: number; owner: string } | null)[] }
+ const index = wallets.value.findIndex(
+ (wallet) => wallet !== null && wallet.lamports > 10_000_000 && wallet.owner === SYSTEM
+ )
+ const maker = known[index]
+ if (maker === undefined) throw new Error('No funded maker among these orders.')
+ return { order: addresses[makers.indexOf(maker)] as string, maker }
+}
+
+/** A closed account reads back empty and owned by the System Program, if it reads back at all. */
+function expectClosed(account: { lamports: number } | null | undefined): void {
+ expect(account?.lamports ?? 0).toBe(0)
+}
+
+async function readAccount(
+ address: string
+): Promise<{ lamports: number; owner: string; data: string }> {
+ const { value } = (await rpc(
+ 'getAccountInfo',
+ [address, { encoding: 'base64', commitment: 'confirmed' }],
+ signal()
+ )) as { value: { lamports: number; owner: string; data: [string, string] } | null }
+ if (value === null) throw new Error(`${address} is not there.`)
+ return { lamports: value.lamports, owner: value.owner, data: value.data[0] }
+}
+
+interface Simulated {
+ value: {
+ err: unknown
+ logs: string[] | null
+ unitsConsumed?: number
+ accounts?: ({ lamports: number; data: [string, string] } | null)[]
+ }
+}
+
+async function simulate(prepared: PreparedAction, read: readonly string[]): Promise {
+ if (prepared.signing.family !== 'solana') throw new Error('Expected Solana.')
+ const result = (await rpc(
+ 'simulateTransaction',
+ [
+ prepared.signing.transaction,
+ {
+ encoding: 'base64',
+ sigVerify: false,
+ replaceRecentBlockhash: false,
+ commitment: 'confirmed',
+ accounts: { encoding: 'base64', addresses: read },
+ },
+ ],
+ signal()
+ )) as Simulated
+ return result
+}
+
+describe.skipIf(process.env['SKIP_LIVE'] === '1')('kamino limit orders on mainnet', () => {
+ beforeAll(() => {
+ // The public endpoint is the one the fallback question is about, whatever this machine sets.
+ vi.stubEnv('SOLANA_RPC_URL', '')
+ })
+
+ let filled: string
+
+ it(
+ 'captures one real filled order to the fixture, and reads it',
+ async () => {
+ filled = (await ordersWithStatus(1))[0] as string
+ const account = await readAccount(filled)
+ expect(account.owner).toBe(LIMO_PROGRAM)
+ const order = decodeOrder(Buffer.from(account.data, 'base64'))
+ expect(order?.status).toBe(1)
+ expect(order?.globalConfig).toBe(LIMO_GLOBAL_CONFIG)
+ expect(order?.inputMint).toBe(SOL)
+ expect(order?.outputMint).toBe(USDC)
+ expect(order?.remainingInput).toBe(BigInt(0))
+ expect(order?.filledOutput).toBeGreaterThanOrEqual(order?.expectedOutput ?? BigInt(1))
+ if (process.env['CAPTURE_LIMO_FIXTURE'] === '1' || !existsSync(FIXTURE)) {
+ writeFileSync(FIXTURE, `${JSON.stringify({ address: filled, ...account }, null, 2)}\n`)
+ }
+ },
+ SLOW_MS
+ )
+
+ it(
+ 'reads the pinned config, which derives the same authority and takes new orders',
+ async () => {
+ await pause()
+ const account = await readAccount(LIMO_GLOBAL_CONFIG)
+ expect(account.owner).toBe(LIMO_PROGRAM)
+ const config = decodeGlobalConfig(Buffer.from(account.data, 'base64'))
+ console.log('config', config)
+ expect(config?.pdaAuthority).toBe(pdaAuthority())
+ expect(config?.newOrdersBlocked).toBe(false)
+ expect(config?.cost).toBeLessThanOrEqual(BigInt(10_000_000))
+ },
+ SLOW_MS
+ )
+
+ it(
+ 'finds a vault at the address it derives for each offered mint',
+ async () => {
+ await pause()
+ for (const mint of [SOL, USDC, 'Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB']) {
+ const vault = await readAccount(inputVault(mint))
+ expect(vault.owner).toBe('TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA')
+ }
+ },
+ SLOW_MS
+ )
+
+ it(
+ 'answers getProgramAccounts filtered by maker on api.mainnet-beta.solana.com',
+ async () => {
+ await pause()
+ const { maker } = decodeOrder(
+ Buffer.from((await readAccount(filled)).data, 'base64')
+ ) as NonNullable>
+ await pause()
+ const orders = await patient(() => limoBook().openOrders(maker, signal()))
+ console.log('orders for maker', maker, orders.length)
+ expect(orders.map((o) => o.id)).toContain(filled)
+ },
+ SLOW_MS
+ )
+
+ it.each([
+ ['SOL', 'USDC', '0.01', '100000'],
+ ['USDC', 'SOL', '1', '0.01'],
+ ])(
+ 'simulates creating %s to %s for a funded wallet, and it passes its own check',
+ async (tokenIn, tokenOut, amount, limitPrice) => {
+ await pause()
+ const venue = createKaminoLimitVenue()
+ const prepared = await patient(() =>
+ venue.prepare({ kind: 'limit', amount, tokenIn, tokenOut, limitPrice }, FUNDED)
+ )
+ venue.assertSafe(prepared.signing, FUNDED, prepared.context)
+ if (prepared.signing.family !== 'solana') throw new Error('Expected Solana.')
+ const decoded = decodeSolanaTransaction(prepared.signing.transaction)
+ expect(decoded.feePayerSignature).toBeNull()
+ const order = decoded.staticAccounts[1] as Address
+
+ await pause()
+ const configBefore = await readAccount(LIMO_GLOBAL_CONFIG)
+ await pause()
+ const simulated = await simulate(prepared, [order, LIMO_GLOBAL_CONFIG])
+ console.log(`${tokenIn} to ${tokenOut}`, {
+ err: simulated.value.err,
+ units: simulated.value.unitsConsumed,
+ orderLamports: simulated.value.accounts?.[0]?.lamports,
+ configLamportsChange:
+ (simulated.value.accounts?.[1]?.lamports ?? 0) - configBefore.lamports,
+ })
+ expect(simulated.value.err).toBeNull()
+ expect(simulated.value.logs?.join('\n')).toContain(`${LIMO_PROGRAM} success`)
+
+ const created = simulated.value.accounts?.[0]
+ const read = decodeOrder(Buffer.from(created?.data[0] ?? '', 'base64'))
+ expect(read?.maker).toBe(FUNDED)
+ expect(read?.globalConfig).toBe(LIMO_GLOBAL_CONFIG)
+ expect(read?.status).toBe(0)
+ expect(read?.initialInput.toString()).toBe(prepared.context?.['inputAmount'])
+ expect(read?.expectedOutput.toString()).toBe(prepared.context?.['outputAmount'])
+ },
+ SLOW_MS
+ )
+
+ it(
+ 'simulates collecting a real filled order, as its own maker',
+ async () => {
+ await pause()
+ const { order, maker } = await orderOfFundedMaker(1)
+ const venue = createKaminoLimitVenue()
+ await pause()
+ const prepared = await patient(() => limoBook().prepareExit(maker, order, 'collect'))
+ venue.assertSafe(prepared.signing, maker, prepared.context)
+ await pause()
+ const before = await readAccount(maker)
+ await pause()
+ const simulated = await simulate(prepared, [order, maker])
+ const makerChange = (simulated.value.accounts?.[1]?.lamports ?? 0) - before.lamports
+ console.log('collect', {
+ order,
+ maker,
+ err: simulated.value.err,
+ units: simulated.value.unitsConsumed,
+ summary: prepared.summary,
+ makerChange,
+ })
+ expect(simulated.value.err).toBeNull()
+ expectClosed(simulated.value.accounts?.[0])
+ // What the card says comes back, less a network fee far under this margin.
+ const promised = Number(prepared.receive?.amount) * 1_000_000_000
+ expect(Math.abs(makerChange - promised)).toBeLessThan(100_000)
+ },
+ SLOW_MS
+ )
+
+ it(
+ 'simulates cancelling a real open order, as its own maker',
+ async () => {
+ await pause()
+ const { order, maker } = await orderOfFundedMaker(0)
+ const venue = createKaminoLimitVenue()
+ await pause()
+ const prepared = await patient(() => limoBook().prepareExit(maker, order, 'cancel'))
+ venue.assertSafe(prepared.signing, maker, prepared.context)
+ await pause()
+ const before = await readAccount(order)
+ await pause()
+ const makerBefore = await readAccount(maker)
+ await pause()
+ const simulated = await simulate(prepared, [order, maker])
+ console.log('cancel', {
+ order,
+ maker,
+ err: simulated.value.err,
+ units: simulated.value.unitsConsumed,
+ summary: prepared.summary,
+ orderLamports: before.lamports,
+ makerChange: (simulated.value.accounts?.[1]?.lamports ?? 0) - makerBefore.lamports,
+ })
+ expect(simulated.value.err).toBeNull()
+ expectClosed(simulated.value.accounts?.[0])
+ },
+ SLOW_MS
+ )
+})
diff --git a/apps/dapp/lib/__tests__/solana-limo.test.ts b/apps/dapp/lib/__tests__/solana-limo.test.ts
new file mode 100644
index 00000000..5947c03f
--- /dev/null
+++ b/apps/dapp/lib/__tests__/solana-limo.test.ts
@@ -0,0 +1,1389 @@
+import { readFileSync } from 'node:fs'
+import { join } from 'node:path'
+
+import {
+ generateKeyPair,
+ getAddressFromPublicKey,
+ getCompiledTransactionMessageDecoder,
+ getCompiledTransactionMessageEncoder,
+ getPublicKeyFromAddress,
+ getTransactionDecoder,
+ getTransactionEncoder,
+ signBytes,
+ verifySignature,
+ type Address,
+ type SignatureBytes,
+} from '@solana/kit'
+import type { Wallet, WalletAccount } from '@wallet-standard/base'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+
+import { prepareChainAction } from '../chains/prepare'
+import {
+ associatedTokenAccount,
+ decodeSolanaTransaction,
+ ownWrappedSolAccount,
+ solanaAssertSafe,
+} from '../chains/solana/assert-safe'
+import { solanaCapabilities } from '../chains/solana/capabilities'
+import { buildCreate, createKaminoLimitVenue, limoBook } from '../chains/solana/limo'
+import {
+ decodeOrder,
+ eventAuthority,
+ inputVault,
+ limoAssertSafe,
+ pdaAuthority,
+} from '../chains/solana/limo-rules'
+import { buildSolanaVenues, solanaModule } from '../chains/solana/module'
+import { JUPITER_SWAP_POLICY, LIMO_GLOBAL_CONFIG, LIMO_PROGRAM } from '../chains/solana/programs'
+import { signSolana } from '../chains/solana/sign'
+import { feePayerSignature } from '../chains/solana/submit'
+import { solanaVenues } from '../chains/solana/venues'
+import { ChainActionRefused, type PreparedAction, type SigningPayload } from '../chains/types'
+
+const ACCOUNT = 'H8sMJSCQxfKiFTCfDR3DUMLPwcRbM61LGFJ8N4dK3WjS'
+const ATTACKER = 'BPFLoaderUpgradeab1e11111111111111111111111'
+const SOL = 'So11111111111111111111111111111111111111112'
+const USDC = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'
+const USDT = 'Es9vMFrzaCERmJfrF4H2FYD4KCoNkY11McCe8BenwNYB'
+const JITO_SOL = 'J1toso1uCk3RLmjorhTtrVwY9HJ7X8V9yYac6Y7kGCPn'
+const SYSTEM = '11111111111111111111111111111111'
+const TOKEN = 'TokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DA'
+const ATA = 'ATokenGPvbdGVxr1b2hvZbsiqW5xWH25efTNsLJA8knL'
+const BLOCKHASH = 'EETubP5AKHgjPAhzPAFcb8BAY1hMH639CWCFTqi3hq1k'
+const LIFETIME = { blockhash: BLOCKHASH, lastValidBlockHeight: 1000 }
+const CREATE_ORDER = [141, 54, 37, 207, 237, 210, 250, 215]
+const CLOSE_ORDER = [244, 27, 12, 226, 45, 247, 230, 43]
+const UPDATE_ORDER = [54, 8, 208, 207, 34, 134, 239, 168]
+
+interface MessageInstruction {
+ programAddressIndex: number
+ accountIndices?: readonly number[]
+ data?: Uint8Array
+}
+interface Message {
+ version: number | 'legacy'
+ header: {
+ numSignerAccounts: number
+ numReadonlySignerAccounts: number
+ numReadonlyNonSignerAccounts: number
+ }
+ staticAccounts: readonly Address[]
+ lifetimeToken: string
+ instructions: readonly MessageInstruction[]
+}
+
+function open(base64: string): Message {
+ const { messageBytes } = getTransactionDecoder().decode(Buffer.from(base64, 'base64'))
+ return getCompiledTransactionMessageDecoder().decode(messageBytes) as unknown as Message
+}
+
+/**
+ * A message re-sealed with the order key's real signature, so an unchanged message still verifies. A
+ * changed one has to be refused for what changed, which each case's words below pin down.
+ */
+function close(message: Message): string {
+ const bytes = getCompiledTransactionMessageEncoder().encode(
+ message as unknown as Parameters<
+ ReturnType['encode']
+ >[0]
+ )
+ const count = message.header.numSignerAccounts
+ return Buffer.concat([
+ Buffer.from([count]),
+ Buffer.alloc(64),
+ count > 1 ? Buffer.from(base.transaction, 'base64').subarray(65, 129) : Buffer.alloc(0),
+ Buffer.alloc(64 * Math.max(count - 2, 0), 1),
+ Buffer.from(bytes),
+ ]).toString('base64')
+}
+
+/** Adds a read-only account, keeping every index pointing at the same address. */
+function withAccount(message: Message, address: string): { next: Message; index: number } {
+ const found = message.staticAccounts.indexOf(address as Address)
+ if (found !== -1) return { next: message, index: found }
+ const index = message.staticAccounts.length
+ return {
+ index,
+ next: {
+ ...message,
+ header: {
+ ...message.header,
+ numReadonlyNonSignerAccounts: message.header.numReadonlyNonSignerAccounts + 1,
+ },
+ staticAccounts: [...message.staticAccounts, address as Address],
+ },
+ }
+}
+
+function indexOfInstruction(message: Message, program: string, firstByte: number): number {
+ const index = message.instructions.findIndex(
+ (ix) => message.staticAccounts[ix.programAddressIndex] === program && ix.data?.[0] === firstByte
+ )
+ if (index === -1) throw new Error(`No instruction on ${program} starting ${firstByte}`)
+ return index
+}
+
+function replaceInstruction(
+ message: Message,
+ index: number,
+ change: Partial
+): Message {
+ return {
+ ...message,
+ instructions: message.instructions.map((ix, i) => (i === index ? { ...ix, ...change } : ix)),
+ }
+}
+
+/** Points one account of an instruction somewhere else. */
+function withAccountAt(
+ message: Message,
+ instruction: number,
+ position: number,
+ address: string
+): Message {
+ const { next, index } = withAccount(message, address)
+ const accounts = [...(next.instructions[instruction]?.accountIndices ?? [])]
+ accounts[position] = index
+ return replaceInstruction(next, instruction, { accountIndices: accounts })
+}
+
+function appended(
+ message: Message,
+ program: string,
+ accounts: readonly string[],
+ data: readonly number[]
+): Message {
+ let next = message
+ const indexOf = (address: string): number => {
+ const added = withAccount(next, address)
+ next = added.next
+ return added.index
+ }
+ const programAddressIndex = indexOf(program)
+ const accountIndices = accounts.map(indexOf)
+ return {
+ ...next,
+ instructions: [
+ ...next.instructions,
+ { programAddressIndex, accountIndices, data: Uint8Array.from(data) },
+ ],
+ }
+}
+
+function u64(value: bigint): number[] {
+ const bytes = Buffer.alloc(8)
+ bytes.writeBigUInt64LE(value)
+ return [...bytes]
+}
+
+function createOrderData(input: bigint, output: bigint): Uint8Array {
+ return Uint8Array.from([...CREATE_ORDER, ...u64(input), ...u64(output), 0])
+}
+
+const createOrderIndex = (m: Message): number =>
+ indexOfInstruction(m, LIMO_PROGRAM, CREATE_ORDER[0]!)
+const createAccountIndex = (m: Message): number => indexOfInstruction(m, SYSTEM, 0)
+
+function createAccountData(lamports: bigint, space: bigint, owner: string): Uint8Array {
+ return Uint8Array.from([0, 0, 0, 0, ...u64(lamports), ...u64(space), ...addressBytes(owner)])
+}
+
+function addressBytes(address: string): number[] {
+ const alphabet = '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz'
+ let value = BigInt(0)
+ for (const c of address) value = value * BigInt(58) + BigInt(alphabet.indexOf(c))
+ const bytes: number[] = []
+ while (value > BigInt(0)) {
+ bytes.unshift(Number(value % BigInt(256)))
+ value /= BigInt(256)
+ }
+ while (bytes.length < 32) bytes.unshift(0)
+ return bytes
+}
+
+const base = await buildCreate({
+ account: ACCOUNT,
+ tokenIn: 'SOL',
+ tokenOut: 'USDC',
+ amount: '1',
+ limitPrice: '110',
+ lifetime: LIFETIME,
+})
+const baseMessage = open(base.transaction)
+
+function assertWith(message: Message, context = base.context): void {
+ limoAssertSafe({ family: 'solana', transaction: close(message) }, ACCOUNT, context)
+}
+
+/** The refusal's words, so a case can't pass because something else refused first. */
+function refusal(run: () => void): string {
+ try {
+ run()
+ } catch (e) {
+ if (e instanceof ChainActionRefused) return e.message
+ throw e
+ }
+ throw new Error('It was not refused.')
+}
+
+interface RpcReply {
+ [method: string]: unknown
+}
+
+const calls: { method: string; params: unknown[] }[] = []
+
+function stubRpc(replies: RpcReply): void {
+ calls.length = 0
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn(async (_url: string, init: RequestInit) => {
+ const { method, params } = JSON.parse(init.body as string) as {
+ method: string
+ params: unknown[]
+ }
+ calls.push({ method, params })
+ if (!(method in replies)) throw new Error(`Unexpected RPC call: ${method}`)
+ return new Response(JSON.stringify({ jsonrpc: '2.0', id: 1, result: replies[method] }))
+ })
+ )
+}
+
+function configBytes(
+ change: { txnFee?: number; ataCost?: number; newOrdersBlocked?: number; emergency?: number } = {}
+): string {
+ const bytes = Buffer.alloc(2168)
+ Buffer.from([149, 8, 156, 202, 160, 252, 176, 217]).copy(bytes, 0)
+ bytes[8] = change.emergency ?? 0
+ bytes[10] = change.newOrdersBlocked ?? 0
+ bytes.writeBigUInt64LE(BigInt(change.txnFee ?? 3_000_000), 224)
+ bytes.writeBigUInt64LE(BigInt(change.ataCost ?? 0), 232)
+ return bytes.toString('base64')
+}
+
+function liveAccount(data: string, owner: string, lamports = 1_000_000) {
+ return { data: [data, 'base64'], owner, lamports, executable: false, rentEpoch: 0 }
+}
+
+function liveConfig(config = configBytes(), vaultThere = true): RpcReply {
+ return {
+ getMultipleAccounts: {
+ context: { slot: 1 },
+ value: [
+ liveAccount(config, LIMO_PROGRAM),
+ vaultThere ? liveAccount(Buffer.alloc(165).toString('base64'), TOKEN) : null,
+ ],
+ },
+ getLatestBlockhash: { context: { slot: 1 }, value: LIFETIME },
+ }
+}
+
+const SOL_FOR_USDC = {
+ kind: 'limit' as const,
+ amount: '1',
+ tokenIn: 'SOL',
+ tokenOut: 'USDC',
+ limitPrice: '110',
+}
+
+const FIXTURE = (): { address: string; lamports: number; owner: string; data: string } =>
+ JSON.parse(
+ readFileSync(join(__dirname, '../chains/solana/__fixtures__/limo-order.json'), 'utf8')
+ ) as { address: string; lamports: number; owner: string; data: string }
+const FIXTURE_MAKER = 'vuRtvoHwadVZABXSfuWD35gHiDU9eTFpxxXbnwT9k5R'
+
+/** The fixture with its status and amounts changed, as an order earlier in its life would read. */
+function orderLike(change: {
+ maker?: string
+ status?: number
+ remaining?: bigint
+ filled?: bigint
+ outputMint?: string
+}): string {
+ const bytes = Buffer.from(FIXTURE().data, 'base64')
+ if (change.maker !== undefined) Buffer.from(addressBytes(change.maker)).copy(bytes, 40)
+ if (change.outputMint !== undefined) Buffer.from(addressBytes(change.outputMint)).copy(bytes, 136)
+ if (change.remaining !== undefined) bytes.writeBigUInt64LE(change.remaining, 216)
+ if (change.filled !== undefined) bytes.writeBigUInt64LE(change.filled, 224)
+ if (change.status !== undefined) bytes[249] = change.status
+ return bytes.toString('base64')
+}
+
+function liveOrder(data: string, lamports = 3_841_920): RpcReply {
+ return {
+ getAccountInfo: { context: { slot: 1 }, value: liveAccount(data, LIMO_PROGRAM, lamports) },
+ getLatestBlockhash: { context: { slot: 1 }, value: LIFETIME },
+ }
+}
+
+function solanaTransaction(prepared: PreparedAction): string {
+ if (prepared.signing.family !== 'solana') throw new Error('Expected Solana.')
+ return prepared.signing.transaction
+}
+
+afterEach(() => {
+ vi.unstubAllGlobals()
+})
+
+describe('buildCreate', () => {
+ const accountsOf = (m: Message, ix: MessageInstruction): (string | undefined)[] =>
+ (ix.accountIndices ?? []).map((i) => m.staticAccounts[i])
+
+ it('makes 1 SOL to USDC at 110 a create_order for exactly those amounts', () => {
+ const order = baseMessage.instructions[createOrderIndex(baseMessage)]!
+ expect([...(order.data ?? [])]).toEqual([
+ ...createOrderData(BigInt(1000000000), BigInt(110000000)),
+ ])
+ expect(base.context).toEqual({
+ inputMint: SOL,
+ outputMint: USDC,
+ inputAmount: '1000000000',
+ outputAmount: '110000000',
+ })
+ })
+
+ it('gives create_order its accounts in the program’s order', () => {
+ const order = baseMessage.instructions[createOrderIndex(baseMessage)]!
+ expect(accountsOf(baseMessage, order)).toEqual([
+ ACCOUNT,
+ LIMO_GLOBAL_CONFIG,
+ pdaAuthority(),
+ base.order,
+ SOL,
+ USDC,
+ ownWrappedSolAccount(ACCOUNT),
+ inputVault(SOL),
+ TOKEN,
+ TOKEN,
+ SYSTEM,
+ eventAuthority(),
+ LIMO_PROGRAM,
+ ])
+ })
+
+ it('derives the same PDAs the deployed program uses', () => {
+ expect(pdaAuthority()).toBe('DJGVEH2F5DdwQVNgxtCEVSpC2Qu5sEjRmBHfa22tAnYY')
+ })
+
+ it('creates the order account at 424 bytes owned by the program, with the order key signing', () => {
+ const create = baseMessage.instructions[createAccountIndex(baseMessage)]!
+ expect(accountsOf(baseMessage, create)).toEqual([ACCOUNT, base.order])
+ expect([...(create.data ?? [])]).toEqual([
+ 0,
+ 0,
+ 0,
+ 0,
+ ...u64(BigInt(3841920)),
+ ...u64(BigInt(424)),
+ ...addressBytes(LIMO_PROGRAM),
+ ])
+ expect(baseMessage.header.numSignerAccounts).toBe(2)
+ expect(baseMessage.staticAccounts[1]).toBe(base.order)
+ })
+
+ it('creates the output token account, because the SDK omits it and fills can fail without it', () => {
+ const creates = baseMessage.instructions
+ .filter((ix) => baseMessage.staticAccounts[ix.programAddressIndex] === ATA)
+ .map((ix) => ({ data: [...(ix.data ?? [])], accounts: accountsOf(baseMessage, ix) }))
+ const output = associatedTokenAccount(ACCOUNT, USDC)
+ expect(creates).toContainEqual({
+ data: [1],
+ accounts: [ACCOUNT, output, ACCOUNT, USDC, SYSTEM, TOKEN],
+ })
+ })
+
+ it('wraps exactly the amount, syncs, and closes the wrapped account back to the maker', () => {
+ const wrapped = ownWrappedSolAccount(ACCOUNT)
+ const programs = baseMessage.instructions
+ .map((ix) => baseMessage.staticAccounts[ix.programAddressIndex])
+ .filter((p) => !String(p).startsWith('ComputeBudget'))
+ expect(programs).toEqual([SYSTEM, ATA, SYSTEM, TOKEN, ATA, LIMO_PROGRAM, TOKEN])
+
+ const creates = baseMessage.instructions.filter(
+ (ix) => baseMessage.staticAccounts[ix.programAddressIndex] === ATA
+ )
+ expect(accountsOf(baseMessage, creates[0]!)).toEqual([
+ ACCOUNT,
+ wrapped,
+ ACCOUNT,
+ SOL,
+ SYSTEM,
+ TOKEN,
+ ])
+ const transfer = baseMessage.instructions[indexOfInstruction(baseMessage, SYSTEM, 2)]!
+ expect(accountsOf(baseMessage, transfer)).toEqual([ACCOUNT, wrapped])
+ expect([...(transfer.data ?? [])]).toEqual([2, 0, 0, 0, ...u64(BigInt(1000000000))])
+ const sync = baseMessage.instructions[indexOfInstruction(baseMessage, TOKEN, 17)]!
+ expect(accountsOf(baseMessage, sync)).toEqual([wrapped])
+ const closeAccount = baseMessage.instructions[indexOfInstruction(baseMessage, TOKEN, 9)]!
+ expect(accountsOf(baseMessage, closeAccount)).toEqual([wrapped, ACCOUNT, ACCOUNT])
+ })
+
+ it('makes USDC to SOL with no output account create and no wrap', async () => {
+ const built = await buildCreate({
+ account: ACCOUNT,
+ tokenIn: 'USDC',
+ tokenOut: 'SOL',
+ amount: '110',
+ limitPrice: '100',
+ lifetime: LIFETIME,
+ })
+ const message = open(built.transaction)
+ const programs = message.instructions
+ .map((ix) => message.staticAccounts[ix.programAddressIndex])
+ .filter((p) => !String(p).startsWith('ComputeBudget'))
+ expect(programs).toEqual([SYSTEM, LIMO_PROGRAM])
+ const order = message.instructions[createOrderIndex(message)]!
+ expect([...(order.data ?? [])]).toEqual([
+ ...createOrderData(BigInt(110000000), BigInt(1100000000)),
+ ])
+ expect(accountsOf(message, order)[6]).toBe(associatedTokenAccount(ACCOUNT, USDC))
+ expect(accountsOf(message, order)[7]).toBe(inputVault(USDC))
+ expect(() =>
+ limoAssertSafe({ family: 'solana', transaction: built.transaction }, ACCOUNT, built.context)
+ ).not.toThrow()
+ })
+
+ it('creates the USDT output account when selling SOL for USDT', async () => {
+ const built = await buildCreate({
+ account: ACCOUNT,
+ tokenIn: 'SOL',
+ tokenOut: 'USDT',
+ amount: '1',
+ limitPrice: '110',
+ lifetime: LIFETIME,
+ })
+ const message = open(built.transaction)
+ const creates = message.instructions.filter(
+ (ix) => message.staticAccounts[ix.programAddressIndex] === ATA
+ )
+ expect(creates.map((ix) => accountsOf(message, ix)[3])).toEqual([SOL, USDT])
+ })
+
+ it.each([
+ ['JitoSOL', 'USDC'],
+ ['USDC', 'ONyc'],
+ ['USDC', 'USDT'],
+ ['SOL', 'SOL'],
+ ])(
+ 'refuses %s to %s, as only SOL, USDC and USDT against a stablecoin are offered',
+ async (a, b) => {
+ await expect(
+ buildCreate({
+ account: ACCOUNT,
+ tokenIn: a,
+ tokenOut: b,
+ amount: '1',
+ limitPrice: '110',
+ lifetime: LIFETIME,
+ })
+ ).rejects.toThrow(/Nothing was signed or sent\.$/)
+ }
+ )
+
+ it('signs with an order key the server does not keep, and only that', async () => {
+ const { signatures, messageBytes } = getTransactionDecoder().decode(
+ Buffer.from(base.transaction, 'base64')
+ )
+ expect(signatures[ACCOUNT as Address]).toBeNull()
+ const signature = signatures[base.order as Address] as SignatureBytes
+ const key = await getPublicKeyFromAddress(base.order as Address)
+ expect(await verifySignature(key, signature, messageBytes)).toBe(true)
+ expect(Object.keys(signatures).sort()).toEqual([ACCOUNT, base.order].sort())
+ expect(JSON.stringify(base)).not.toMatch(/private|secret/i)
+ })
+
+ it('uses a new order key every time', async () => {
+ const again = await buildCreate({
+ account: ACCOUNT,
+ tokenIn: 'SOL',
+ tokenOut: 'USDC',
+ amount: '1',
+ limitPrice: '110',
+ lifetime: LIFETIME,
+ })
+ expect(again.order).not.toBe(base.order)
+ })
+})
+
+describe('limoAssertSafe', () => {
+ it('passes the payload it built', () => {
+ expect(() =>
+ limoAssertSafe({ family: 'solana', transaction: base.transaction }, ACCOUNT, base.context)
+ ).not.toThrow()
+ expect(baseMessage.version).toBe('legacy')
+ })
+
+ it('passes the re-sealed payload, so the refusals below come from what each changes', () => {
+ expect(() => assertWith(baseMessage)).not.toThrow()
+ })
+
+ it('refuses another global config', () => {
+ const i = createOrderIndex(baseMessage)
+ expect(refusal(() => assertWith(withAccountAt(baseMessage, i, 1, ATTACKER)))).toMatch(
+ /global config/
+ )
+ })
+
+ it('refuses another maker', () => {
+ const i = createOrderIndex(baseMessage)
+ expect(refusal(() => assertWith(withAccountAt(baseMessage, i, 0, ATTACKER)))).toMatch(/maker/)
+ })
+
+ it('refuses another vault', () => {
+ const i = createOrderIndex(baseMessage)
+ expect(refusal(() => assertWith(withAccountAt(baseMessage, i, 7, ATTACKER)))).toMatch(/vault/)
+ })
+
+ it('refuses another pda authority', () => {
+ const i = createOrderIndex(baseMessage)
+ expect(refusal(() => assertWith(withAccountAt(baseMessage, i, 2, ATTACKER)))).toMatch(
+ /authority/
+ )
+ })
+
+ it.each([
+ ['input up by one', BigInt(1000000001), BigInt(110000000)],
+ ['input down by one', BigInt(999999999), BigInt(110000000)],
+ ['output up by one', BigInt(1000000000), BigInt(110000001)],
+ ['output down by one', BigInt(1000000000), BigInt(109999999)],
+ ])('refuses %s', (_name, input, output) => {
+ const i = createOrderIndex(baseMessage)
+ const changed = replaceInstruction(baseMessage, i, { data: createOrderData(input, output) })
+ expect(refusal(() => assertWith(changed))).toMatch(/amount/i)
+ })
+
+ it('refuses order type 1', () => {
+ const i = createOrderIndex(baseMessage)
+ const data = createOrderData(BigInt(1000000000), BigInt(110000000))
+ data[24] = 1
+ expect(refusal(() => assertWith(replaceInstruction(baseMessage, i, { data })))).toMatch(
+ /amount|type/i
+ )
+ })
+
+ it('refuses a system transfer to anyone else', () => {
+ const changed = appended(
+ baseMessage,
+ SYSTEM,
+ [ACCOUNT, ATTACKER],
+ [2, 0, 0, 0, ...u64(BigInt(1))]
+ )
+ expect(refusal(() => assertWith(changed))).toMatch(/SOL transfer/)
+ })
+
+ it('refuses a second system transfer into the wrapped account', () => {
+ const changed = appended(
+ baseMessage,
+ SYSTEM,
+ [ACCOUNT, ownWrappedSolAccount(ACCOUNT)],
+ [2, 0, 0, 0, ...u64(BigInt(1))]
+ )
+ expect(refusal(() => assertWith(changed))).toMatch(/not the limit order/)
+ })
+
+ it('refuses wrapping one lamport more than the order takes', () => {
+ const i = indexOfInstruction(baseMessage, SYSTEM, 2)
+ const changed = replaceInstruction(baseMessage, i, {
+ data: Uint8Array.from([2, 0, 0, 0, ...u64(BigInt(1000000001))]),
+ })
+ expect(refusal(() => assertWith(changed))).toMatch(/not the limit order/)
+ })
+
+ it('refuses a close-account destination that is not the maker', () => {
+ const i = indexOfInstruction(baseMessage, TOKEN, 9)
+ expect(refusal(() => assertWith(withAccountAt(baseMessage, i, 1, ATTACKER)))).toMatch(
+ /does not pay you back/
+ )
+ })
+
+ it('refuses dropping the wrapped account close, which would strand SOL', () => {
+ const i = indexOfInstruction(baseMessage, TOKEN, 9)
+ const changed = {
+ ...baseMessage,
+ instructions: baseMessage.instructions.filter((_, index) => index !== i),
+ }
+ expect(refusal(() => assertWith(changed))).toMatch(/not the limit order/)
+ })
+
+ it('refuses update_order', () => {
+ const changed = appended(baseMessage, LIMO_PROGRAM, [ACCOUNT, base.order], [...UPDATE_ORDER, 1])
+ expect(refusal(() => assertWith(changed))).toMatch(/instruction I didn’t expect/)
+ })
+
+ it('refuses initialize_vault, since a missing vault is refused rather than paid for', () => {
+ const changed = appended(
+ baseMessage,
+ LIMO_PROGRAM,
+ [ACCOUNT],
+ [207, 2, 100, 25, 126, 106, 225, 4]
+ )
+ expect(refusal(() => assertWith(changed))).toMatch(/instruction I didn’t expect/)
+ })
+
+ it('refuses a second extra signer', () => {
+ const changed = {
+ ...baseMessage,
+ header: { ...baseMessage.header, numSignerAccounts: 3 },
+ }
+ expect(refusal(() => assertWith(changed))).toMatch(/someone other than you/)
+ })
+
+ it('refuses a read-only extra signer', () => {
+ const changed = {
+ ...baseMessage,
+ header: { ...baseMessage.header, numReadonlySignerAccounts: 1 },
+ }
+ expect(refusal(() => assertWith(changed))).toMatch(/someone other than you/)
+ })
+
+ it('refuses an extra signer the transaction does not create', () => {
+ const i = createAccountIndex(baseMessage)
+ const changed = {
+ ...baseMessage,
+ instructions: baseMessage.instructions.filter((_, index) => index !== i),
+ }
+ expect(refusal(() => assertWith(changed))).toMatch(/someone other than you/)
+ })
+
+ it('refuses creating an account other than the extra signer', () => {
+ const i = createAccountIndex(baseMessage)
+ expect(refusal(() => assertWith(withAccountAt(baseMessage, i, 1, ATTACKER)))).toMatch(
+ /System Program/
+ )
+ })
+
+ it('refuses another wallet paying for the order account', () => {
+ const i = createAccountIndex(baseMessage)
+ expect(refusal(() => assertWith(withAccountAt(baseMessage, i, 0, ATTACKER)))).toMatch(
+ /System Program/
+ )
+ })
+
+ it('refuses a create_order on an account other than the one created', () => {
+ const i = createOrderIndex(baseMessage)
+ expect(refusal(() => assertWith(withAccountAt(baseMessage, i, 3, ATTACKER)))).toMatch(
+ /order account/
+ )
+ })
+
+ it.each([
+ ['423 bytes', BigInt(3841920), BigInt(423), LIMO_PROGRAM],
+ ['425 bytes', BigInt(3841920), BigInt(425), LIMO_PROGRAM],
+ ['another owner', BigInt(3841920), BigInt(424), ATTACKER],
+ ['more lamports', BigInt(3841921), BigInt(424), LIMO_PROGRAM],
+ ])('refuses an order account of %s', (_name, lamports, space, owner) => {
+ const changed = replaceInstruction(baseMessage, createAccountIndex(baseMessage), {
+ data: createAccountData(lamports, space, owner),
+ })
+ expect(refusal(() => assertWith(changed))).toMatch(/System Program/)
+ })
+
+ it('refuses a second create of the order account', () => {
+ const changed = appended(
+ baseMessage,
+ SYSTEM,
+ [ACCOUNT, base.order],
+ [...createAccountData(BigInt(3841920), BigInt(424), LIMO_PROGRAM)]
+ )
+ expect(refusal(() => assertWith(changed))).toMatch(/someone other than you/)
+ })
+
+ it('refuses a payload a person’s wallet would not pay the fee on', () => {
+ expect(
+ refusal(() =>
+ limoAssertSafe({ family: 'solana', transaction: base.transaction }, ATTACKER, base.context)
+ )
+ ).toMatch(/would not be paid by your wallet/)
+ })
+
+ it('refuses a create with nothing saying what was asked for', () => {
+ expect(
+ refusal(() => limoAssertSafe({ family: 'solana', transaction: base.transaction }, ACCOUNT))
+ ).toMatch(/Nothing was signed or sent\.$/)
+ })
+
+ it('refuses a mint outside SOL, USDC and USDT, even if the amounts say so', () => {
+ expect(
+ refusal(() =>
+ limoAssertSafe({ family: 'solana', transaction: base.transaction }, ACCOUNT, {
+ ...base.context,
+ outputMint: JITO_SOL,
+ })
+ )
+ ).toMatch(/Nothing was signed or sent\.$/)
+ })
+
+ it('refuses a transaction with no order in it', () => {
+ const i = createOrderIndex(baseMessage)
+ const changed = {
+ ...baseMessage,
+ instructions: baseMessage.instructions.filter((_, index) => index !== i),
+ }
+ expect(refusal(() => assertWith(changed))).toMatch(/Nothing was signed or sent\.$/)
+ })
+
+ it('refuses an order signature that is missing', () => {
+ const bytes = Buffer.from(base.transaction, 'base64')
+ // One count byte, the wallet's empty slot, then the order key's slot.
+ bytes.fill(0, 1 + 64, 1 + 64 + 64)
+ expect(
+ refusal(() =>
+ limoAssertSafe(
+ { family: 'solana', transaction: bytes.toString('base64') },
+ ACCOUNT,
+ base.context
+ )
+ )
+ ).toMatch(/signature/)
+ })
+})
+
+describe('the order key’s signature', () => {
+ const sealed = (bytes: Buffer): SigningPayload => ({
+ family: 'solana',
+ transaction: bytes.toString('base64'),
+ })
+
+ it('refuses a raised priority fee kept under the old signature, in words', () => {
+ const i = indexOfInstruction(baseMessage, 'ComputeBudget111111111111111111111111111111', 3)
+ const changed = replaceInstruction(baseMessage, i, {
+ data: Uint8Array.from([3, ...u64(BigInt(200_000))]),
+ })
+ expect(refusal(() => assertWith(changed))).toMatch(/signature.*Nothing was sent\.$/)
+ })
+
+ it('refuses junk in its slot', () => {
+ const bytes = Buffer.from(base.transaction, 'base64')
+ bytes.fill(1, 65, 129)
+ expect(refusal(() => limoAssertSafe(sealed(bytes), ACCOUNT, base.context))).toMatch(
+ /signature.*Nothing was sent\.$/
+ )
+ })
+
+ it('says nothing was sent, not nothing was signed, once the wallet has signed', () => {
+ const decoded = getTransactionDecoder().decode(Buffer.from(base.transaction, 'base64'))
+ const withWallet = getTransactionEncoder().encode({
+ ...decoded,
+ signatures: {
+ ...decoded.signatures,
+ [ACCOUNT as Address]: new Uint8Array(64).fill(1) as SignatureBytes,
+ },
+ })
+ const payload = sealed(Buffer.from(withWallet))
+ expect(refusal(() => limoAssertSafe(payload, ACCOUNT))).toMatch(/Nothing was sent\.$/)
+ })
+
+ it('refuses one bit changed in it', () => {
+ const bytes = Buffer.from(base.transaction, 'base64')
+ bytes[100] = (bytes[100] as number) ^ 1
+ expect(refusal(() => limoAssertSafe(sealed(bytes), ACCOUNT, base.context))).toMatch(
+ /signature.*Nothing was sent\.$/
+ )
+ })
+})
+
+describe('solanaAssertSafe', () => {
+ it('still refuses an extra signer for a venue that creates no account', () => {
+ const assertSafe = solanaAssertSafe(JUPITER_SWAP_POLICY)
+ expect(() => assertSafe({ family: 'solana', transaction: base.transaction }, ACCOUNT)).toThrow(
+ /someone other than you/
+ )
+ })
+})
+
+describe('the wallet path', () => {
+ it('keeps the order’s signature through signing, and passes the check at submit', async () => {
+ const pair = await generateKeyPair()
+ const wallet = await getAddressFromPublicKey(pair.publicKey)
+ const built = await buildCreate({
+ account: wallet,
+ tokenIn: 'SOL',
+ tokenOut: 'USDC',
+ amount: '1',
+ limitPrice: '110',
+ lifetime: LIFETIME,
+ })
+
+ let handed: Uint8Array | undefined
+ const fakeWallet = {
+ name: 'Fake',
+ features: {
+ 'solana:signTransaction': {
+ signTransaction: async ({ transaction }: { transaction: Uint8Array }) => {
+ handed = transaction
+ const decoded = getTransactionDecoder().decode(transaction)
+ const signature = await signBytes(pair.privateKey, decoded.messageBytes)
+ const signedTransaction = getTransactionEncoder().encode({
+ ...decoded,
+ signatures: { ...decoded.signatures, [wallet]: signature },
+ })
+ return [{ signedTransaction: Uint8Array.from(signedTransaction) }]
+ },
+ },
+ },
+ } as unknown as Wallet
+
+ const signed = await signSolana(fakeWallet, { address: wallet } as unknown as WalletAccount, {
+ family: 'solana',
+ transaction: built.transaction,
+ })
+ if (signed.family !== 'solana') throw new Error('Expected Solana.')
+
+ // What the wallet was handed is what the server built, already carrying the order's signature.
+ expect(Buffer.from(handed as Uint8Array).toString('base64')).toBe(built.transaction)
+
+ const { signatures, messageBytes } = getTransactionDecoder().decode(
+ Buffer.from(signed.signedTransaction, 'base64')
+ )
+ const orderKey = await getPublicKeyFromAddress(built.order as Address)
+ expect(
+ await verifySignature(
+ orderKey,
+ signatures[built.order as Address] as SignatureBytes,
+ messageBytes
+ )
+ ).toBe(true)
+ expect(
+ await verifySignature(pair.publicKey, signatures[wallet] as SignatureBytes, messageBytes)
+ ).toBe(true)
+
+ const payload: SigningPayload = { family: 'solana', transaction: signed.signedTransaction }
+ expect(() => limoAssertSafe(payload, wallet, built.context)).not.toThrow()
+ expect(feePayerSignature(signed.signedTransaction)).toBeTruthy()
+ expect(decodeSolanaTransaction(signed.signedTransaction).feePayer).toBe(wallet)
+ })
+
+ it('refuses at submit if the wallet dropped the order’s signature', async () => {
+ const decoded = getTransactionDecoder().decode(Buffer.from(base.transaction, 'base64'))
+ const stripped = getTransactionEncoder().encode({
+ ...decoded,
+ signatures: { ...decoded.signatures, [base.order]: null },
+ })
+ const payload: SigningPayload = {
+ family: 'solana',
+ transaction: Buffer.from(stripped).toString('base64'),
+ }
+ expect(refusal(() => limoAssertSafe(payload, ACCOUNT, base.context))).toMatch(/signature/)
+ })
+})
+
+describe('kamino limit venue', () => {
+ const venue = createKaminoLimitVenue()
+
+ it('is named, costed and only does limit orders', () => {
+ expect(venue.id).toBe('kamino-limit')
+ expect(venue.name).toBe('Kamino Limit Orders')
+ expect(venue.kinds).toEqual(['limit'])
+ expect(venue.limitCost).toEqual({ amount: '0.00684', symbol: 'SOL' })
+ })
+
+ it('prepares an order it would pass, with the notes the person reads', async () => {
+ stubRpc(liveConfig())
+ const prepared = await venue.prepare(SOL_FOR_USDC, ACCOUNT)
+ expect(prepared.venueId).toBe('kamino-limit')
+ expect(prepared.kind).toBe('limit')
+ expect(prepared.pay).toEqual({ amount: '1', symbol: 'SOL' })
+ expect(prepared.receive).toEqual({ amount: '110', symbol: 'USDC', approximate: false })
+ expect(prepared.summary).toMatch(/1 SOL/)
+ expect(prepared.summary).toMatch(/110 USDC/)
+ expect(prepared.notes).toEqual([
+ 'This stays open until it fills or you cancel it. There is no expiry.',
+ 'Locks about 0.00684 SOL until you close the order, then it comes back, less the network fee. Each token account opened for this order locks about 0.002 SOL more, which comes back only if you close that account.',
+ 'Only Pyth Express Relay searchers fill it. It may never fill, and small orders are less likely to.',
+ 'Kamino can upgrade this program, so your escrowed amount carries that risk.',
+ ])
+ expect(prepared.context).toMatchObject({ inputAmount: '1000000000', outputAmount: '110000000' })
+ expect(() => venue.assertSafe(prepared.signing, ACCOUNT, prepared.context)).not.toThrow()
+ expect(decodeSolanaTransaction(solanaTransaction(prepared)).blockhash).toBe(BLOCKHASH)
+ })
+
+ it('reads the pinned config and the input mint’s vault', async () => {
+ stubRpc(liveConfig())
+ await venue.prepare(SOL_FOR_USDC, ACCOUNT)
+ const read = calls.find((c) => c.method === 'getMultipleAccounts')
+ expect(read?.params[0]).toEqual([LIMO_GLOBAL_CONFIG, inputVault(SOL)])
+ })
+
+ it('states a cost other than the usual one, rather than the usual figure', async () => {
+ stubRpc(liveConfig(configBytes({ txnFee: 5_000_000, ataCost: 1_000_000 })))
+ const prepared = await venue.prepare(SOL_FOR_USDC, ACCOUNT)
+ expect(prepared.notes[1]).toMatch(/^Locks about 0\.00984 SOL until/)
+ })
+
+ it.each([
+ ['the fee alone', { txnFee: 10_000_001 }],
+ ['the fee and account cost together', { txnFee: 9_000_000, ataCost: 1_000_001 }],
+ ])('refuses a cost above 0.01 SOL, from %s', async (_name, change) => {
+ stubRpc(liveConfig(configBytes(change)))
+ await expect(venue.prepare(SOL_FOR_USDC, ACCOUNT)).rejects.toThrow(
+ /Nothing was signed or sent\.$/
+ )
+ expect(calls.map((c) => c.method)).not.toContain('getLatestBlockhash')
+ })
+
+ it('takes a cost of exactly 0.01 SOL', async () => {
+ stubRpc(liveConfig(configBytes({ txnFee: 10_000_000 })))
+ await expect(venue.prepare(SOL_FOR_USDC, ACCOUNT)).resolves.toBeDefined()
+ })
+
+ it('refuses when new orders are blocked', async () => {
+ stubRpc(liveConfig(configBytes({ newOrdersBlocked: 1 })))
+ await expect(venue.prepare(SOL_FOR_USDC, ACCOUNT)).rejects.toThrow(
+ /not taking new orders.*Nothing was signed or sent\.$/
+ )
+ })
+
+ it('refuses in emergency mode', async () => {
+ stubRpc(liveConfig(configBytes({ emergency: 1 })))
+ await expect(venue.prepare(SOL_FOR_USDC, ACCOUNT)).rejects.toThrow(
+ /Nothing was signed or sent\.$/
+ )
+ })
+
+ it('refuses a mint whose vault does not exist', async () => {
+ stubRpc(liveConfig(configBytes(), false))
+ await expect(venue.prepare(SOL_FOR_USDC, ACCOUNT)).rejects.toThrow(
+ /vault.*Nothing was signed or sent\.$/
+ )
+ })
+
+ it('refuses a config it can’t read, rather than assume it is fine', async () => {
+ stubRpc({
+ getMultipleAccounts: {
+ context: { slot: 1 },
+ value: [liveAccount(Buffer.alloc(10).toString('base64'), LIMO_PROGRAM), null],
+ },
+ })
+ await expect(venue.prepare(SOL_FOR_USDC, ACCOUNT)).rejects.toThrow(
+ /Nothing was signed or sent\.$/
+ )
+ })
+
+ it('refuses a config another program owns', async () => {
+ stubRpc({
+ getMultipleAccounts: {
+ context: { slot: 1 },
+ value: [
+ liveAccount(configBytes(), ATTACKER),
+ liveAccount(Buffer.alloc(165).toString('base64'), TOKEN),
+ ],
+ },
+ })
+ await expect(venue.prepare(SOL_FOR_USDC, ACCOUNT)).rejects.toThrow(
+ /Nothing was signed or sent\.$/
+ )
+ })
+
+ it('turns an unreachable network into a refusal', async () => {
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn(async () => new Response('', { status: 503 }))
+ )
+ await expect(venue.prepare(SOL_FOR_USDC, ACCOUNT)).rejects.toThrow(ChainActionRefused)
+ })
+
+ it('refuses another pair, kind or malformed account before reading the chain', async () => {
+ stubRpc({})
+ await expect(venue.prepare({ ...SOL_FOR_USDC, tokenIn: 'JitoSOL' }, ACCOUNT)).rejects.toThrow(
+ /Nothing was signed or sent\.$/
+ )
+ await expect(venue.prepare({ ...SOL_FOR_USDC, kind: 'swap' }, ACCOUNT)).rejects.toThrow(
+ /Nothing was signed or sent\.$/
+ )
+ await expect(venue.prepare(SOL_FOR_USDC, 'not-an-address')).rejects.toThrow(
+ /Nothing was signed or sent\.$/
+ )
+ expect(calls).toEqual([])
+ })
+
+ it('refuses more decimal places than the token has, in the same words as any refusal', async () => {
+ stubRpc(liveConfig())
+ await expect(
+ venue.prepare(
+ { ...SOL_FOR_USDC, tokenIn: 'USDC', tokenOut: 'SOL', amount: '1.1234567' },
+ ACCOUNT
+ )
+ ).rejects.toThrow(/Nothing was signed or sent\.$/)
+ })
+
+ it('runs through the shared route, which holds back the cost and checks the price', async () => {
+ stubRpc(liveConfig())
+ const chainApi = { ...solanaModule, priceUsd: async () => 100 }
+ const prepared = await prepareChainAction(chainApi, SOL_FOR_USDC, ACCOUNT)
+ expect(prepared.venueId).toBe('kamino-limit')
+ await expect(
+ prepareChainAction(chainApi, { ...SOL_FOR_USDC, limitPrice: '90' }, ACCOUNT)
+ ).rejects.toThrow(/fill at once/)
+ })
+
+ it('answers to "kamino" for a limit, leaving a deposit with the vault venue', async () => {
+ stubRpc(liveConfig())
+ const chainApi = { ...solanaModule, priceUsd: async () => 100 }
+ const prepared = await prepareChainAction(
+ chainApi,
+ { ...SOL_FOR_USDC, venue: 'kamino' },
+ ACCOUNT
+ )
+ expect(prepared.venueId).toBe('kamino-limit')
+ })
+})
+
+describe('decodeOrder', () => {
+ it('reads a real order account', () => {
+ const fixture = FIXTURE()
+ expect(fixture.owner).toBe(LIMO_PROGRAM)
+ const order = decodeOrder(Buffer.from(fixture.data, 'base64'))
+ expect(order).toEqual({
+ globalConfig: LIMO_GLOBAL_CONFIG,
+ maker: FIXTURE_MAKER,
+ inputMint: SOL,
+ outputMint: USDC,
+ initialInput: BigInt(200000000),
+ expectedOutput: BigInt(37268956),
+ remainingInput: BigInt(0),
+ filledOutput: BigInt(37268956),
+ tip: BigInt(24350),
+ status: 1,
+ })
+ })
+
+ it('reads nothing from an account of another size or kind', () => {
+ const bytes = Buffer.from(FIXTURE().data, 'base64')
+ expect(decodeOrder(bytes.subarray(0, 423))).toBeUndefined()
+ const other = Buffer.from(bytes)
+ other[0] = 0
+ expect(decodeOrder(other)).toBeUndefined()
+ })
+})
+
+describe('limoBook', () => {
+ const book = limoBook()
+
+ function listed(data: string, address = FIXTURE().address, lamports = 3_841_920) {
+ return { pubkey: address, account: liveAccount(data, LIMO_PROGRAM, lamports) }
+ }
+
+ it('asks only for this account’s orders under the pinned config', async () => {
+ stubRpc({ getProgramAccounts: [] })
+ await book.openOrders(FIXTURE_MAKER)
+ const call = calls[0]
+ expect(call?.method).toBe('getProgramAccounts')
+ expect(call?.params[0]).toBe(LIMO_PROGRAM)
+ const { filters } = call?.params[1] as { filters: unknown[] }
+ expect(filters).toEqual([
+ { dataSize: 424 },
+ { memcmp: { offset: 0, bytes: 'PXZJQQ2HEmx', encoding: 'base58' } },
+ { memcmp: { offset: 8, bytes: LIMO_GLOBAL_CONFIG, encoding: 'base58' } },
+ { memcmp: { offset: 40, bytes: FIXTURE_MAKER, encoding: 'base58' } },
+ ])
+ })
+
+ it('shows a filled order, with what closing it brings back', async () => {
+ stubRpc({ getProgramAccounts: [listed(FIXTURE().data)] })
+ const [order] = await book.openOrders(FIXTURE_MAKER)
+ expect(order).toMatchObject({
+ id: FIXTURE().address,
+ venueId: 'kamino-limit',
+ tokenIn: 'SOL',
+ tokenOut: 'USDC',
+ amountIn: '0.2',
+ remainingIn: '0',
+ receivedOut: '37.268956',
+ status: 'filled',
+ limitPrice: '186.345',
+ })
+ // The 3,841,920 lamports the account holds plus the tip of 24,350.
+ expect(order?.collectable).toEqual([{ amount: '0.00386627', symbol: 'SOL' }])
+ expect(order?.note).toMatch(/SOL/)
+ expect(order?.note).not.toMatch(/rent/)
+ })
+
+ it('tells an open order from a partly filled one, and neither has anything to collect', async () => {
+ stubRpc({
+ getProgramAccounts: [
+ listed(orderLike({ status: 0, remaining: BigInt(200000000), filled: BigInt(0) })),
+ listed(orderLike({ status: 0, remaining: BigInt(50000000), filled: BigInt(27951717) })),
+ ],
+ })
+ const orders = await book.openOrders(FIXTURE_MAKER)
+ expect(orders.map((o) => o.status)).toEqual(['open', 'partly'])
+ expect(orders[1]).toMatchObject({ remainingIn: '0.05', receivedOut: '27.951717' })
+ expect(orders.map((o) => o.collectable)).toEqual([undefined, undefined])
+ })
+
+ it('reads a cancelled order as done', async () => {
+ stubRpc({ getProgramAccounts: [listed(orderLike({ status: 2 }))] })
+ expect((await book.openOrders(FIXTURE_MAKER))[0]?.status).toBe('done')
+ })
+
+ it('states a buy’s price the same way up as its limit, the stablecoin per token', async () => {
+ const bytes = Buffer.from(FIXTURE().data, 'base64')
+ Buffer.from(addressBytes(USDC)).copy(bytes, 72)
+ Buffer.from(addressBytes(SOL)).copy(bytes, 136)
+ bytes.writeBigUInt64LE(BigInt(110000000), 200)
+ bytes.writeBigUInt64LE(BigInt(1100000000), 208)
+ stubRpc({ getProgramAccounts: [listed(bytes.toString('base64'))] })
+ const [order] = await book.openOrders(FIXTURE_MAKER)
+ expect(order).toMatchObject({ tokenIn: 'USDC', tokenOut: 'SOL', limitPrice: '100' })
+ })
+
+ it('leaves out an order on a token this app does not offer', async () => {
+ stubRpc({ getProgramAccounts: [listed(orderLike({ outputMint: JITO_SOL }))] })
+ await expect(book.openOrders(FIXTURE_MAKER)).resolves.toEqual([])
+ })
+
+ it('refuses a malformed account', async () => {
+ stubRpc({})
+ await expect(book.openOrders('nope')).rejects.toThrow(ChainActionRefused)
+ })
+
+ it('lets an unreadable chain throw, rather than show no orders', async () => {
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn(async () => new Response('', { status: 429 }))
+ )
+ await expect(book.openOrders(FIXTURE_MAKER)).rejects.toThrow()
+ })
+
+ describe('prepareExit', () => {
+ const closeAccounts = (prepared: PreparedAction): (string | undefined)[] => {
+ const message = open(solanaTransaction(prepared))
+ const close = message.instructions[indexOfInstruction(message, LIMO_PROGRAM, CLOSE_ORDER[0]!)]
+ return (close?.accountIndices ?? []).map((i) => message.staticAccounts[i])
+ }
+
+ it('refuses an order whose maker is not the account', async () => {
+ stubRpc(liveOrder(FIXTURE().data))
+ await expect(book.prepareExit(ACCOUNT, FIXTURE().address, 'collect')).rejects.toThrow(
+ /another wallet.*Nothing was signed or sent\.$/
+ )
+ })
+
+ it('offers a filled order’s collect, saying the SOL comes back', async () => {
+ stubRpc(liveOrder(FIXTURE().data))
+ const prepared = await book.prepareExit(FIXTURE_MAKER, FIXTURE().address, 'collect')
+ expect(prepared.venueId).toBe('kamino-limit')
+ expect(prepared.kind).toBe('limit')
+ expect(prepared.summary).toMatch(/SOL/)
+ expect(prepared.summary).toMatch(/back/)
+ expect(prepared.summary).not.toMatch(/rent/)
+ expect(prepared.receive).toEqual({
+ amount: '0.00386627',
+ symbol: 'SOL',
+ approximate: false,
+ })
+ expect(() => limoAssertSafe(prepared.signing, FIXTURE_MAKER)).not.toThrow()
+ })
+
+ it('counts what the account really holds, as a newer order may also hold its fee', async () => {
+ stubRpc(liveOrder(FIXTURE().data, 6_841_920))
+ const prepared = await book.prepareExit(FIXTURE_MAKER, FIXTURE().address, 'collect')
+ expect(prepared.receive?.amount).toBe('0.00686627')
+ })
+
+ it('gives close_order its accounts in the program’s order', async () => {
+ stubRpc(liveOrder(FIXTURE().data))
+ const prepared = await book.prepareExit(FIXTURE_MAKER, FIXTURE().address, 'collect')
+ expect(closeAccounts(prepared)).toEqual([
+ FIXTURE_MAKER,
+ FIXTURE().address,
+ LIMO_GLOBAL_CONFIG,
+ pdaAuthority(),
+ SOL,
+ USDC,
+ ownWrappedSolAccount(FIXTURE_MAKER),
+ inputVault(SOL),
+ TOKEN,
+ SYSTEM,
+ eventAuthority(),
+ LIMO_PROGRAM,
+ ])
+ })
+
+ it('recreates and closes the wrapped account around closing a SOL order', async () => {
+ stubRpc(liveOrder(FIXTURE().data))
+ const prepared = await book.prepareExit(FIXTURE_MAKER, FIXTURE().address, 'collect')
+ const message = open(solanaTransaction(prepared))
+ const programs = message.instructions
+ .map((ix) => message.staticAccounts[ix.programAddressIndex])
+ .filter((p) => !String(p).startsWith('ComputeBudget'))
+ expect(programs).toEqual([ATA, LIMO_PROGRAM, TOKEN])
+ expect(message.header.numSignerAccounts).toBe(1)
+ })
+
+ it('cancels an open order, saying what comes back', async () => {
+ stubRpc(liveOrder(orderLike({ status: 0, remaining: BigInt(200000000), filled: BigInt(0) })))
+ const prepared = await book.prepareExit(FIXTURE_MAKER, FIXTURE().address, 'cancel')
+ expect(prepared.summary).toMatch(/0\.2 SOL/)
+ expect(prepared.summary).not.toMatch(/rent/)
+ expect(prepared.receive).toEqual({ amount: '0.2', symbol: 'SOL', approximate: false })
+ expect(() => limoAssertSafe(prepared.signing, FIXTURE_MAKER)).not.toThrow()
+ })
+
+ it('leaves a stablecoin order’s token account in place when cancelling', async () => {
+ const bytes = Buffer.from(
+ orderLike({ status: 0, remaining: BigInt(100000000), filled: BigInt(0) }),
+ 'base64'
+ )
+ Buffer.from(addressBytes(USDC)).copy(bytes, 72)
+ Buffer.from(addressBytes(SOL)).copy(bytes, 136)
+ stubRpc(liveOrder(bytes.toString('base64')))
+ const prepared = await book.prepareExit(FIXTURE_MAKER, FIXTURE().address, 'cancel')
+ const message = open(solanaTransaction(prepared))
+ const programs = message.instructions
+ .map((ix) => message.staticAccounts[ix.programAddressIndex])
+ .filter((p) => !String(p).startsWith('ComputeBudget'))
+ expect(programs).toEqual([ATA, LIMO_PROGRAM])
+ expect(closeAccounts(prepared)[6]).toBe(associatedTokenAccount(FIXTURE_MAKER, USDC))
+ expect(prepared.receive).toEqual({ amount: '100', symbol: 'USDC', approximate: false })
+ expect(() => limoAssertSafe(prepared.signing, FIXTURE_MAKER)).not.toThrow()
+ })
+
+ it('won’t collect an order still open, since closing it would cancel it', async () => {
+ stubRpc(liveOrder(orderLike({ status: 0, remaining: BigInt(1), filled: BigInt(1) })))
+ await expect(book.prepareExit(FIXTURE_MAKER, FIXTURE().address, 'collect')).rejects.toThrow(
+ /still open.*Nothing was signed or sent\.$/
+ )
+ })
+
+ it('won’t cancel an order that has filled', async () => {
+ stubRpc(liveOrder(FIXTURE().data))
+ await expect(book.prepareExit(FIXTURE_MAKER, FIXTURE().address, 'cancel')).rejects.toThrow(
+ /already filled.*Nothing was signed or sent\.$/
+ )
+ })
+
+ it('refuses an order that is already cancelled', async () => {
+ stubRpc(liveOrder(orderLike({ status: 2 })))
+ await expect(book.prepareExit(FIXTURE_MAKER, FIXTURE().address, 'collect')).rejects.toThrow(
+ /Nothing was signed or sent\.$/
+ )
+ })
+
+ it('refuses an order that is gone', async () => {
+ stubRpc({ getAccountInfo: { context: { slot: 1 }, value: null } })
+ await expect(book.prepareExit(FIXTURE_MAKER, FIXTURE().address, 'cancel')).rejects.toThrow(
+ /Nothing was signed or sent\.$/
+ )
+ })
+
+ it('refuses an account another program owns, whatever it holds', async () => {
+ stubRpc({
+ getAccountInfo: { context: { slot: 1 }, value: liveAccount(FIXTURE().data, ATTACKER) },
+ })
+ await expect(book.prepareExit(FIXTURE_MAKER, FIXTURE().address, 'collect')).rejects.toThrow(
+ /Nothing was signed or sent\.$/
+ )
+ })
+
+ it('refuses an order under another global config', async () => {
+ const bytes = Buffer.from(FIXTURE().data, 'base64')
+ Buffer.from(addressBytes(ATTACKER)).copy(bytes, 8)
+ stubRpc(liveOrder(bytes.toString('base64')))
+ await expect(book.prepareExit(FIXTURE_MAKER, FIXTURE().address, 'collect')).rejects.toThrow(
+ /Nothing was signed or sent\.$/
+ )
+ })
+
+ it('refuses an order on a token this app does not offer', async () => {
+ stubRpc(liveOrder(orderLike({ outputMint: JITO_SOL })))
+ await expect(book.prepareExit(FIXTURE_MAKER, FIXTURE().address, 'collect')).rejects.toThrow(
+ /Nothing was signed or sent\.$/
+ )
+ })
+
+ it('refuses an id that is not an address', async () => {
+ stubRpc({})
+ await expect(book.prepareExit(FIXTURE_MAKER, 'nope', 'cancel')).rejects.toThrow(
+ /Nothing was signed or sent\.$/
+ )
+ expect(calls).toEqual([])
+ })
+ })
+})
+
+describe('limoAssertSafe on an exit', () => {
+ async function collect(): Promise<{ message: Message; prepared: PreparedAction }> {
+ stubRpc(liveOrder(FIXTURE().data))
+ const prepared = await limoBook().prepareExit(FIXTURE_MAKER, FIXTURE().address, 'collect')
+ return { prepared, message: open(solanaTransaction(prepared)) }
+ }
+ const exit = (message: Message): void =>
+ limoAssertSafe({ family: 'solana', transaction: close(message) }, FIXTURE_MAKER)
+ const position = (message: Message): number =>
+ indexOfInstruction(message, LIMO_PROGRAM, CLOSE_ORDER[0]!)
+
+ it.each([
+ ['maker', 0],
+ ['global config', 2],
+ ['authority', 3],
+ ['token account', 6],
+ ['vault', 7],
+ ])('refuses another %s', async (word, at) => {
+ const { message } = await collect()
+ expect(refusal(() => exit(withAccountAt(message, position(message), at, ATTACKER)))).toMatch(
+ new RegExp(word)
+ )
+ })
+
+ it('refuses a close with an extra transfer', async () => {
+ const { message } = await collect()
+ const changed = appended(
+ message,
+ SYSTEM,
+ [FIXTURE_MAKER, ATTACKER],
+ [2, 0, 0, 0, ...u64(BigInt(1))]
+ )
+ expect(refusal(() => exit(changed))).toMatch(/SOL transfer/)
+ })
+
+ it('refuses two closes in one transaction', async () => {
+ const { message } = await collect()
+ const close0 = message.instructions[position(message)]!
+ const changed = { ...message, instructions: [...message.instructions, close0] }
+ expect(refusal(() => exit(changed))).toMatch(/Nothing was signed or sent\.$/)
+ })
+
+ it('refuses a close of a mint outside SOL, USDC and USDT', async () => {
+ const { message } = await collect()
+ expect(refusal(() => exit(withAccountAt(message, position(message), 5, JITO_SOL)))).toMatch(
+ /Nothing was signed or sent\.$/
+ )
+ })
+
+ it('refuses an account creation on an exit', async () => {
+ const { message } = await collect()
+ const changed = appended(
+ message,
+ SYSTEM,
+ [FIXTURE_MAKER, ATTACKER],
+ [...createAccountData(BigInt(3841920), BigInt(424), LIMO_PROGRAM)]
+ )
+ expect(refusal(() => exit(changed))).toMatch(/System Program/)
+ })
+})
+
+describe('the app knows about it', () => {
+ it('is offered without any configuration', () => {
+ expect(buildSolanaVenues({}).map((v) => v.id)).toContain('kamino-limit')
+ })
+
+ it('is the chain’s default for a limit, and the book answers', () => {
+ expect(solanaModule.defaultVenue.limit).toBe('kamino-limit')
+ expect(solanaModule.limitBook).toBeDefined()
+ })
+
+ it('is listed on the Apps page as something that runs', () => {
+ expect(solanaVenues.find((v) => v.id === 'kamino-limit')).toMatchObject({
+ name: 'Kamino Limit Orders',
+ integration: 'executes',
+ })
+ const row = solanaVenues.find((v) => v.id === 'kamino-limit')
+ expect(row?.capability).toMatch(/Locks about 0\.00684 SOL until you close the order/)
+ expect(row?.capability).not.toMatch(/0\.003/)
+ })
+
+ it('is one of the things this chain says it can do', () => {
+ expect(solanaCapabilities.map((c) => c.kind)).toContain('limit')
+ })
+})
diff --git a/apps/dapp/lib/__tests__/solana-venues.test.ts b/apps/dapp/lib/__tests__/solana-venues.test.ts
index 309d92d9..3365e985 100644
--- a/apps/dapp/lib/__tests__/solana-venues.test.ts
+++ b/apps/dapp/lib/__tests__/solana-venues.test.ts
@@ -6,6 +6,7 @@ import {
import { afterEach, describe, expect, it, vi } from 'vitest'
import { parseChainAction } from '../chains/parse-action'
+import { parseChainLimit } from '../chains/parse-limit'
import { prepareChainAction } from '../chains/prepare'
import { solanaCapabilities } from '../chains/solana/capabilities'
import { decodeSolanaTransaction } from '../chains/solana/assert-safe'
@@ -670,6 +671,7 @@ describe('module', () => {
'1click-solana',
'jupiter-lend',
'kamino',
+ 'kamino-limit',
'kamino-lend',
'jito',
'marinade',
@@ -684,6 +686,7 @@ describe('module', () => {
'jupiter',
'jupiter-lend',
'kamino',
+ 'kamino-limit',
'kamino-lend',
'jito',
'marinade',
@@ -738,8 +741,8 @@ describe('module', () => {
describe('venues page', () => {
it('lists every row of the report', () => {
- expect(solanaVenues).toHaveLength(20)
- expect(new Set(solanaVenues.map((v) => v.id)).size).toBe(20)
+ expect(solanaVenues).toHaveLength(21)
+ expect(new Set(solanaVenues.map((v) => v.id)).size).toBe(21)
for (const v of solanaVenues) {
expect(v.family).toBe('solana')
expect(v.chains).toEqual(['Solana'])
@@ -776,7 +779,17 @@ describe('capabilities', () => {
it('names every wired kind, and each example parses and resolves', async () => {
const kinds = solanaCapabilities.map((c) => c.kind)
expect(new Set(kinds)).toEqual(
- new Set(['swap', 'supply', 'withdraw', 'borrow', 'repay', 'stake', 'unstake', 'claim'])
+ new Set([
+ 'swap',
+ 'supply',
+ 'withdraw',
+ 'borrow',
+ 'repay',
+ 'stake',
+ 'unstake',
+ 'claim',
+ 'limit',
+ ])
)
const swap = solanaCapabilities.find((c) => c.kind === 'swap')!
expect(parseChainAction(`swap 10 USDC to ${swap.symbol}`)).toMatchObject({
@@ -788,5 +801,11 @@ describe('capabilities', () => {
kind: 'supply',
venue: supply.venue,
})
+ const limit = solanaCapabilities.find((c) => c.kind === 'limit')!
+ expect(parseChainLimit(`sell 1 ${limit.symbol} at $110`)).toMatchObject({
+ kind: 'limit',
+ tokenIn: limit.symbol,
+ limitPrice: '110',
+ })
})
})
diff --git a/apps/dapp/lib/__tests__/sui-deepbook.live.test.ts b/apps/dapp/lib/__tests__/sui-deepbook.live.test.ts
new file mode 100644
index 00000000..b97ac147
--- /dev/null
+++ b/apps/dapp/lib/__tests__/sui-deepbook.live.test.ts
@@ -0,0 +1,575 @@
+import { bcs } from '@mysten/sui/bcs'
+import { coinWithBalance, Transaction } from '@mysten/sui/transactions'
+import { fromBase64, normalizeStructTag } from '@mysten/sui/utils'
+import { describe, expect, it } from 'vitest'
+
+import { suiClient } from '../chains/sui/client'
+import { deepbookBook, deepbookLimitVenue } from '../chains/sui/deepbook-limit'
+import { decodeSettled, DEEPBOOK_READS } from '../chains/sui/deepbook-rules'
+import { DEEPBOOK_CALLS } from '../chains/sui/policies'
+import type { PreparedAction } from '../chains/types'
+
+/**
+ * Against mainnet, reading and simulating only. Nothing here signs: a simulation needs no signature,
+ * and each account named is a public address whose key nobody here has. Run by this exact file name.
+ */
+
+const FUNDED = '0xc6fa48a7455bff49124318d5297320441cfac33f83ceea890c485372de917df4'
+const EMPTY = '0x0000000000000000000000000000000000000000000000000000000000001234'
+const PACKAGE = '0xbca0cefe720330e7bb4f2283438b68de6a0e656688db4ac90d61e71fe6151fe8'
+const TYPES = '0x2c8d603bc51326b8c13cef9dd07031a408a48dddb541963357661df5d3204809'
+const REGISTRY = '0xaf16199a2dff736e9f07a845f23c5da6df6f756eddb631aed9d24a93efc4549d'
+const POOL = '0xe05dafb5133bcffb8d59f4e12465dc0e9faeaa05e3e342a08fe135800e3e4407'
+const SUI = '0x2::sui::SUI'
+const USDC = '0xdba34672e30cb065b1f93e3ab55318768fd6fef66c15942c9f7cb846e2f900e7::usdc::USDC'
+const MANAGER_TYPE = `${TYPES}::balance_manager::BalanceManager`
+const POST_ONLY = 3
+const SLOW_MS = 120_000
+const INDEXER = 'https://deepbook-indexer.mainnet.mystenlabs.com'
+const ZERO = BigInt(0)
+
+async function read(build: (tx: Transaction) => void): Promise {
+ const tx = new Transaction()
+ tx.setSender(FUNDED)
+ build(tx)
+ const result = await suiClient().core.simulateTransaction({
+ transaction: tx,
+ include: { commandResults: true, effects: true },
+ })
+ if (result.$kind !== 'Transaction') {
+ throw new Error(JSON.stringify(result.FailedTransaction.effects?.status))
+ }
+ return (result.commandResults ?? []).map((c) => c.returnValues.map((v) => v.bcs))
+}
+
+interface Params {
+ tick: bigint
+ lot: bigint
+ min: bigint
+ makerFee: bigint
+ takerFee: bigint
+ mid: bigint
+}
+
+async function poolParams(): Promise {
+ const [book, trade, mid] = await read((tx) => {
+ const common = { typeArguments: [SUI, USDC] }
+ tx.moveCall({
+ target: `${PACKAGE}::pool::pool_book_params`,
+ ...common,
+ arguments: [tx.object(POOL)],
+ })
+ tx.moveCall({
+ target: `${PACKAGE}::pool::pool_trade_params`,
+ ...common,
+ arguments: [tx.object(POOL)],
+ })
+ tx.moveCall({
+ target: `${PACKAGE}::pool::mid_price`,
+ ...common,
+ arguments: [tx.object(POOL), tx.object.clock()],
+ })
+ })
+ const u64 = (bytes: Uint8Array | undefined): bigint =>
+ BigInt(bcs.u64().parse(bytes as Uint8Array))
+ return {
+ tick: u64(book?.[0]),
+ lot: u64(book?.[1]),
+ min: u64(book?.[2]),
+ takerFee: u64(trade?.[0]),
+ makerFee: u64(trade?.[1]),
+ mid: u64(mid?.[0]),
+ }
+}
+
+interface Attempt {
+ selling: boolean
+ payWithDeep: boolean
+ register: boolean
+ price: bigint
+ quantity: bigint
+ deposit: bigint
+}
+
+function firstUseTransaction(attempt: Attempt): Transaction {
+ const tx = new Transaction()
+ tx.setSender(FUNDED)
+ const manager = tx.moveCall({ target: `${PACKAGE}::balance_manager::new` })
+ const coin = tx.add(
+ attempt.selling
+ ? coinWithBalance({ balance: attempt.deposit })
+ : coinWithBalance({ type: USDC, balance: attempt.deposit, useGasCoin: false })
+ )
+ tx.moveCall({
+ target: `${PACKAGE}::balance_manager::deposit`,
+ typeArguments: [attempt.selling ? SUI : USDC],
+ arguments: [manager, coin],
+ })
+ const proof = tx.moveCall({
+ target: `${PACKAGE}::balance_manager::generate_proof_as_owner`,
+ arguments: [manager],
+ })
+ tx.moveCall({
+ target: `${PACKAGE}::pool::place_limit_order`,
+ typeArguments: [SUI, USDC],
+ arguments: [
+ tx.object(POOL),
+ manager,
+ proof,
+ tx.pure.u64(0),
+ tx.pure.u8(POST_ONLY),
+ tx.pure.u8(0),
+ tx.pure.u64(attempt.price),
+ tx.pure.u64(attempt.quantity),
+ tx.pure.bool(!attempt.selling),
+ tx.pure.bool(attempt.payWithDeep),
+ tx.pure.u64(Date.now() + 7 * 24 * 60 * 60 * 1000),
+ tx.object.clock(),
+ ],
+ })
+ if (attempt.register) {
+ tx.moveCall({
+ target: `${PACKAGE}::balance_manager::register_balance_manager`,
+ arguments: [manager, tx.object(REGISTRY)],
+ })
+ }
+ tx.moveCall({
+ target: '0x2::transfer::public_share_object',
+ typeArguments: [MANAGER_TYPE],
+ arguments: [manager],
+ })
+ return tx
+}
+
+const onTick = (value: bigint, tick: bigint): bigint => (value / tick) * tick
+const away = (mid: bigint, above: boolean): bigint => (mid * BigInt(above ? 12 : 8)) / BigInt(10)
+const display = (raw: bigint): string => (Number(raw) / 1_000_000).toFixed(5)
+
+function amountFor(
+ changes: readonly { coinType: string; address: string; amount: string }[] | undefined,
+ owner: string,
+ coin: string
+): bigint {
+ return (changes ?? [])
+ .filter(
+ (c) => c.address === owner && normalizeStructTag(c.coinType) === normalizeStructTag(coin)
+ )
+ .reduce((sum, c) => sum + BigInt(c.amount), ZERO)
+}
+
+async function dryRun(prepared: PreparedAction, sender: string) {
+ if (prepared.signing.family !== 'sui') throw new Error('Expected Sui.')
+ const result = await suiClient().core.simulateTransaction({
+ transaction: fromBase64(prepared.signing.transaction),
+ include: { effects: true, balanceChanges: true },
+ })
+ const held = result.$kind === 'Transaction' ? result.Transaction : result.FailedTransaction
+ return {
+ held,
+ sui: amountFor(held.balanceChanges, sender, SUI),
+ usdc: amountFor(held.balanceChanges, sender, USDC),
+ }
+}
+
+/** Nothing for a manager that can't be read on its own, as one held inside another object can't. */
+async function ownerOf(manager: string): Promise {
+ try {
+ const { object } = await suiClient().core.getObject({
+ objectId: manager,
+ include: { json: true },
+ })
+ const owner = (object.json as { owner?: unknown } | null)?.owner
+ return object.owner.$kind === 'Shared' && typeof owner === 'string' ? owner : undefined
+ } catch {
+ return undefined
+ }
+}
+
+/**
+ * Managers that have traded lately, those with the fewest trades first. The two market makers hold
+ * most of the book but replace their orders at once and settle as they go, so a person's order is
+ * what these tests need.
+ */
+async function recentMakers(): Promise {
+ const counts = new Map()
+ const now = Math.floor(Date.now() / 1000)
+ for (const daysAgo of [1, 3, 7, 14, 21, 30]) {
+ const end = now - daysAgo * 86_400
+ const reply = await fetch(
+ `${INDEXER}/trades/SUI_USDC?limit=1000&start_time=${end - 86_400}&end_time=${end}`,
+ { signal: AbortSignal.timeout(20_000) }
+ )
+ if (!reply.ok) throw new Error(`The indexer answered ${reply.status}.`)
+ for (const trade of (await reply.json()) as { maker_balance_manager_id: string }[]) {
+ const id = trade.maker_balance_manager_id
+ counts.set(id, (counts.get(id) ?? 0) + 1)
+ }
+ }
+ return [...counts.entries()].sort((a, b) => a[1] - b[1]).map(([id]) => id)
+}
+
+async function managerRef(manager: string) {
+ const { object } = await suiClient().core.getObject({ objectId: manager })
+ if (object.owner.$kind !== 'Shared') throw new Error('The manager is not shared.')
+ return {
+ objectId: manager,
+ initialSharedVersion: object.owner.Shared.initialSharedVersion,
+ mutable: true,
+ }
+}
+
+describe.skipIf(process.env['SKIP_LIVE'] === '1')('DeepBook on mainnet', () => {
+ it(
+ 'reads tick, lot, minimum and fees from the chain, and the pinned shared versions',
+ async () => {
+ const params = await poolParams()
+ console.log('params', params)
+ expect(params.tick).toBe(BigInt(10))
+ expect(params.lot).toBe(BigInt(100_000_000))
+ expect(params.min).toBe(BigInt(1_000_000_000))
+ expect(params.makerFee).toBe(ZERO)
+ const shared = async (id: string): Promise => {
+ const { object } = await suiClient().core.getObject({ objectId: id })
+ return object.owner.$kind === 'Shared'
+ ? object.owner.Shared.initialSharedVersion
+ : undefined
+ }
+ expect(await shared(POOL)).toBe('389750322')
+ expect(await shared(REGISTRY)).toBe('336155480')
+ },
+ SLOW_MS
+ )
+
+ it.each([
+ ['sell, pay_with_deep false', true, false, false],
+ ['buy, pay_with_deep false', false, false, false],
+ ['sell, pay_with_deep true', true, true, false],
+ ['buy, pay_with_deep true', false, true, false],
+ ['sell, registered, pay_with_deep false', true, false, true],
+ ['buy, registered, pay_with_deep false', false, false, true],
+ ])(
+ 'first use in one transaction: %s',
+ async (name, selling, payWithDeep, register) => {
+ const { tick, min, mid } = await poolParams()
+ const price = onTick(away(mid, selling), tick)
+ const deposit = selling ? min : (min * price) / BigInt(1_000_000_000)
+ const result = await suiClient().core.simulateTransaction({
+ transaction: firstUseTransaction({
+ selling,
+ payWithDeep,
+ register,
+ price,
+ quantity: min,
+ deposit,
+ }),
+ include: { effects: true, balanceChanges: true, objectTypes: true },
+ })
+ const held = result.$kind === 'Transaction' ? result.Transaction : result.FailedTransaction
+ const created = held.effects?.changedObjects.filter((c) => c.idOperation === 'Created') ?? []
+ const types = created.map((c) => held.objectTypes?.[c.objectId] ?? '')
+ console.log(name, {
+ status: JSON.stringify(held.effects?.status),
+ gas: held.effects?.gasUsed,
+ created: types,
+ balanceChanges: held.balanceChanges,
+ })
+ expect(held.effects?.status.success).toBe(true)
+ const manager = created.find((c) => held.objectTypes?.[c.objectId] === MANAGER_TYPE)
+ expect(manager?.outputOwner?.$kind).toBe('Shared')
+ expect(types.filter((t) => /TradeCap|DepositCap|WithdrawCap/.test(t))).toEqual([])
+ // Registering adds one entry to the registry's table of owners, and moves nothing.
+ expect(types.filter((t) => /Field BigInt(c.amount) < ZERO)
+ expect(lost.every((c) => c.address === FUNDED)).toBe(true)
+ },
+ SLOW_MS
+ )
+
+ it.each([
+ ['SUI', 'USDC', '1', true],
+ ['USDC', 'SUI', '2', false],
+ ])(
+ 'builds %s to %s for a funded wallet, and it passes its own check and simulates clean',
+ async (tokenIn, tokenOut, amount, selling) => {
+ const { mid } = await poolParams()
+ const prepared = await deepbookLimitVenue.prepare(
+ { kind: 'limit', amount, tokenIn, tokenOut, limitPrice: display(away(mid, selling)) },
+ FUNDED
+ )
+ deepbookLimitVenue.assertSafe(prepared.signing, FUNDED, prepared.context)
+ const { held, usdc } = await dryRun(prepared, FUNDED)
+ if (prepared.signing.family !== 'sui') throw new Error('Expected Sui.')
+ const data = Transaction.from(prepared.signing.transaction).getData()
+ console.log(tokenIn, 'to', tokenOut, {
+ summary: prepared.summary,
+ context: prepared.context,
+ steps: data.commands.map((c) => c.MoveCall?.function ?? c.$kind),
+ status: JSON.stringify(held.effects?.status),
+ budget: data.gasData.budget,
+ gas: held.effects?.gasUsed,
+ usdc,
+ })
+ expect(held.effects?.status.success).toBe(true)
+ expect(prepared.thenPrepareAgain).toBeUndefined()
+ // The reserve keeps back the fee reserve and the limit cost: the budget has to fit in that.
+ expect(Number(data.gasData.budget)).toBeLessThanOrEqual(70_000_000)
+ if (!selling) expect(-usdc).toBe(BigInt(prepared.context?.['deposit'] ?? '0'))
+ },
+ SLOW_MS
+ )
+
+ it.each([
+ ['a buy above the market', 'USDC', 'SUI', '5', true],
+ ['a sell below the market', 'SUI', 'USDC', '1', false],
+ ])(
+ 'refuses %s, because POST_ONLY would abort',
+ async (name, tokenIn, tokenOut, amount, above) => {
+ const { mid } = await poolParams()
+ const refusal = await deepbookLimitVenue
+ .prepare(
+ { kind: 'limit', amount, tokenIn, tokenOut, limitPrice: display(away(mid, above)) },
+ FUNDED
+ )
+ .then(
+ () => undefined,
+ (e: unknown) => (e instanceof Error ? e.message : String(e))
+ )
+ console.log(name, refusal)
+ expect(refusal).toMatch(/Nothing was signed or sent\.$/)
+ },
+ SLOW_MS
+ )
+
+ it(
+ 'answers an account with no SUI and no manager with no orders',
+ async () => {
+ expect(await deepbookBook().openOrders(EMPTY)).toEqual([])
+ },
+ SLOW_MS
+ )
+
+ it(
+ 'simulates cancelling a real open order as its own owner, and reads a registered owner',
+ async () => {
+ let cancelled = false
+ let listed = false
+ for (const manager of (await recentMakers()).slice(0, 40)) {
+ const owner = await ownerOf(manager)
+ if (owner === undefined) continue
+ const rows = await deepbookBook().openOrders(owner)
+ if (rows.length > 0 && !listed) {
+ console.log('rows for a registered owner', owner, rows.slice(0, 3))
+ listed = rows.every((row) => row.venueId === 'deepbook-limit')
+ expect(listed).toBe(true)
+ }
+ if (cancelled) {
+ if (listed) break
+ continue
+ }
+ const held = await managerRef(manager)
+ const reading = await read((tx) => {
+ tx.moveCall({
+ target: `${PACKAGE}::pool::account_open_orders`,
+ typeArguments: [SUI, USDC],
+ arguments: [tx.object(POOL), tx.sharedObjectRef(held)],
+ })
+ })
+ const open = bcs
+ .struct('VecSet', { contents: bcs.vector(bcs.u128()) })
+ .parse(reading[0]?.[0] as Uint8Array).contents
+ const order = open[0]
+ if (order === undefined) continue
+ const prepared = await deepbookBook().prepareExit(owner, `${manager}:${order}`, 'cancel')
+ deepbookLimitVenue.assertSafe(prepared.signing, owner, prepared.context)
+ const { held: run, sui, usdc } = await dryRun(prepared, owner)
+ console.log('cancel', {
+ owner,
+ manager,
+ summary: prepared.summary,
+ receive: prepared.receive,
+ status: JSON.stringify(run.effects?.status),
+ gas: run.effects?.gasUsed,
+ sui,
+ usdc,
+ })
+ expect(run.effects?.status.success).toBe(true)
+ expect(usdc > ZERO || sui > ZERO).toBe(true)
+ cancelled = true
+ }
+ console.log('cancelled a real order:', cancelled, 'listed a registered owner:', listed)
+ expect(cancelled).toBe(true)
+ },
+ SLOW_MS
+ )
+
+ /**
+ * Two new managers trade with each other inside one simulation, so a maker's fill is there to look
+ * at. `withdraw_all` before settling comes back empty, and after settling holds the whole fill.
+ * The book moves between reading the middle and simulating, so a run where the buyer met someone
+ * else's order first, or the seller's order crossed, proves nothing and goes again.
+ */
+ async function tradeWithAnotherManager() {
+ const { tick, min, mid } = await poolParams()
+ const price = onTick(mid, tick)
+ const quote = (min * price) / BigInt(1_000_000_000)
+ const tx = new Transaction()
+ tx.setSender(FUNDED)
+ const pool = tx.object(POOL)
+ const clock = tx.object.clock()
+ const types = { typeArguments: [SUI, USDC] }
+ const expiry = Date.now() + 86_400_000
+ const open = (coin: ReturnType, type: string) => {
+ const manager = tx.moveCall({ target: `${PACKAGE}::balance_manager::new` })
+ tx.moveCall({
+ target: `${PACKAGE}::balance_manager::deposit`,
+ typeArguments: [type],
+ arguments: [manager, tx.add(coin)],
+ })
+ const proof = tx.moveCall({
+ target: `${PACKAGE}::balance_manager::generate_proof_as_owner`,
+ arguments: [manager],
+ })
+ return { manager, proof }
+ }
+ const order = (
+ side: {
+ manager: ReturnType
+ proof: ReturnType
+ },
+ orderType: number,
+ isBid: boolean
+ ) =>
+ tx.moveCall({
+ target: DEEPBOOK_CALLS.place,
+ ...types,
+ arguments: [
+ pool,
+ side.manager,
+ side.proof,
+ tx.pure.u64(0),
+ tx.pure.u8(orderType),
+ tx.pure.u8(0),
+ tx.pure.u64(price),
+ tx.pure.u64(min),
+ tx.pure.bool(isBid),
+ tx.pure.bool(false),
+ tx.pure.u64(expiry),
+ clock,
+ ],
+ })
+ const maker = open(coinWithBalance({ balance: min }), SUI)
+ const taker = open(
+ coinWithBalance({
+ type: USDC,
+ balance: (quote * BigInt(11)) / BigInt(10),
+ useGasCoin: false,
+ }),
+ USDC
+ )
+ order(maker, POST_ONLY, false)
+ order(taker, 0, true)
+ const withdraw = () =>
+ tx.moveCall({
+ target: DEEPBOOK_CALLS.withdrawAll,
+ typeArguments: [USDC],
+ arguments: [maker.manager],
+ })
+ const value = (coin: ReturnType) =>
+ tx.moveCall({ target: '0x2::coin::value', typeArguments: [USDC], arguments: [coin] })
+ const early = withdraw()
+ value(early)
+ tx.moveCall({
+ target: DEEPBOOK_READS.account,
+ ...types,
+ arguments: [pool, maker.manager],
+ })
+ tx.moveCall({
+ target: DEEPBOOK_CALLS.settle,
+ ...types,
+ arguments: [pool, maker.manager, maker.proof],
+ })
+ const late = withdraw()
+ value(late)
+ tx.transferObjects([early, late], FUNDED)
+ for (const manager of [maker.manager, taker.manager]) {
+ tx.moveCall({
+ target: DEEPBOOK_CALLS.share,
+ typeArguments: [MANAGER_TYPE],
+ arguments: [manager],
+ })
+ }
+
+ const result = await suiClient().core.simulateTransaction({
+ transaction: tx,
+ include: { effects: true, commandResults: true },
+ })
+ if (result.$kind !== 'Transaction') return undefined
+ // Building has replaced the coin steps, so the handles above no longer count commands.
+ const outputs = tx.getData().commands.flatMap((command, index) => {
+ const bytes = result.commandResults?.[index]?.returnValues[0]?.bcs
+ const fn = command.MoveCall?.function
+ return (fn === 'value' || fn === 'account') && bytes !== undefined ? [bytes] : []
+ })
+ const [earlyBytes, settledBytes, lateBytes] = outputs
+ if (earlyBytes === undefined || settledBytes === undefined || lateBytes === undefined) {
+ throw new Error('A value did not come back.')
+ }
+ return {
+ price,
+ quote,
+ before: BigInt(bcs.u64().parse(earlyBytes)),
+ after: BigInt(bcs.u64().parse(lateBytes)),
+ settled: decodeSettled(settledBytes),
+ }
+ }
+
+ it(
+ 'shows on the deployed package that a fill can only be collected once settled',
+ async () => {
+ for (let attempt = 0; attempt < 8; attempt += 1) {
+ const trade = await tradeWithAnotherManager()
+ console.log('a fill', trade)
+ if (trade === undefined || trade.settled.quote === ZERO) continue
+ expect(trade.before).toBe(ZERO)
+ expect(trade.settled.quote).toBe(trade.quote)
+ expect(trade.after).toBe(trade.quote)
+ return
+ }
+ throw new Error('The two managers never met on the book.')
+ },
+ SLOW_MS
+ )
+
+ it(
+ 'simulates collecting real money waiting for a registered owner, as much as the row says',
+ async () => {
+ let done = false
+ for (const manager of (await recentMakers()).slice(0, 60)) {
+ const owner = await ownerOf(manager)
+ if (owner === undefined) continue
+ const rows = await deepbookBook().openOrders(owner)
+ const waiting = rows.find((row) => row.collectable !== undefined)
+ if (waiting === undefined) continue
+ const prepared = await deepbookBook().prepareExit(owner, waiting.id, 'collect')
+ deepbookLimitVenue.assertSafe(prepared.signing, owner, prepared.context)
+ const { held, sui, usdc } = await dryRun(prepared, owner)
+ console.log('collect', {
+ owner,
+ waiting,
+ summary: prepared.summary,
+ status: JSON.stringify(held.effects?.status),
+ gas: held.effects?.gasUsed,
+ sui,
+ usdc,
+ })
+ expect(held.effects?.status.success).toBe(true)
+ const inUsdc = waiting.collectable?.find((c) => c.symbol === 'USDC')?.amount
+ if (inUsdc !== undefined) expect(Number(usdc) / 1_000_000).toBe(Number(inUsdc))
+ done = true
+ break
+ }
+ expect(done).toBe(true)
+ },
+ SLOW_MS
+ )
+})
diff --git a/apps/dapp/lib/__tests__/sui-deepbook.test.ts b/apps/dapp/lib/__tests__/sui-deepbook.test.ts
new file mode 100644
index 00000000..3039ec41
--- /dev/null
+++ b/apps/dapp/lib/__tests__/sui-deepbook.test.ts
@@ -0,0 +1,1389 @@
+import { bcs } from '@mysten/sui/bcs'
+import { Transaction } from '@mysten/sui/transactions'
+import { fromBase64, normalizeStructTag, toBase64 } from '@mysten/sui/utils'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+import { deepbookBook, deepbookLimitVenue } from '../chains/sui/deepbook-limit'
+import { decodeOrderId, deepbookAssertSafe, orderFigures } from '../chains/sui/deepbook-rules'
+import { ChainActionRefused, type ChainActionRequest, type PreparedAction } from '../chains/types'
+
+const ME = '0xc6fa48a7455bff49124318d5297320441cfac33f83ceea890c485372de917df4'
+const THIEF = `0x${'dead'.repeat(16)}`
+const MANAGER = `0x${'a1'.repeat(32)}`
+const OTHER_MANAGER = `0x${'a2'.repeat(32)}`
+const GAS_COIN = `0x${'c1'.repeat(32)}`
+const USDC_COIN = `0x${'c2'.repeat(32)}`
+const USDC_COIN_2 = `0x${'c3'.repeat(32)}`
+const DIGEST = '11111111111111111111111111111111'
+
+const PACKAGE = '0xbca0cefe720330e7bb4f2283438b68de6a0e656688db4ac90d61e71fe6151fe8'
+const TYPES = '0x2c8d603bc51326b8c13cef9dd07031a408a48dddb541963357661df5d3204809'
+const POOL = '0xe05dafb5133bcffb8d59f4e12465dc0e9faeaa05e3e342a08fe135800e3e4407'
+const OTHER_POOL = '0xf948981b806057580f91622417534f491da5f61aeaf33d0ed8e69fd5691c95ce'
+const REGISTRY = '0xaf16199a2dff736e9f07a845f23c5da6df6f756eddb631aed9d24a93efc4549d'
+const SUI = normalizeStructTag('0x2::sui::SUI')
+const USDC = '0xdba34672e30cb065b1f93e3ab55318768fd6fef66c15942c9f7cb846e2f900e7::usdc::USDC'
+const USDT = '0x375f70cf2ae4c00bf37117d0c85a2c71545e6ee05c4a5c7d282cd66a4504b068::usdt::USDT'
+const MANAGER_TYPE = `${TYPES}::balance_manager::BalanceManager`
+
+const NOW = 1_793_720_000_000
+const DAY = 86_400_000
+
+const VecSetOfIds = bcs.struct('VecSet', { contents: bcs.vector(bcs.Address) })
+const ManagerBytes = bcs.struct('BalanceManager', {
+ id: bcs.Address,
+ owner: bcs.Address,
+ balances: bcs.struct('Bag', { id: bcs.Address, size: bcs.u64() }),
+ allow_listed: VecSetOfIds,
+})
+const BalancesBytes = bcs.struct('Balances', { base: bcs.u64(), quote: bcs.u64(), deep: bcs.u64() })
+const AccountBytes = bcs.struct('Account', {
+ epoch: bcs.u64(),
+ open_orders: bcs.struct('VecSet', { contents: bcs.vector(bcs.u128()) }),
+ taker_volume: bcs.u128(),
+ maker_volume: bcs.u128(),
+ active_stake: bcs.u64(),
+ inactive_stake: bcs.u64(),
+ created_proposal: bcs.bool(),
+ voted_proposal: bcs.option(bcs.Address),
+ unclaimed_rebates: BalancesBytes,
+ settled_balances: BalancesBytes,
+ owed_balances: BalancesBytes,
+})
+const OrderBytes = bcs.struct('Order', {
+ balance_manager_id: bcs.Address,
+ order_id: bcs.u128(),
+ client_order_id: bcs.u64(),
+ quantity: bcs.u64(),
+ filled_quantity: bcs.u64(),
+ fee_is_deep: bcs.bool(),
+ order_deep_price: bcs.struct('OrderDeepPrice', {
+ asset_is_base: bcs.bool(),
+ deep_per_asset: bcs.u64(),
+ }),
+ epoch: bcs.u64(),
+ status: bcs.u8(),
+ expire_timestamp: bcs.u64(),
+})
+
+const encodeOrderId = (isBid: boolean, price: bigint, counter: bigint): bigint =>
+ (isBid ? BigInt(0) : BigInt(1) << BigInt(127)) + (price << BigInt(64)) + counter
+
+interface FakeOrder {
+ id: bigint
+ quantity: bigint
+ filled: bigint
+ expires: number
+}
+
+function fakeOrder(isBid: boolean, price: string, quantity: string, filled = '0'): FakeOrder {
+ return {
+ id: encodeOrderId(isBid, BigInt(price), BigInt(7)),
+ quantity: BigInt(quantity),
+ filled: BigInt(filled),
+ expires: NOW + 7 * DAY,
+ }
+}
+
+interface ManagerState {
+ owner: string
+ caps?: string[]
+ type?: string
+ orders?: FakeOrder[]
+ sui?: string
+ usdc?: string
+ settledBase?: string
+ settledQuote?: string
+ noAccount?: boolean
+}
+
+interface Chain {
+ managers: Record
+ registered: string[]
+ makerFee: string
+ suiAddressBalance: string
+ usdcAddressBalance: string
+ usdcCoins: { id: string; balance: string }[]
+ simulateFails: boolean
+ builds: number
+}
+
+let chain: Chain
+
+function freshChain(): Chain {
+ return {
+ managers: {},
+ registered: [],
+ makerFee: '0',
+ suiAddressBalance: '0',
+ usdcAddressBalance: '0',
+ usdcCoins: [{ id: USDC_COIN, balance: '20000000' }],
+ simulateFails: false,
+ builds: 0,
+ }
+}
+
+const u64 = (value: string | number | bigint): Uint8Array => bcs.u64().serialize(value).toBytes()
+
+type Read = {
+ function: string
+ typeArguments: readonly string[]
+ arguments: readonly { $kind: string; Input?: number }[]
+}
+
+function objectIdOf(tx: Transaction, argument: { Input?: number } | undefined): string | undefined {
+ const input = tx.getData().inputs[argument?.Input ?? -1]
+ return input?.Object?.SharedObject?.objectId
+}
+
+function answer(tx: Transaction, call: Read): Uint8Array[] {
+ const managerOf = (index: number): ManagerState => {
+ const id = objectIdOf(tx, call.arguments[index])
+ return chain.managers[id ?? ''] ?? { owner: ME }
+ }
+ switch (call.function) {
+ case 'pool_book_params':
+ return [u64('10'), u64('100000000'), u64('1000000000')]
+ case 'pool_trade_params':
+ return [u64('200000'), u64(chain.makerFee), u64('100000000000')]
+ case 'get_balance_manager_ids':
+ return [VecSetOfIds.serialize({ contents: chain.registered }).toBytes()]
+ case 'account_exists':
+ return [
+ bcs
+ .bool()
+ .serialize(managerOf(1).noAccount !== true)
+ .toBytes(),
+ ]
+ case 'balance':
+ return [
+ u64(
+ normalizeStructTag(call.typeArguments[0] as string) === SUI
+ ? (managerOf(0).sui ?? '0')
+ : (managerOf(0).usdc ?? '0')
+ ),
+ ]
+ case 'get_account_order_details':
+ return [
+ bcs
+ .vector(OrderBytes)
+ .serialize(
+ (managerOf(1).orders ?? []).map((o) => ({
+ balance_manager_id: MANAGER,
+ order_id: o.id,
+ client_order_id: '0',
+ quantity: o.quantity,
+ filled_quantity: o.filled,
+ fee_is_deep: false,
+ order_deep_price: { asset_is_base: true, deep_per_asset: '0' },
+ epoch: '900',
+ status: 0,
+ expire_timestamp: String(o.expires),
+ }))
+ )
+ .toBytes(),
+ ]
+ case 'account': {
+ const m = managerOf(1)
+ const none = { base: '0', quote: '0', deep: '0' }
+ return [
+ AccountBytes.serialize({
+ epoch: '900',
+ open_orders: { contents: [] },
+ taker_volume: '0',
+ maker_volume: '0',
+ active_stake: '0',
+ inactive_stake: '0',
+ created_proposal: false,
+ voted_proposal: null,
+ unclaimed_rebates: none,
+ settled_balances: {
+ base: m.settledBase ?? '0',
+ quote: m.settledQuote ?? '0',
+ deep: '0',
+ },
+ owed_balances: none,
+ }).toBytes(),
+ ]
+ }
+ default:
+ throw new Error(`nothing answers ${call.function}`)
+ }
+}
+
+const fakeClient = {
+ core: {
+ simulateTransaction: async ({ transaction }: { transaction: Transaction }) => {
+ if (chain.simulateFails) throw new Error('fetch failed')
+ const commandResults = transaction.getData().commands.map((command) => ({
+ returnValues: answer(transaction, command.MoveCall as Read).map((value) => ({
+ bcs: value,
+ })),
+ mutatedReferences: [],
+ }))
+ return { $kind: 'Transaction', Transaction: {}, commandResults }
+ },
+ getObject: async ({ objectId }: { objectId: string }) => {
+ const manager = chain.managers[objectId]
+ if (manager === undefined) throw new Error('Object not found')
+ return {
+ object: {
+ objectId,
+ version: '500',
+ digest: DIGEST,
+ owner: { $kind: 'Shared', Shared: { initialSharedVersion: '123' } },
+ type: manager.type ?? MANAGER_TYPE,
+ content: ManagerBytes.serialize({
+ id: objectId,
+ owner: manager.owner,
+ balances: { id: OTHER_MANAGER, size: '1' },
+ allow_listed: { contents: manager.caps ?? [] },
+ }).toBytes(),
+ },
+ }
+ },
+ getBalance: async ({ coinType }: { coinType: string }) => {
+ const usdc = chain.usdcCoins.reduce((sum, c) => sum + BigInt(c.balance), BigInt(0))
+ const address =
+ normalizeStructTag(coinType) === SUI ? chain.suiAddressBalance : chain.usdcAddressBalance
+ return {
+ balance: {
+ coinType,
+ balance: String(usdc + BigInt(address)),
+ coinBalance: String(usdc),
+ addressBalance: address,
+ },
+ }
+ },
+ listCoins: async () => ({
+ objects: chain.usdcCoins.map((c) => ({
+ objectId: c.id,
+ digest: DIGEST,
+ version: '9',
+ balance: c.balance,
+ })),
+ hasNextPage: false,
+ cursor: null,
+ }),
+ resolveTransactionPlugin:
+ () =>
+ async (
+ data: { gasData: { budget?: unknown; price?: unknown; payment?: unknown } },
+ _options: unknown,
+ next: () => Promise
+ ) => {
+ chain.builds += 1
+ data.gasData.budget ??= '100000000'
+ data.gasData.price ??= '1000'
+ data.gasData.payment ??= [{ objectId: GAS_COIN, version: '4', digest: DIGEST }]
+ await next()
+ },
+ },
+}
+
+vi.mock('../chains/sui/client', async (original) => ({
+ ...(await original()),
+ suiClient: () => fakeClient,
+}))
+
+beforeEach(() => {
+ chain = freshChain()
+ vi.useFakeTimers({ toFake: ['Date'], now: NOW })
+})
+
+afterEach(() => {
+ vi.useRealTimers()
+})
+
+function sell(amount = '2', limitPrice = '1.10'): ChainActionRequest {
+ return { kind: 'limit', amount, tokenIn: 'SUI', tokenOut: 'USDC', limitPrice }
+}
+
+function buy(amount = '11', limitPrice = '1.00'): ChainActionRequest {
+ return { kind: 'limit', amount, tokenIn: 'USDC', tokenOut: 'SUI', limitPrice }
+}
+
+const bytesOf = (prepared: PreparedAction): string => {
+ if (prepared.signing.family !== 'sui') throw new Error('Expected Sui.')
+ return prepared.signing.transaction
+}
+
+const dataOf = (prepared: PreparedAction) => Transaction.from(bytesOf(prepared)).getData()
+
+const inputIndex = (argument: object | undefined): number =>
+ (argument as { Input?: number } | undefined)?.Input ?? -1
+
+function stepNames(prepared: PreparedAction): string[] {
+ return dataOf(prepared).commands.map((c) =>
+ c.$kind === 'MoveCall' ? c.MoveCall.function : c.$kind
+ )
+}
+
+function callOf(prepared: PreparedAction, fn: string) {
+ const data = dataOf(prepared)
+ const call = data.commands.find((c) => c.MoveCall?.function === fn)?.MoveCall
+ if (call === undefined) throw new Error(`no ${fn}`)
+ const pure = (index: number): Uint8Array => {
+ const input = data.inputs[inputIndex(call.arguments[index])]
+ if (input?.Pure === undefined) throw new Error(`argument ${index} is not pure`)
+ return fromBase64(input.Pure.bytes)
+ }
+ return { call, pure, data }
+}
+
+function placed(prepared: PreparedAction) {
+ const { pure } = callOf(prepared, 'place_limit_order')
+ return {
+ orderType: bcs.u8().parse(pure(4)),
+ selfMatching: bcs.u8().parse(pure(5)),
+ price: bcs.u64().parse(pure(6)),
+ quantity: bcs.u64().parse(pure(7)),
+ isBid: bcs.bool().parse(pure(8)),
+ payWithDeep: bcs.bool().parse(pure(9)),
+ expiry: Number(bcs.u64().parse(pure(10))),
+ }
+}
+
+function splitAmounts(prepared: PreparedAction): string[] {
+ const data = dataOf(prepared)
+ const split = data.commands.find((c) => c.$kind === 'SplitCoins')?.SplitCoins
+ return (split?.amounts ?? []).map((a) =>
+ bcs.u64().parse(fromBase64(data.inputs[inputIndex(a)]?.Pure?.bytes ?? ''))
+ )
+}
+
+const FIRST_USE_SELL = [
+ 'SplitCoins',
+ 'new',
+ 'deposit',
+ 'generate_proof_as_owner',
+ 'place_limit_order',
+ 'register_balance_manager',
+ 'public_share_object',
+]
+
+function useExistingManager(state: Partial = {}): void {
+ chain.registered = [MANAGER]
+ chain.managers[MANAGER] = { owner: ME, ...state }
+}
+
+describe('placing an order', () => {
+ it('sells 2 SUI at 1.10: deposits 2 SUI and places an ask, sharing a new manager last', async () => {
+ const prepared = await deepbookLimitVenue.prepare(sell(), ME)
+ expect(stepNames(prepared)).toEqual(FIRST_USE_SELL)
+ expect(splitAmounts(prepared)).toEqual(['2000000000'])
+ expect(placed(prepared)).toEqual({
+ orderType: 3,
+ selfMatching: 0,
+ price: '1100000',
+ quantity: '2000000000',
+ isBid: false,
+ payWithDeep: false,
+ expiry: NOW + 7 * DAY,
+ })
+ const { call } = callOf(prepared, 'place_limit_order')
+ expect(call.typeArguments.map((t) => normalizeStructTag(t))).toEqual([SUI, USDC])
+ expect(
+ callOf(prepared, 'deposit').call.typeArguments.map((t) => normalizeStructTag(t))
+ ).toEqual([SUI])
+ expect(callOf(prepared, 'public_share_object').call.typeArguments).toEqual([MANAGER_TYPE])
+ expect(prepared.pay).toEqual({ amount: '2', symbol: 'SUI' })
+ expect(prepared.receive).toEqual({ amount: '2.2', symbol: 'USDC', approximate: false })
+ expect(prepared.summary).toBe('Offer 2 SUI on DeepBook for 2.2 USDC, at 1.1 USDC per SUI.')
+ })
+
+ it('takes one signature on first use, so nothing asks to be prepared again', async () => {
+ const prepared = await deepbookLimitVenue.prepare(sell(), ME)
+ expect(prepared.thenPrepareAgain).toBeUndefined()
+ })
+
+ it('buys with 11 USDC at 1.00: deposits 11 USDC and places a bid for 11 SUI', async () => {
+ const prepared = await deepbookLimitVenue.prepare(buy(), ME)
+ expect(splitAmounts(prepared)).toEqual(['11000000'])
+ expect(
+ callOf(prepared, 'deposit').call.typeArguments.map((t) => normalizeStructTag(t))
+ ).toEqual([USDC])
+ expect(placed(prepared)).toMatchObject({
+ price: '1000000',
+ quantity: '11000000000',
+ isBid: true,
+ orderType: 3,
+ })
+ expect(prepared.pay).toEqual({ amount: '11', symbol: 'USDC' })
+ expect(prepared.receive).toEqual({ amount: '11', symbol: 'SUI', approximate: false })
+ })
+
+ it('uses the manager the wallet already has, and creates and shares nothing', async () => {
+ useExistingManager()
+ const prepared = await deepbookLimitVenue.prepare(sell(), ME)
+ expect(stepNames(prepared)).toEqual([
+ 'SplitCoins',
+ 'deposit',
+ 'generate_proof_as_owner',
+ 'place_limit_order',
+ ])
+ const { call, data } = callOf(prepared, 'deposit')
+ expect(data.inputs[inputIndex(call.arguments[0])]?.Object?.SharedObject).toMatchObject({
+ objectId: MANAGER,
+ initialSharedVersion: '123',
+ mutable: true,
+ })
+ expect(prepared.context?.['manager']).toBe(MANAGER)
+ })
+
+ it('says it opens a DeepBook account only when it does', async () => {
+ const first = await deepbookLimitVenue.prepare(sell(), ME)
+ expect(first.notes.join(' ')).toMatch(/opens a DeepBook account/)
+ useExistingManager()
+ const again = await deepbookLimitVenue.prepare(sell(), ME)
+ expect(again.notes.join(' ')).not.toMatch(/opens a DeepBook account/)
+ })
+
+ it('carries its notes, word for word', async () => {
+ const prepared = await deepbookLimitVenue.prepare(sell(), ME)
+ expect(prepared.notes).toEqual(
+ expect.arrayContaining([
+ 'Filled tokens stay in your DeepBook account until you press Collect, which needs a second signature. Cancelling brings back what is left in one signature.',
+ 'Expires in 7 days. After that, cancel it to bring back what is left.',
+ 'It never fills at once. If the price has already passed yours, placing it fails and nothing moves.',
+ "DeepBook's contracts can be upgraded by the multisig that holds their upgrade key, so your deposit carries that risk.",
+ ])
+ )
+ })
+
+ it('refuses under the minimum, read from the chain', async () => {
+ await expect(deepbookLimitVenue.prepare(sell('0.9'), ME)).rejects.toThrow(
+ "DeepBook's smallest SUI order is 1 SUI. Nothing was signed or sent."
+ )
+ await expect(deepbookLimitVenue.prepare(buy('0.9', '1'), ME)).rejects.toThrow(
+ "DeepBook's smallest SUI order is 1 SUI. Nothing was signed or sent."
+ )
+ })
+
+ it('rounds a sell off the lot down, and the card shows the rounded figure', async () => {
+ const prepared = await deepbookLimitVenue.prepare(sell('2.37'), ME)
+ expect(placed(prepared).quantity).toBe('2300000000')
+ expect(splitAmounts(prepared)).toEqual(['2300000000'])
+ expect(prepared.pay).toEqual({ amount: '2.3', symbol: 'SUI' })
+ expect(prepared.summary).toContain('2.3 SUI')
+ })
+
+ it('rounds a buy off the lot down, and deposits only what that order holds', async () => {
+ const prepared = await deepbookLimitVenue.prepare(buy('11', '1.03'), ME)
+ expect(placed(prepared).quantity).toBe('10600000000')
+ expect(splitAmounts(prepared)).toEqual(['10918000'])
+ expect(prepared.pay).toEqual({ amount: '10.918', symbol: 'USDC' })
+ expect(prepared.receive?.amount).toBe('10.6')
+ })
+
+ it('refuses anything but SUI against USDC', async () => {
+ await expect(deepbookLimitVenue.prepare({ ...sell(), tokenOut: 'USDT' }, ME)).rejects.toThrow(
+ 'DeepBook orders here take SUI against USDC only. Nothing was signed or sent.'
+ )
+ await expect(
+ deepbookLimitVenue.prepare(
+ { kind: 'limit', amount: '5', tokenIn: 'USDC', tokenOut: 'USDsui', limitPrice: '1' },
+ ME
+ )
+ ).rejects.toThrow(
+ 'DeepBook orders here take SUI against USDC only. Nothing was signed or sent.'
+ )
+ })
+
+ it('refuses a kind it does not do', async () => {
+ await expect(
+ deepbookLimitVenue.prepare(
+ { kind: 'swap', amount: '2', tokenIn: 'SUI', tokenOut: 'USDC' },
+ ME
+ )
+ ).rejects.toThrow(/Nothing was signed or sent\.$/)
+ })
+
+ it('refuses a resting fee it has not been checked against', async () => {
+ chain.makerFee = '100000'
+ await expect(deepbookLimitVenue.prepare(sell(), ME)).rejects.toThrow(
+ /charges a fee to rest an order.*Nothing was signed or sent\.$/
+ )
+ })
+
+ it('refuses in words when it cannot read the pool, and builds nothing', async () => {
+ chain.simulateFails = true
+ await expect(deepbookLimitVenue.prepare(sell(), ME)).rejects.toThrow(ChainActionRefused)
+ await expect(deepbookLimitVenue.prepare(sell(), ME)).rejects.toThrow(
+ /couldn’t read DeepBook.*Nothing was signed or sent\.$/
+ )
+ expect(chain.builds).toBe(0)
+ })
+
+ it('ends a refusal from the build in the same words as every other', async () => {
+ chain.usdcCoins = [{ id: USDC_COIN, balance: '5' }]
+ await expect(deepbookLimitVenue.prepare(buy(), ME)).rejects.toThrow(
+ /enough USDC.*Nothing was signed or sent\.$/
+ )
+ })
+
+ it('counts the first-use storage in what the reserve keeps back', () => {
+ expect(deepbookLimitVenue.limitCost).toEqual({ amount: '0.02', symbol: 'SUI' })
+ })
+})
+
+describe('an existing manager', () => {
+ it('is refused before anything is built when its owner on chain is someone else', async () => {
+ useExistingManager({ owner: THIEF })
+ await expect(deepbookLimitVenue.prepare(sell(), ME)).rejects.toThrow(
+ /belongs to another wallet.*Nothing was signed or sent\.$/
+ )
+ expect(chain.builds).toBe(0)
+ })
+
+ it('is passed over, and never deposited into, when the object is not a BalanceManager', async () => {
+ useExistingManager({ type: `${TYPES}::balance_manager::Lookalike` })
+ const prepared = await deepbookLimitVenue.prepare(sell(), ME)
+ expect(stepNames(prepared)).toEqual(FIRST_USE_SELL)
+ expect(prepared.context?.['manager']).toBe('new')
+ })
+
+ it('is passed over when it can’t be read on its own, as one held inside another object can’t', async () => {
+ chain.registered = [OTHER_MANAGER, MANAGER]
+ chain.managers[MANAGER] = { owner: ME }
+ const prepared = await deepbookLimitVenue.prepare(sell(), ME)
+ expect(prepared.context?.['manager']).toBe(MANAGER)
+ })
+
+ it('takes no deposit when a cap has been minted on it, and opens a clean one instead', async () => {
+ useExistingManager({ caps: [THIEF] })
+ const prepared = await deepbookLimitVenue.prepare(sell(), ME)
+ expect(stepNames(prepared)).toEqual(FIRST_USE_SELL)
+ expect(prepared.context?.['manager']).toBe('new')
+ })
+
+ it('prefers a clean manager over one with a cap', async () => {
+ chain.registered = [OTHER_MANAGER, MANAGER]
+ chain.managers[OTHER_MANAGER] = { owner: ME, caps: [THIEF] }
+ chain.managers[MANAGER] = { owner: ME }
+ const prepared = await deepbookLimitVenue.prepare(sell(), ME)
+ expect(prepared.context?.['manager']).toBe(MANAGER)
+ })
+})
+
+describe('orderFigures', () => {
+ const book = { tick: BigInt(10), lot: BigInt(100000000), min: BigInt(1000000000) }
+
+ it('rounds a sell price up to the tick, so it never accepts less than stated', () => {
+ const f = orderFigures({ amount: '2', price: '1.100001', selling: true, book })
+ expect(f.price.toString()).toBe('1100010')
+ })
+
+ it('rounds a buy price down to the tick, so it never pays more than stated', () => {
+ const f = orderFigures({ amount: '11', price: '1.100009', selling: false, book })
+ expect(f.price.toString()).toBe('1100000')
+ })
+
+ it('uses the tick it is given, not one it assumes', () => {
+ const f = orderFigures({
+ amount: '2',
+ price: '1.1005',
+ selling: false,
+ book: { ...book, tick: BigInt(10000) },
+ })
+ expect(f.price.toString()).toBe('1100000')
+ })
+
+ it('refuses a price that rounds to nothing', () => {
+ expect(() => orderFigures({ amount: '11', price: '0.000001', selling: false, book })).toThrow(
+ /Nothing was signed or sent\.$/
+ )
+ })
+
+ it('refuses text that is no price', () => {
+ expect(() => orderFigures({ amount: '2', price: 'lots', selling: true, book })).toThrow(
+ /Nothing was signed or sent\.$/
+ )
+ })
+
+ it('says what the receive is at the order’s own price', () => {
+ const f = orderFigures({ amount: '2.37', price: '1.1', selling: true, book })
+ expect(f.quantity.toString()).toBe('2300000000')
+ expect(f.deposit.toString()).toBe('2300000000')
+ expect(f.receive.toString()).toBe('2530000')
+ })
+})
+
+interface JsonCall {
+ package: string
+ module: string
+ function: string
+ typeArguments: string[]
+ arguments: { Input?: number; Result?: number; NestedResult?: [number, number]; GasCoin?: true }[]
+}
+interface JsonMerge {
+ destination: unknown
+ sources: unknown[]
+}
+interface JsonCommand {
+ MoveCall?: JsonCall
+ SplitCoins?: { coin: unknown; amounts: { Input: number }[] }
+ MergeCoins?: JsonMerge
+ TransferObjects?: { objects: unknown[]; address: { Input: number } }
+}
+interface JsonInput {
+ Pure?: { bytes: string }
+ Object?: {
+ SharedObject?: { objectId: string; initialSharedVersion: string; mutable: boolean }
+ ImmOrOwnedObject?: { objectId: string; version: string; digest: string }
+ }
+}
+interface JsonTx {
+ inputs: JsonInput[]
+ commands: JsonCommand[]
+}
+
+async function rewrite(base: string, edit: (data: JsonTx) => void): Promise {
+ const data = JSON.parse(await Transaction.from(base).toJSON()) as JsonTx
+ edit(data)
+ return toBase64(await Transaction.from(JSON.stringify(data)).build())
+}
+
+const callIn = (data: JsonTx, fn: string): JsonCall => {
+ const call = data.commands.find((c) => c.MoveCall?.function === fn)?.MoveCall
+ if (call === undefined) throw new Error(`no ${fn}`)
+ return call
+}
+
+function setPure(data: JsonTx, fn: string, index: number, bytes: Uint8Array): void {
+ const input = callIn(data, fn).arguments[index]?.Input as number
+ const target = data.inputs[input]
+ if (target?.Pure === undefined) throw new Error('not pure')
+ target.Pure.bytes = toBase64(bytes)
+}
+
+function check(transaction: string, context: Record | undefined): void {
+ deepbookAssertSafe({ family: 'sui', transaction }, ME, context)
+}
+
+const NOT_SENT = /Nothing was signed or sent\.$/
+
+describe('DEEPBOOK_POLICY on a placed order', () => {
+ async function firstUse(request = sell()) {
+ const prepared = await deepbookLimitVenue.prepare(request, ME)
+ return { base: bytesOf(prepared), context: prepared.context }
+ }
+
+ it('passes what it built, and a copy of it re-sealed unchanged', async () => {
+ const { base, context } = await firstUse()
+ expect(() => check(base, context)).not.toThrow()
+ const resealed = await rewrite(base, () => undefined)
+ expect(() => check(resealed, context)).not.toThrow()
+ })
+
+ it('passes a buy and an existing manager', async () => {
+ const buying = await firstUse(buy())
+ expect(() => check(buying.base, buying.context)).not.toThrow()
+ useExistingManager()
+ const existing = await firstUse()
+ expect(() => check(existing.base, existing.context)).not.toThrow()
+ })
+
+ it('passes the coin shapes a wallet’s holdings can produce', async () => {
+ chain.usdcCoins = [
+ { id: USDC_COIN, balance: '5000000' },
+ { id: USDC_COIN_2, balance: '15000000' },
+ ]
+ const merged = await deepbookLimitVenue.prepare(buy(), ME)
+ expect(stepNames(merged).slice(0, 2)).toEqual(['MergeCoins', 'SplitCoins'])
+ expect(() => check(bytesOf(merged), merged.context)).not.toThrow()
+
+ chain.usdcCoins = [{ id: USDC_COIN, balance: '11000000' }]
+ const exact = await deepbookLimitVenue.prepare(buy(), ME)
+ expect(stepNames(exact).slice(0, 2)).toEqual(['SplitCoins', 'destroy_zero'])
+ expect(() => check(bytesOf(exact), exact.context)).not.toThrow()
+
+ chain.usdcCoins = []
+ chain.usdcAddressBalance = '20000000'
+ const fromAddress = await deepbookLimitVenue.prepare(buy(), ME)
+ expect(stepNames(fromAddress).slice(0, 3)).toEqual(['redeem_funds', 'SplitCoins', 'send_funds'])
+ expect(() => check(bytesOf(fromAddress), fromAddress.context)).not.toThrow()
+
+ chain.suiAddressBalance = '5000000000'
+ const suiFromAddress = await deepbookLimitVenue.prepare(sell(), ME)
+ expect(stepNames(suiFromAddress).slice(0, 3)).toEqual([
+ 'redeem_funds',
+ 'SplitCoins',
+ 'send_funds',
+ ])
+ expect(() => check(bytesOf(suiFromAddress), suiFromAddress.context)).not.toThrow()
+ })
+
+ it.each([
+ ['mint_trade_cap'],
+ ['mint_deposit_cap'],
+ ['mint_withdraw_cap'],
+ ['set_owner'],
+ ['new_with_custom_owner'],
+ ])('refuses %s, by name', async (fn) => {
+ const { base, context } = await firstUse()
+ const tampered = await rewrite(base, (data) => {
+ callIn(data, 'new').function = fn
+ })
+ expect(() => check(tampered, context)).toThrow(new RegExp(`balance_manager::${fn}`))
+ expect(() => check(tampered, context)).toThrow(NOT_SENT)
+ })
+
+ it('refuses a cap minted beside the order, on the manager it makes', async () => {
+ const { base, context } = await firstUse()
+ const tampered = await rewrite(base, (data) => {
+ const proof = callIn(data, 'generate_proof_as_owner')
+ data.commands.push({
+ MoveCall: { ...proof, function: 'mint_withdraw_cap' },
+ })
+ })
+ expect(() => check(tampered, context)).toThrow(/mint_withdraw_cap/)
+ })
+
+ it('refuses another pool with the right types', async () => {
+ const { base, context } = await firstUse()
+ const tampered = await rewrite(base, (data) => {
+ const input = callIn(data, 'place_limit_order').arguments[0]?.Input as number
+ const shared = data.inputs[input]?.Object?.SharedObject
+ if (shared === undefined) throw new Error('no pool')
+ shared.objectId = OTHER_POOL
+ })
+ expect(() => check(tampered, context)).toThrow(/different market/)
+ expect(() => check(tampered, context)).toThrow(NOT_SENT)
+ })
+
+ it('refuses wrong type arguments on the order', async () => {
+ const { base, context } = await firstUse()
+ const tampered = await rewrite(base, (data) => {
+ callIn(data, 'place_limit_order').typeArguments = [SUI, USDT]
+ })
+ expect(() => check(tampered, context)).toThrow(/other tokens/)
+ expect(() => check(tampered, context)).toThrow(NOT_SENT)
+ })
+
+ it('refuses a deposit of another coin', async () => {
+ const { base, context } = await firstUse()
+ const tampered = await rewrite(base, (data) => {
+ callIn(data, 'deposit').typeArguments = [USDT]
+ })
+ expect(() => check(tampered, context)).toThrow(/other tokens/)
+ })
+
+ it('refuses a deposit of the other listed coin than the side asked for', async () => {
+ const { base, context } = await firstUse()
+ const tampered = await rewrite(base, (data) => {
+ callIn(data, 'deposit').typeArguments = [USDC]
+ })
+ expect(() => check(tampered, context)).toThrow(/deposits the wrong token/)
+ })
+
+ it.each([
+ ['price', 6, () => bcs.u64().serialize('1000000').toBytes(), /price/],
+ ['quantity', 7, () => bcs.u64().serialize('3000000000').toBytes(), /amount/],
+ ['side', 8, () => bcs.bool().serialize(true).toBytes(), /side/],
+ ])('refuses a changed %s', async (_name, index, bytes, words) => {
+ const { base, context } = await firstUse()
+ const tampered = await rewrite(base, (data) => {
+ setPure(data, 'place_limit_order', index, bytes())
+ })
+ expect(() => check(tampered, context)).toThrow(words)
+ expect(() => check(tampered, context)).toThrow(NOT_SENT)
+ })
+
+ it('refuses an order type other than POST_ONLY', async () => {
+ const { base, context } = await firstUse()
+ for (const kind of [0, 1, 2]) {
+ const tampered = await rewrite(base, (data) => {
+ setPure(data, 'place_limit_order', 4, bcs.u8().serialize(kind).toBytes())
+ })
+ expect(() => check(tampered, context)).toThrow(/fill at once/)
+ }
+ })
+
+ it('refuses paying with DEEP, and another self-matching rule', async () => {
+ const { base, context } = await firstUse()
+ const deep = await rewrite(base, (data) => {
+ setPure(data, 'place_limit_order', 9, bcs.bool().serialize(true).toBytes())
+ })
+ expect(() => check(deep, context)).toThrow(/pays its fee/)
+ const matching = await rewrite(base, (data) => {
+ setPure(data, 'place_limit_order', 5, bcs.u8().serialize(1).toBytes())
+ })
+ expect(() => check(matching, context)).toThrow(/matches/)
+ })
+
+ it('refuses an expiry past 8 days, whatever the context says', async () => {
+ const { base, context } = await firstUse()
+ const far = String(NOW + 8 * DAY + 1)
+ const tampered = await rewrite(base, (data) => {
+ setPure(data, 'place_limit_order', 10, bcs.u64().serialize(far).toBytes())
+ })
+ expect(() => check(tampered, context)).toThrow(/8 days/)
+ expect(() => check(tampered, { ...context, expiry: far })).toThrow(/8 days/)
+ })
+
+ it('refuses an expiry changed within the 8 days', async () => {
+ const { base, context } = await firstUse()
+ const tampered = await rewrite(base, (data) => {
+ const sooner = bcs
+ .u64()
+ .serialize(String(NOW + DAY))
+ .toBytes()
+ setPure(data, 'place_limit_order', 10, sooner)
+ })
+ expect(() => check(tampered, context)).toThrow(/expiry/)
+ })
+
+ it('refuses sharing anything but a BalanceManager', async () => {
+ const { base, context } = await firstUse()
+ const tampered = await rewrite(base, (data) => {
+ callIn(data, 'public_share_object').typeArguments = [`${TYPES}::pool::Pool`]
+ })
+ expect(() => check(tampered, context)).toThrow(/shares something/)
+ expect(() => check(tampered, context)).toThrow(NOT_SENT)
+ })
+
+ it('refuses a manager that is not shared at the end', async () => {
+ const { base, context } = await firstUse()
+ const tampered = await rewrite(base, (data) => {
+ data.commands = data.commands.filter((c) => c.MoveCall?.function !== 'public_share_object')
+ })
+ expect(() => check(tampered, context)).toThrow(/steps are not the ones expected/)
+ })
+
+ it('refuses a new manager left out of the registry, which a new device could not find', async () => {
+ const { base, context } = await firstUse()
+ const tampered = await rewrite(base, (data) => {
+ data.commands = data.commands.filter(
+ (c) => c.MoveCall?.function !== 'register_balance_manager'
+ )
+ })
+ expect(() => check(tampered, context)).toThrow(/steps are not the ones expected/)
+ })
+
+ it('refuses another registry', async () => {
+ const { base, context } = await firstUse()
+ const tampered = await rewrite(base, (data) => {
+ const input = callIn(data, 'register_balance_manager').arguments[1]?.Input as number
+ const shared = data.inputs[input]?.Object?.SharedObject
+ if (shared === undefined) throw new Error('no registry')
+ shared.objectId = OTHER_POOL
+ })
+ expect(() => check(tampered, context)).toThrow(/registry/)
+ })
+
+ it('refuses a deposit coin that differs from the expected amount', async () => {
+ const { base, context } = await firstUse()
+ const tampered = await rewrite(base, (data) => {
+ const split = data.commands.find((c) => c.SplitCoins !== undefined)?.SplitCoins
+ const input = data.inputs[split?.amounts[0]?.Input as number]
+ if (input?.Pure === undefined) throw new Error('no amount')
+ input.Pure.bytes = toBase64(bcs.u64().serialize('3000000000').toBytes())
+ })
+ expect(() => check(tampered, context)).toThrow(/deposits a different amount/)
+ expect(() => check(tampered, context)).toThrow(NOT_SENT)
+ })
+
+ describe('a merge beside the split', () => {
+ const SPLIT = { NestedResult: [0, 0] }
+ const coinInput = (objectId: string): JsonInput => ({
+ Object: { ImmOrOwnedObject: { objectId, version: '9', digest: DIGEST } },
+ })
+
+ /** Adds a merge straight after the split at step 0, keeping every later step's references true. */
+ function mergeAfterSplit(
+ data: JsonTx,
+ merge: (coin: (objectId: string) => { Input: number }) => JsonMerge
+ ): void {
+ const coin = (objectId: string): { Input: number } => {
+ data.inputs.push(coinInput(objectId))
+ return { Input: data.inputs.length - 1 }
+ }
+ expect(data.commands[0]?.SplitCoins).toBeDefined()
+ for (const command of data.commands) {
+ for (const argument of command.MoveCall?.arguments ?? []) {
+ if (argument.Result !== undefined && argument.Result > 0) argument.Result += 1
+ if (argument.NestedResult !== undefined && argument.NestedResult[0] > 0) {
+ argument.NestedResult[0] += 1
+ }
+ }
+ }
+ data.commands.splice(1, 0, { MergeCoins: merge(coin) })
+ }
+
+ it('still passes one between other coins, which puts nothing into the deposit', async () => {
+ const { base, context } = await firstUse()
+ const tampered = await rewrite(base, (data) => {
+ mergeAfterSplit(data, (coin) => ({
+ destination: coin(USDC_COIN),
+ sources: [coin(USDC_COIN_2)],
+ }))
+ })
+ expect(() => check(tampered, context)).not.toThrow()
+ })
+
+ it('refuses more coin merged into the deposit, past what the card says', async () => {
+ const { base, context } = await firstUse()
+ const tampered = await rewrite(base, (data) => {
+ mergeAfterSplit(data, (coin) => ({ destination: SPLIT, sources: [coin(USDC_COIN)] }))
+ })
+ expect(() => check(tampered, context)).toThrow(/deposits a different amount/)
+ expect(() => check(tampered, context)).toThrow(NOT_SENT)
+ })
+
+ it('refuses the split merged into something else', async () => {
+ const { base, context } = await firstUse()
+ const tampered = await rewrite(base, (data) => {
+ mergeAfterSplit(data, (coin) => ({ destination: coin(USDC_COIN), sources: [SPLIT] }))
+ })
+ expect(() => check(tampered, context)).toThrow(/deposits a different amount/)
+ })
+
+ it('refuses it however the split is named', async () => {
+ const { base, context } = await firstUse()
+ const tampered = await rewrite(base, (data) => {
+ mergeAfterSplit(data, (coin) => ({
+ destination: { Result: 0 },
+ sources: [coin(USDC_COIN)],
+ }))
+ })
+ expect(() => check(tampered, context)).toThrow(/deposits a different amount/)
+ })
+ })
+
+ it('refuses a deposit that does not come from the split it checked', async () => {
+ const { base, context } = await firstUse()
+ const tampered = await rewrite(base, (data) => {
+ const deposit = callIn(data, 'deposit')
+ deposit.arguments[1] = { GasCoin: true }
+ })
+ expect(() => check(tampered, context)).toThrow(/deposits a different amount/)
+ })
+
+ it('refuses an order on a manager other than the one it funds', async () => {
+ useExistingManager()
+ const { base, context } = await firstUse()
+ const tampered = await rewrite(base, (data) => {
+ data.inputs.push({
+ Object: {
+ SharedObject: { objectId: OTHER_MANAGER, initialSharedVersion: '5', mutable: true },
+ },
+ })
+ callIn(data, 'deposit').arguments[0] = { Input: data.inputs.length - 1 }
+ })
+ expect(() => check(tampered, context)).toThrow(/different DeepBook account/)
+ expect(() => check(tampered, context)).toThrow(NOT_SENT)
+ })
+
+ it('refuses a context naming a manager the bytes do not use', async () => {
+ useExistingManager()
+ const { base, context } = await firstUse()
+ expect(() => check(base, { ...context, manager: OTHER_MANAGER })).toThrow(
+ /different DeepBook account/
+ )
+ expect(() => check(base, { ...context, manager: 'new' })).toThrow(
+ /steps are not the ones expected/
+ )
+ })
+
+ it('refuses a transfer added to the order', async () => {
+ const { base, context } = await firstUse()
+ const tampered = await rewrite(base, (data) => {
+ const me = data.inputs.length
+ data.inputs.push({ Pure: { bytes: toBase64(bcs.Address.serialize(ME).toBytes()) } })
+ data.commands.push({
+ TransferObjects: { objects: [{ Result: 1 }], address: { Input: me } },
+ })
+ })
+ expect(() => check(tampered, context)).toThrow(/steps are not the ones expected/)
+ })
+
+ it.each([
+ ['no context', undefined],
+ ['a missing figure', { manager: 'new', side: 'sell', price: '1100000' }],
+ [
+ 'a figure that is no number',
+ {
+ manager: 'new',
+ side: 'sell',
+ price: 'x',
+ quantity: '2000000000',
+ deposit: '2000000000',
+ expiry: String(NOW + 7 * DAY),
+ },
+ ],
+ ])('refuses %s', async (_name, context) => {
+ const { base } = await firstUse()
+ expect(() => check(base, context)).toThrow(/lost track/)
+ expect(() => check(base, context)).toThrow(NOT_SENT)
+ })
+
+ it('refuses another sender', async () => {
+ const { base, context } = await firstUse()
+ const tx = Transaction.from(base)
+ tx.setSender(THIEF)
+ const tampered = toBase64(await tx.build())
+ expect(() => check(tampered, context)).toThrow(/different account/)
+ })
+})
+
+describe('leaving an order', () => {
+ const ORDER = fakeOrder(false, '1100000', '2000000000', '500000000')
+ const orderKey = `${MANAGER}:${ORDER.id}`
+
+ beforeEach(() => {
+ useExistingManager({
+ orders: [ORDER],
+ sui: '0',
+ usdc: '0',
+ settledQuote: '550000',
+ })
+ })
+
+ it('cancels, then withdraws both coins to the account, in one transaction', async () => {
+ const prepared = await deepbookBook().prepareExit(ME, orderKey, 'cancel')
+ expect(stepNames(prepared)).toEqual([
+ 'generate_proof_as_owner',
+ 'cancel_order',
+ 'withdraw_all',
+ 'withdraw_all',
+ 'TransferObjects',
+ ])
+ const data = dataOf(prepared)
+ const transfer = data.commands.at(-1)?.TransferObjects
+ expect(transfer?.objects).toHaveLength(2)
+ const to = data.inputs[inputIndex(transfer?.address)]?.Pure?.bytes ?? ''
+ expect(bcs.Address.parse(fromBase64(to))).toBe(ME)
+ const types = data.commands
+ .filter((c) => c.MoveCall?.function === 'withdraw_all')
+ .map((c) => normalizeStructTag(c.MoveCall?.typeArguments[0] as string))
+ expect(types).toEqual([SUI, USDC])
+ const { pure } = callOf(prepared, 'cancel_order')
+ expect(bcs.u128().parse(pure(3))).toBe(ORDER.id.toString())
+ expect(() =>
+ deepbookLimitVenue.assertSafe(prepared.signing, ME, prepared.context)
+ ).not.toThrow()
+ })
+
+ it('says what a cancel brings back to the wallet, and where it was', async () => {
+ const prepared = await deepbookBook().prepareExit(ME, orderKey, 'cancel')
+ expect(prepared.summary).toBe(
+ 'Cancel your DeepBook order and bring 1.5 SUI and 0.55 USDC back to your wallet.'
+ )
+ expect(prepared.pay).toEqual({ amount: '0', symbol: 'SUI' })
+ expect(prepared.receive).toEqual({ amount: '1.5', symbol: 'SUI', approximate: false })
+ })
+
+ it('collects what filled: settles it into the manager, then withdraws both', async () => {
+ const prepared = await deepbookBook().prepareExit(ME, MANAGER, 'collect')
+ expect(stepNames(prepared)).toEqual([
+ 'generate_proof_as_owner',
+ 'withdraw_settled_amounts',
+ 'withdraw_all',
+ 'withdraw_all',
+ 'TransferObjects',
+ ])
+ expect(prepared.summary).toBe('Move 0.55 USDC from your DeepBook account to your wallet.')
+ expect(prepared.receive).toEqual({ amount: '0.55', symbol: 'USDC', approximate: false })
+ expect(() =>
+ deepbookLimitVenue.assertSafe(prepared.signing, ME, prepared.context)
+ ).not.toThrow()
+ })
+
+ it('collects from an order’s row without touching the order', async () => {
+ const prepared = await deepbookBook().prepareExit(ME, orderKey, 'collect')
+ expect(stepNames(prepared)).not.toContain('cancel_order')
+ expect(prepared.notes.join(' ')).toMatch(/stays open/)
+ })
+
+ it('counts the manager’s balance as well as what is settled', async () => {
+ useExistingManager({ sui: '1000000000', usdc: '2000000', settledQuote: '550000', orders: [] })
+ const prepared = await deepbookBook().prepareExit(ME, MANAGER, 'collect')
+ expect(prepared.summary).toBe(
+ 'Move 1 SUI and 2.55 USDC from your DeepBook account to your wallet.'
+ )
+ })
+
+ it('refuses a manager that is not the account’s, for both exits', async () => {
+ useExistingManager({ owner: THIEF, orders: [ORDER] })
+ await expect(deepbookBook().prepareExit(ME, orderKey, 'cancel')).rejects.toThrow(
+ 'That order belongs to another wallet. Nothing was signed or sent.'
+ )
+ await expect(deepbookBook().prepareExit(ME, MANAGER, 'collect')).rejects.toThrow(
+ 'That order belongs to another wallet. Nothing was signed or sent.'
+ )
+ expect(chain.builds).toBe(0)
+ })
+
+ it('refuses an order that is not open on this account’s manager', async () => {
+ const stranger = fakeOrder(false, '1100000', '2000000000').id + BigInt(1)
+ await expect(
+ deepbookBook().prepareExit(ME, `${MANAGER}:${stranger}`, 'cancel')
+ ).rejects.toThrow(/no longer open.*Nothing was signed or sent\.$/)
+ expect(chain.builds).toBe(0)
+ })
+
+ it('refuses cancelling the row of waiting money, which has no order', async () => {
+ await expect(deepbookBook().prepareExit(ME, MANAGER, 'cancel')).rejects.toThrow(
+ /nothing to cancel.*Nothing was signed or sent\.$/
+ )
+ })
+
+ it('refuses collecting when nothing is waiting', async () => {
+ useExistingManager({ orders: [] })
+ await expect(deepbookBook().prepareExit(ME, MANAGER, 'collect')).rejects.toThrow(
+ /nothing waiting.*Nothing was signed or sent\.$/
+ )
+ expect(chain.builds).toBe(0)
+ })
+
+ it('refuses an order that is not there, and something that is no order', async () => {
+ await expect(deepbookBook().prepareExit(ME, `${OTHER_MANAGER}:5`, 'cancel')).rejects.toThrow(
+ /Nothing was signed or sent\.$/
+ )
+ for (const bad of ['nonsense', `${MANAGER}:abc`, `${MANAGER}:`, '0x1']) {
+ await expect(deepbookBook().prepareExit(ME, bad, 'cancel')).rejects.toThrow(
+ 'That is not a DeepBook order. Nothing was signed or sent.'
+ )
+ }
+ })
+
+ it('refuses when the manager is not a BalanceManager', async () => {
+ useExistingManager({ type: `${TYPES}::balance_manager::Lookalike` })
+ await expect(deepbookBook().prepareExit(ME, MANAGER, 'collect')).rejects.toThrow(
+ 'That is not a DeepBook order. Nothing was signed or sent.'
+ )
+ })
+
+ describe('their check', () => {
+ async function exit(action: 'cancel' | 'collect') {
+ const prepared = await deepbookBook().prepareExit(
+ ME,
+ action === 'cancel' ? orderKey : MANAGER,
+ action
+ )
+ return bytesOf(prepared)
+ }
+
+ it('passes each re-sealed unchanged', async () => {
+ for (const action of ['cancel', 'collect'] as const) {
+ const resealed = await rewrite(await exit(action), () => undefined)
+ expect(() => check(resealed, undefined)).not.toThrow()
+ }
+ })
+
+ it('refuses sending the coins to someone else', async () => {
+ const tampered = await rewrite(await exit('cancel'), (data) => {
+ const to = data.commands.at(-1)?.TransferObjects?.address.Input as number
+ const input = data.inputs[to]
+ if (input?.Pure === undefined) throw new Error('no recipient')
+ input.Pure.bytes = toBase64(bcs.Address.serialize(THIEF).toBytes())
+ })
+ expect(() => check(tampered, undefined)).toThrow(/another address/)
+ })
+
+ it('refuses leaving a withdrawn coin out of the transfer', async () => {
+ const tampered = await rewrite(await exit('collect'), (data) => {
+ data.commands.at(-1)?.TransferObjects?.objects.pop()
+ })
+ expect(() => check(tampered, undefined)).toThrow(/does not bring both/)
+ })
+
+ it('refuses a withdrawal of another coin', async () => {
+ const tampered = await rewrite(await exit('collect'), (data) => {
+ const call = data.commands.find((c) => c.MoveCall?.function === 'withdraw_all')?.MoveCall
+ if (call === undefined) throw new Error('no withdrawal')
+ call.typeArguments = [USDT]
+ })
+ expect(() => check(tampered, undefined)).toThrow(/other tokens/)
+ })
+
+ it('refuses the same coin withdrawn twice', async () => {
+ const tampered = await rewrite(await exit('collect'), (data) => {
+ const calls = data.commands.filter((c) => c.MoveCall?.function === 'withdraw_all')
+ if (calls[1]?.MoveCall === undefined) throw new Error('no withdrawal')
+ calls[1].MoveCall.typeArguments = [SUI]
+ })
+ expect(() => check(tampered, undefined)).toThrow(/does not bring both/)
+ })
+
+ it('refuses a cancel on another pool', async () => {
+ const tampered = await rewrite(await exit('cancel'), (data) => {
+ const input = callIn(data, 'cancel_order').arguments[0]?.Input as number
+ const shared = data.inputs[input]?.Object?.SharedObject
+ if (shared === undefined) throw new Error('no pool')
+ shared.objectId = OTHER_POOL
+ })
+ expect(() => check(tampered, undefined)).toThrow(/different market/)
+ })
+
+ it('refuses an exit that takes from a second manager', async () => {
+ const tampered = await rewrite(await exit('collect'), (data) => {
+ data.inputs.push({
+ Object: {
+ SharedObject: { objectId: OTHER_MANAGER, initialSharedVersion: '5', mutable: true },
+ },
+ })
+ const call = data.commands.find((c) => c.MoveCall?.function === 'withdraw_all')?.MoveCall
+ if (call === undefined) throw new Error('no withdrawal')
+ call.arguments[0] = { Input: data.inputs.length - 1 }
+ })
+ expect(() => check(tampered, undefined)).toThrow(/different DeepBook account/)
+ })
+
+ it('refuses extra steps', async () => {
+ const tampered = await rewrite(await exit('collect'), (data) => {
+ const proof = callIn(data, 'generate_proof_as_owner')
+ data.commands.splice(1, 0, { MoveCall: { ...proof } })
+ })
+ expect(() => check(tampered, undefined)).toThrow(/steps are not the ones expected/)
+ })
+ })
+})
+
+describe('reading orders', () => {
+ it('reads an open sell and an open buy, with their prices and expiries', async () => {
+ const asking = fakeOrder(false, '1100000', '2000000000')
+ const bidding = fakeOrder(true, '900000', '3000000000')
+ useExistingManager({ orders: [asking, bidding] })
+ const orders = await deepbookBook().openOrders(ME)
+ expect(orders).toEqual([
+ {
+ id: `${MANAGER}:${asking.id}`,
+ venueId: 'deepbook-limit',
+ tokenIn: 'SUI',
+ tokenOut: 'USDC',
+ amountIn: '2',
+ remainingIn: '2',
+ receivedOut: '0',
+ limitPrice: '1.1',
+ status: 'open',
+ expiresAt: NOW + 7 * DAY,
+ },
+ {
+ id: `${MANAGER}:${bidding.id}`,
+ venueId: 'deepbook-limit',
+ tokenIn: 'USDC',
+ tokenOut: 'SUI',
+ amountIn: '2.7',
+ remainingIn: '2.7',
+ receivedOut: '0',
+ limitPrice: '0.9',
+ status: 'open',
+ expiresAt: NOW + 7 * DAY,
+ },
+ ])
+ })
+
+ it('reads what a partly filled order has given and got', async () => {
+ useExistingManager({ orders: [fakeOrder(false, '1100000', '2000000000', '500000000')] })
+ const [order] = await deepbookBook().openOrders(ME)
+ expect(order).toMatchObject({
+ status: 'partly',
+ amountIn: '2',
+ remainingIn: '1.5',
+ receivedOut: '0.55',
+ })
+ expect(order?.note).toMatch(/DeepBook account, not your wallet/)
+ })
+
+ it('reads a partly filled buy', async () => {
+ useExistingManager({ orders: [fakeOrder(true, '900000', '3000000000', '1000000000')] })
+ const [order] = await deepbookBook().openOrders(ME)
+ expect(order).toMatchObject({
+ tokenIn: 'USDC',
+ tokenOut: 'SUI',
+ status: 'partly',
+ amountIn: '2.7',
+ remainingIn: '1.8',
+ receivedOut: '1',
+ })
+ })
+
+ it('says an expired order can only be cancelled', async () => {
+ const expired = { ...fakeOrder(false, '1100000', '2000000000'), expires: NOW - 1 }
+ useExistingManager({ orders: [expired] })
+ const [order] = await deepbookBook().openOrders(ME)
+ expect(order?.status).toBe('open')
+ expect(order?.note).toMatch(/expired.*Cancel/)
+ })
+
+ it('marks money waiting as collectable, and never as being in the wallet', async () => {
+ useExistingManager({ sui: '1000000000', usdc: '2000000', settledQuote: '550000' })
+ const orders = await deepbookBook().openOrders(ME)
+ expect(orders).toHaveLength(1)
+ const [waiting] = orders
+ expect(waiting).toMatchObject({ id: MANAGER, status: 'filled' })
+ expect(waiting?.collectable).toEqual([
+ { amount: '2.55', symbol: 'USDC' },
+ { amount: '1', symbol: 'SUI' },
+ ])
+ expect(waiting?.note).toBe(
+ 'This is in your DeepBook account, not your wallet, until you press Collect.'
+ )
+ })
+
+ it('counts settled money that has not reached the manager yet', async () => {
+ useExistingManager({ settledBase: '1000000000' })
+ const [waiting] = await deepbookBook().openOrders(ME)
+ expect(waiting?.collectable).toEqual([{ amount: '1', symbol: 'SUI' }])
+ expect(waiting).toMatchObject({ tokenOut: 'SUI', tokenIn: 'USDC', receivedOut: '1' })
+ })
+
+ it('lists an open order and the waiting money apart', async () => {
+ useExistingManager({
+ orders: [fakeOrder(false, '1100000', '2000000000', '500000000')],
+ settledQuote: '550000',
+ })
+ const orders = await deepbookBook().openOrders(ME)
+ expect(orders.map((o) => o.status)).toEqual(['partly', 'filled'])
+ expect(orders[0]?.collectable).toBeUndefined()
+ })
+
+ it('shows nothing for a wallet with no manager, or a manager with nothing', async () => {
+ expect(await deepbookBook().openOrders(ME)).toEqual([])
+ useExistingManager({ noAccount: true })
+ expect(await deepbookBook().openOrders(ME)).toEqual([])
+ })
+
+ it('still reads the rest when one registered manager can’t be read on its own', async () => {
+ chain.registered = [OTHER_MANAGER, MANAGER]
+ chain.managers[MANAGER] = { owner: ME, sui: '1000000000' }
+ const orders = await deepbookBook().openOrders(ME)
+ expect(orders.map((o) => o.id)).toEqual([MANAGER])
+ })
+
+ it('leaves out a manager that is not the account’s', async () => {
+ useExistingManager({ owner: THIEF, orders: [fakeOrder(false, '1100000', '2000000000')] })
+ expect(await deepbookBook().openOrders(ME)).toEqual([])
+ })
+
+ it('refuses something that is no address, and lets a failed read reach the route', async () => {
+ await expect(deepbookBook().openOrders('nope')).rejects.toThrow(ChainActionRefused)
+ chain.simulateFails = true
+ await expect(deepbookBook().openOrders(ME)).rejects.toThrow('fetch failed')
+ })
+})
+
+describe('decodeOrderId', () => {
+ it('reads the side and price out of an order id', () => {
+ expect(decodeOrderId(encodeOrderId(true, BigInt(900000), BigInt(3)))).toEqual({
+ isBid: true,
+ price: BigInt(900000),
+ })
+ expect(decodeOrderId(encodeOrderId(false, BigInt(1100000), BigInt(3)))).toEqual({
+ isBid: false,
+ price: BigInt(1100000),
+ })
+ })
+})
+
+describe('the constants', () => {
+ it('are the published ids', async () => {
+ const { DEEPBOOK_PACKAGE, DEEPBOOK_POOL, DEEPBOOK_REGISTRY } =
+ await import('../chains/sui/policies')
+ expect(DEEPBOOK_PACKAGE).toBe(PACKAGE)
+ expect(DEEPBOOK_POOL).toBe(POOL)
+ expect(DEEPBOOK_REGISTRY).toBe(REGISTRY)
+ })
+})
diff --git a/apps/dapp/lib/__tests__/sui-module.test.ts b/apps/dapp/lib/__tests__/sui-module.test.ts
index 64aa95fc..237bd5ef 100644
--- a/apps/dapp/lib/__tests__/sui-module.test.ts
+++ b/apps/dapp/lib/__tests__/sui-module.test.ts
@@ -306,6 +306,7 @@ describe('the module', () => {
'springsui',
'aftermath',
'walrus',
+ 'deepbook-limit',
'send',
])
})
@@ -321,6 +322,7 @@ describe('the module', () => {
['springsui', ['stake', 'unstake']],
['aftermath', ['stake', 'unstake']],
['walrus', ['stake', 'unstake', 'claim']],
+ ['deepbook-limit', ['limit']],
])
expect(suiModule.defaultVenue).toEqual({
swap: 'cetus',
@@ -331,7 +333,10 @@ describe('the module', () => {
stake: 'springsui',
unstake: 'springsui',
send: 'send',
+ limit: 'deepbook-limit',
})
+ expect(suiModule.limitBook).toBeDefined()
+ expect(suiModule.venues.find((v) => v.id === 'deepbook-limit')?.name).toBe('DeepBook')
})
it('offers one capability per wired kind', () => {
@@ -344,14 +349,15 @@ describe('the module', () => {
{ kind: 'stake', venue: 'SpringSui', symbol: 'SUI' },
{ kind: 'unstake', venue: 'SpringSui', symbol: 'sSUI' },
{ kind: 'claim', venue: 'Walrus', symbol: 'WAL' },
+ { kind: 'limit', venue: 'DeepBook', symbol: 'SUI' },
])
})
})
describe('the venue list', () => {
it('has every row of the report', () => {
- expect(suiVenues).toHaveLength(16)
- expect(new Set(suiVenues.map((v) => v.id)).size).toBe(16)
+ expect(suiVenues).toHaveLength(17)
+ expect(new Set(suiVenues.map((v) => v.id)).size).toBe(17)
})
it('is all Sui, with a real link and an honest reason', () => {
@@ -372,6 +378,19 @@ describe('the venue list', () => {
for (const venue of claimed) expect(venue.capability).toBeTruthy()
})
+ it('lists DeepBook twice, its swaps and its limit orders, both running', () => {
+ expect(suiVenues.filter((v) => v.name.startsWith('DeepBook'))).toEqual([
+ expect.objectContaining({ id: 'deepbook', integration: 'executes' }),
+ expect.objectContaining({
+ id: 'deepbook-limit',
+ name: 'DeepBook Limit Orders',
+ category: 'orderbook',
+ integration: 'executes',
+ url: 'https://deepbook.xyz',
+ }),
+ ])
+ })
+
it('leaves Linq listed', () => {
const linq = suiVenues.find((v) => v.name.startsWith('Linq'))
expect(linq?.integration).toBeUndefined()
diff --git a/apps/dapp/lib/__tests__/v3-swap-suite.ts b/apps/dapp/lib/__tests__/v3-swap-suite.ts
index 895487b9..1e737bbe 100644
--- a/apps/dapp/lib/__tests__/v3-swap-suite.ts
+++ b/apps/dapp/lib/__tests__/v3-swap-suite.ts
@@ -133,6 +133,8 @@ export function fakeClient(
return {
tokenBalance: async () => overrides.balance ?? BigInt('1000000000000000000000000'),
nativeBalance: async () => overrides.native ?? BigInt('1000000000000000000000000'),
+ allowance: async () => BigInt(0),
+ code: async () => undefined,
waitForReceipt: async () =>
({ status: 'success', from: ACCOUNT, to: '', logs: [], ...overrides.receipt }) as EvmReceipt,
transaction: async () => overrides.sent ?? { input: '0x', value: ZERO },
diff --git a/apps/dapp/lib/adapters/evm-adapter.ts b/apps/dapp/lib/adapters/evm-adapter.ts
index 802ea950..69dc46e3 100644
--- a/apps/dapp/lib/adapters/evm-adapter.ts
+++ b/apps/dapp/lib/adapters/evm-adapter.ts
@@ -7,16 +7,21 @@ import {
monadDescriptor,
} from '@intent/config'
import type { ChainDescriptor } from '@intent/types'
-import { BaseError, UserRejectedRequestError, type Address, type Hex } from 'viem'
+import { type Address, type Hex } from 'viem'
import { useAccount, useBalance, useConnect, useDisconnect, useSwitchChain } from 'wagmi'
-import { getAccount, sendTransaction, switchChain, waitForTransactionReceipt } from 'wagmi/actions'
+import {
+ getAccount,
+ sendTransaction,
+ signTypedData,
+ switchChain,
+ waitForTransactionReceipt,
+} from 'wagmi/actions'
import { avalanche, monad } from 'viem/chains'
import type { ChainAdapter, ChainWallet } from '../chain-adapter'
+import { signTypedOn, walletFailure, type TypedWallet } from '../chains/evm/sign-typed'
import { withArticle, type SignedOutcome, type SigningPayload } from '../chains/types'
import { arcTestnet, wagmiConfig } from '../wagmi.config'
-import { isUserRejection } from '../wallet/connector'
-import { walletErrorMessage } from '../wallet/error-message'
/**
* An EVM chain's wallet, behind the shared adapter interface. wagmi holds one
@@ -93,8 +98,16 @@ function evmAdapter(
}
}
+/** wagmi's actions, as the pure typed-signing helper asks for them. */
+const typedWallet: TypedWallet = {
+ account: () => getAccount(wagmiConfig),
+ switchChain: (chainId) => switchChain(wagmiConfig, { chainId: chainId as 43114 | 143 }),
+ signTypedData: (args) => signTypedData(wagmiConfig, args),
+}
+
function signOn(name: string, chainId: 43114 | 143): NonNullable {
return async (payload: SigningPayload): Promise => {
+ if (payload.family === 'evm-typed') return signTypedOn(name, chainId, payload, typedWallet)
if (payload.family !== 'evm') {
return {
ok: false,
@@ -143,15 +156,7 @@ function signOn(name: string, chainId: 43114 | 143): NonNullable cause instanceof UserRejectedRequestError) !== null) ||
- isUserRejection(detail)
- return declined
- ? { ok: false, reason: 'rejected' }
- : { ok: false, reason: 'wallet_error', detail }
+ return walletFailure(e)
}
}
}
diff --git a/apps/dapp/lib/agents/brain.ts b/apps/dapp/lib/agents/brain.ts
index b45ccc64..34237eb7 100644
--- a/apps/dapp/lib/agents/brain.ts
+++ b/apps/dapp/lib/agents/brain.ts
@@ -1,5 +1,6 @@
import type { ParsedIntent } from '../parse-intent'
import type { PerpFacts } from '../perps/market-facts'
+import type { RestOption } from './rest-option'
/**
* The seam between the competition flow and whatever produces agent proposals.
@@ -48,6 +49,8 @@ export interface MarketContext {
prices: Record
/** Venues the agent may name, already filtered to the active chain. */
venues: { id: string; name: string; category: string }[]
+ /** A resting order the agents may propose instead of filling. Off Stellar only. */
+ rest?: RestOption
volatilityHint: 'low' | 'normal' | 'elevated'
gasHint: 'cheap' | 'normal' | 'expensive'
/**
diff --git a/apps/dapp/lib/agents/brains/openai-compatible.ts b/apps/dapp/lib/agents/brains/openai-compatible.ts
index 2d1add13..f58b1b63 100644
--- a/apps/dapp/lib/agents/brains/openai-compatible.ts
+++ b/apps/dapp/lib/agents/brains/openai-compatible.ts
@@ -13,6 +13,7 @@ import type {
import { configuredLendingVenues } from '../../lend/venues'
import { anchorOn, anchorsOn } from '../../offramp/anchors'
import { systemPromptFor } from '../brief'
+import { restContextLines } from '../rest-option'
import type { ChainRouteQuote } from '../chain-routes'
import { promptSafe } from '../prompt-safe'
import { SUBMIT_PROPOSAL_TOOL, validateProposal } from '../tool-schema'
@@ -146,7 +147,7 @@ export function buildMessages(req: ProposalRequest): { role: string; content: st
.join(', ')
return [
- { role: 'system', content: systemPromptFor(req.chain) },
+ { role: 'system', content: systemPromptFor(req.chain, req.market.rest !== undefined) },
{
role: 'system',
content: [
@@ -197,6 +198,7 @@ export function buildMessages(req: ProposalRequest): { role: string; content: st
: []),
...perpLines(req),
...routeLines(req),
+ ...(req.market.rest !== undefined ? restContextLines(req.market.rest) : []),
].join('\n'),
},
{
diff --git a/apps/dapp/lib/agents/brief.ts b/apps/dapp/lib/agents/brief.ts
index 8b81ab28..81225dfa 100644
--- a/apps/dapp/lib/agents/brief.ts
+++ b/apps/dapp/lib/agents/brief.ts
@@ -96,7 +96,16 @@ Return your answer by calling the submit_proposal tool. Do not reply with prose.
Your reasoning is shown directly to the user in a chat bubble: at most two sentences and under 300 characters, no preamble, no restating the question. Never use an em dash or an en dash; use a period or a comma. Decide quickly and commit. Do not enumerate alternatives you are not going to choose, and do not re-derive the same figure twice.`
+/**
+ * Added only when the market context lists a resting option. The plan stays a single fill unless
+ * the agent judges that resting is worth its cost.
+ */
+const REST_ADDENDUM = `
+
+The market context lists a resting option. Besides filling now, you may propose resting: the person's order waits on the book at your price and may never fill, and placing it costs the amount stated there, so weigh that cost against the amount being traded. A very small swap should fill. To rest, set executionMode "rest" and restPriceUsd to a price inside the range the market context gives, still choosing the route you would take to fill. Any price outside that range, and any split, becomes a fill. The person reviews and signs the order themselves.`
+
/** The brief for this chain. Stellar's is exactly the one it has always had. */
-export function systemPromptFor(chain: string): string {
- return chain === 'stellar' ? SYSTEM_PROMPT : CHAIN_BRIEF
+export function systemPromptFor(chain: string, withRest: boolean = false): string {
+ if (chain === 'stellar') return SYSTEM_PROMPT
+ return withRest ? `${CHAIN_BRIEF}${REST_ADDENDUM}` : CHAIN_BRIEF
}
diff --git a/apps/dapp/lib/agents/chain-race.ts b/apps/dapp/lib/agents/chain-race.ts
index 767b7313..1a5a8623 100644
--- a/apps/dapp/lib/agents/chain-race.ts
+++ b/apps/dapp/lib/agents/chain-race.ts
@@ -4,6 +4,10 @@ import type { AgentProposalView, CompetitionErrorCode } from './competition'
import type { ChainRouteQuote } from './chain-routes'
import { isUnmeasuredSwap } from './settle-chain-plan'
+function dollars(n: number): string {
+ return `$${n.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 4 })}`
+}
+
/**
* The route as a chain route, or nothing. A Stellar quote has no `kind`, so it
* can never be mistaken for one and signed through the chain path.
@@ -51,6 +55,23 @@ export function chainRequestFor(
const chain = asChainRoute(route)
if (chain === undefined) return undefined
const r = chain.request
+ if (chain.rest !== undefined) {
+ if (
+ r.kind !== 'swap' ||
+ asked.kind !== 'swap' ||
+ r.amount !== asked.amount ||
+ r.tokenIn !== asked.tokenIn ||
+ (r.tokenOut ?? '') !== (asked.tokenOut ?? '')
+ ) {
+ return undefined
+ }
+ return {
+ ...asked,
+ kind: 'limit',
+ venue: chain.rest.venueId,
+ limitPrice: String(Number(chain.rest.priceUsd.toPrecision(8))),
+ }
+ }
if (
r.kind !== asked.kind ||
r.amount !== asked.amount ||
@@ -211,6 +232,7 @@ export function needsDirectRoute(state: {
export function chainFigures(route: unknown): { headline: string } | undefined {
const chain = asChainRoute(route)
if (chain === undefined) return undefined
+ if (chain.rest !== undefined) return { headline: `Rests at ${dollars(chain.rest.priceUsd)}` }
if (chain.request.kind === 'swap') {
return isUnmeasuredSwap(chain) ? { headline: 'Price unknown' } : undefined
}
@@ -235,11 +257,20 @@ export function routeFigures(
/** The venue a route executes on: a Stellar quote's source, or a chain route's venue. */
export function routeSource(route: unknown): string | undefined {
- const r = route as { source?: unknown; venueName?: unknown } | null | undefined
- const name = r?.source ?? r?.venueName
+ const r = route as
+ | { source?: unknown; venueName?: unknown; rest?: { venueName?: unknown } }
+ | null
+ | undefined
+ const name = r?.rest?.venueName ?? r?.source ?? r?.venueName
return typeof name === 'string' ? name : undefined
}
+/** The one-off cost of a resting route, for the line that offers resting as an alternative. */
+export function routeRestCost(route: unknown): { amount: string; symbol: string } | undefined {
+ return (route as { rest?: { cost?: { amount: string; symbol: string } } } | null | undefined)
+ ?.rest?.cost
+}
+
/**
* The line under a decided race. Only a route with a measured price has a measured fill to be
* best at. Anything else, such as a supply's rate, is named for what it is.
@@ -255,6 +286,30 @@ export function recommendationLine(
return `Recommended: ${name}, ${why}. You pick who executes.`
}
+/**
+ * The line under a decided race off Stellar. It always states one strategy, also when the agents
+ * agree or there is a single route, so the person is never left without a recommendation.
+ */
+export function chainRecommendationLine(
+ winner: {
+ executionMode?: string | undefined
+ restPriceUsd?: number | undefined
+ source?: string | undefined
+ },
+ options: { agreeing?: number } = {}
+): string {
+ const plan =
+ winner.executionMode === 'rest' && winner.restPriceUsd !== undefined
+ ? `rest at ${dollars(winner.restPriceUsd)}`
+ : 'fill now'
+ const via = winner.source !== undefined ? ` via ${winner.source}` : ''
+ const agree =
+ options.agreeing !== undefined && options.agreeing > 1
+ ? ` All ${options.agreeing} agents agree.`
+ : ''
+ return `Recommended: ${plan}${via}.${agree} You pick who executes.`
+}
+
const UNANSWERED_REASON: Readonly> = {
rate_limited: 'rate limited',
timeout: 'too slow',
diff --git a/apps/dapp/lib/agents/chain-routes.ts b/apps/dapp/lib/agents/chain-routes.ts
index 7c2153d7..3fd8a0d4 100644
--- a/apps/dapp/lib/agents/chain-routes.ts
+++ b/apps/dapp/lib/agents/chain-routes.ts
@@ -14,6 +14,13 @@ export interface ChainRouteQuote {
apyPercent?: number
valueInUsd?: number
valueOutUsd?: number
+ /** An agent proposed resting instead of this fill: the limit venue and the price it waits at. */
+ rest?: {
+ venueId: string
+ venueName: string
+ priceUsd: number
+ cost?: { amount: string; symbol: string }
+ }
}
/**
diff --git a/apps/dapp/lib/agents/competition.ts b/apps/dapp/lib/agents/competition.ts
index aa134d1c..d3b567a3 100644
--- a/apps/dapp/lib/agents/competition.ts
+++ b/apps/dapp/lib/agents/competition.ts
@@ -56,6 +56,8 @@ export interface AgentProposalView {
executionMode?: 'fill' | 'rest' | 'split'
splitPct?: number
restPriceUsd?: number
+ /** The one-off cost of resting, when the chosen route is a resting order. */
+ restCost?: { amount: string; symbol: string }
thenAction?: 'lend' | 'offramp'
thenVenue?: string
/**
diff --git a/apps/dapp/lib/agents/rest-option.ts b/apps/dapp/lib/agents/rest-option.ts
new file mode 100644
index 00000000..246acc36
--- /dev/null
+++ b/apps/dapp/lib/agents/rest-option.ts
@@ -0,0 +1,118 @@
+import { limitPair } from '../chains/limit'
+import type { ChainActionRequest, ChainModule } from '../chains/types'
+import type { ChainRouteQuote } from './chain-routes'
+
+/** A resting order the agents may propose instead of filling now. */
+export interface RestOption {
+ venueId: string
+ venueName: string
+ /** Dollars per unit of the token that is not the stablecoin. */
+ marketUsd: number
+ /** The person gives up the non-stable token. */
+ selling: boolean
+ cost?: { amount: string; symbol: string }
+ notes: string[]
+}
+
+/**
+ * The resting order a swap could use, or nothing: only a plain swap against a stablecoin, with no
+ * venue named, on a chain that has a limit venue.
+ */
+export function restOption(
+ chainApi: ChainModule,
+ request: ChainActionRequest,
+ marketUsd: number
+): RestOption | undefined {
+ if (request.kind !== 'swap' || request.venue !== undefined || request.tokenOut === undefined) {
+ return undefined
+ }
+ if (!Number.isFinite(marketUsd) || marketUsd <= 0) return undefined
+ const pair = limitPair(request.tokenIn, request.tokenOut)
+ if (pair === undefined) return undefined
+ const venue = chainApi.venues.find((v) => v.kinds.includes('limit'))
+ if (venue === undefined) return undefined
+ return {
+ venueId: venue.id,
+ venueName: venue.name,
+ marketUsd,
+ selling: pair.selling,
+ ...(venue.limitCost !== undefined ? { cost: venue.limitCost } : {}),
+ notes: [],
+ }
+}
+
+/** Where an agent may rest: 0.1% to 5% better than the market, `least` the nearest, `most` the furthest. */
+export function restBand(marketUsd: number, selling: boolean): { least: number; most: number } {
+ return selling
+ ? { least: marketUsd * 1.001, most: marketUsd * 1.05 }
+ : { least: marketUsd * 0.999, most: marketUsd * 0.95 }
+}
+
+export function priceInBand(price: number, rest: RestOption): boolean {
+ if (!Number.isFinite(price) || price <= 0) return false
+ const { least, most } = restBand(rest.marketUsd, rest.selling)
+ const slack = rest.marketUsd * 1e-9
+ return rest.selling
+ ? price >= least - slack && price <= most + slack
+ : price <= least + slack && price >= most - slack
+}
+
+function money(n: number): string {
+ return `$${n.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 4 })}`
+}
+
+/** The route as the person will see it when an agent rests: the limit venue, at the price. */
+export function restRoute(
+ quote: ChainRouteQuote,
+ rest: RestOption,
+ priceUsd: number
+): ChainRouteQuote {
+ return {
+ ...quote,
+ rest: {
+ venueId: rest.venueId,
+ venueName: rest.venueName,
+ priceUsd,
+ ...(rest.cost !== undefined ? { cost: rest.cost } : {}),
+ },
+ }
+}
+
+/** The lines the agents are shown when resting is possible. */
+export function restContextLines(rest: RestOption): string[] {
+ const band = restBand(rest.marketUsd, rest.selling)
+ const [near, far] = [band.least, band.most].map((n) => n.toFixed(4))
+ return [
+ '',
+ `Resting option: ${rest.venueName} can hold a limit order instead of filling now.`,
+ `The market price is about ${money(rest.marketUsd)}. A resting price must be between $${near} and $${far}, otherwise the plan becomes a fill.`,
+ rest.cost !== undefined
+ ? `Placing it costs about ${rest.cost.amount} ${rest.cost.symbol}, and it may never fill.`
+ : 'It may never fill.',
+ ...rest.notes,
+ ]
+}
+
+interface RestView {
+ executionMode?: string | undefined
+ restPriceUsd?: number | undefined
+ source?: string | undefined
+ restCost?: { amount: string; symbol: string } | undefined
+}
+
+function costWord(cost: { amount: string; symbol: string }): string {
+ return `${Number(Number(cost.amount).toPrecision(2))} ${cost.symbol}`
+}
+
+/** The other way to do it, said beside the recommendation when some agents would rest. */
+export function restAlternativeLine(proposals: readonly RestView[]): string | undefined {
+ const resting = proposals.filter((p) => p.executionMode === 'rest')
+ if (resting.length === 0 || resting.length === proposals.length) return undefined
+ const first = resting[0]
+ if (first === undefined) return undefined
+ const who = resting.length === 1 ? '1 agent suggests' : `${resting.length} agents suggest`
+ const at = first.restPriceUsd !== undefined ? ` at ${money(first.restPriceUsd)}` : ''
+ const through = first.source !== undefined ? ` through ${first.source}` : ''
+ const cost = first.restCost !== undefined ? `costs about ${costWord(first.restCost)}, ` : ''
+ return `${who} resting${at}${through}: it could get more, ${cost}and may never fill. You choose.`
+}
diff --git a/apps/dapp/lib/agents/scoring.ts b/apps/dapp/lib/agents/scoring.ts
index 0231367b..4a7a603e 100644
--- a/apps/dapp/lib/agents/scoring.ts
+++ b/apps/dapp/lib/agents/scoring.ts
@@ -73,7 +73,7 @@ export function tieBreak(competitionId: string, agent: string): number {
export function scoreProposals(
proposals: AgentProposalResult[],
- options: { competitionId: string; plan?: PlanContext }
+ options: { competitionId: string; plan?: PlanContext; restsBelowFills?: boolean }
): ScoredProposal[] {
if (proposals.length === 0) return []
@@ -89,6 +89,12 @@ export function scoreProposals(
// Nothing to sign is disqualifying, whatever the numbers say. A losing
// real route beats a winning opinion.
if (a.executable !== b.executable) return a.executable ? -1 : 1
+ // A rest has no measured fill to be best at, so it never takes the lead from one that has.
+ if (options.restsBelowFills === true) {
+ const aRests = a.proposal.executionMode === 'rest'
+ const bRests = b.proposal.executionMode === 'rest'
+ if (aRests !== bRests) return aRests ? 1 : -1
+ }
if (b.score !== a.score) return b.score - a.score
return tieBreak(options.competitionId, a.agent) - tieBreak(options.competitionId, b.agent)
})
diff --git a/apps/dapp/lib/agents/settle-chain-plan.ts b/apps/dapp/lib/agents/settle-chain-plan.ts
index 928d226f..27088e31 100644
--- a/apps/dapp/lib/agents/settle-chain-plan.ts
+++ b/apps/dapp/lib/agents/settle-chain-plan.ts
@@ -1,6 +1,7 @@
import type { AgentProposalResult } from './brain'
import type { ChainRouteQuote } from './chain-routes'
import { measureChainRoute } from './measure'
+import { priceInBand, type RestOption } from './rest-option'
/**
* A plan on a chain other than Stellar is one fill and nothing after it.
@@ -10,7 +11,19 @@ import { measureChainRoute } from './measure'
* keeps its route and its reasoning; only the shape of the plan is corrected,
* so scoring never rewards or punishes a choice that could not happen.
*/
-export function settleChainPlan(proposal: AgentProposalResult): AgentProposalResult {
+export function settleChainPlan(
+ proposal: AgentProposalResult,
+ restOption?: RestOption
+): AgentProposalResult {
+ if (
+ restOption !== undefined &&
+ proposal.executionMode === 'rest' &&
+ proposal.restPriceUsd !== undefined &&
+ priceInBand(proposal.restPriceUsd, restOption)
+ ) {
+ const { splitPct: _split, thenAction: _then, thenVenue: _venue, ...resting } = proposal
+ return { ...resting, executionMode: 'rest', sliceCount: 1 }
+ }
const {
restPriceUsd: _rest,
splitPct: _split,
diff --git a/apps/dapp/lib/chains/evm/call.ts b/apps/dapp/lib/chains/evm/call.ts
index bdce2f6f..fbb68ba6 100644
--- a/apps/dapp/lib/chains/evm/call.ts
+++ b/apps/dapp/lib/chains/evm/call.ts
@@ -245,6 +245,10 @@ export async function confirmEvmCall(
client: EvmClient = evmClient(policy.chain)
): Promise {
const { name, chain } = EVM_CHAINS[policy.chain]
+ // A signed message can't have moved anything, whatever wallet signed it.
+ if (signed.family === 'evm-typed') {
+ return { ok: false, error: 'That is a signed message, not a transaction, so nothing was sent.' }
+ }
if (signed.family !== 'evm' || !EVM_HASH.test(signed.hash)) {
return pending(
"The wallet didn't return a transaction this app can follow. Check your wallet's activity before trying again, in case it was sent."
diff --git a/apps/dapp/lib/chains/evm/capabilities.ts b/apps/dapp/lib/chains/evm/capabilities.ts
index 3481805f..18633a2d 100644
--- a/apps/dapp/lib/chains/evm/capabilities.ts
+++ b/apps/dapp/lib/chains/evm/capabilities.ts
@@ -3,6 +3,7 @@ import type { ChainCapability } from '../explain'
/** What actually works here, for telling someone who asked for something else. Client-safe. */
export const avalancheCapabilities: ChainCapability[] = [
{ kind: 'swap', venue: 'Uniswap', symbol: 'AVAX', then: ['cashout', 'supply', 'send'] },
+ { kind: 'limit', venue: 'CoW Protocol', symbol: 'AVAX' },
{ kind: 'supply', venue: 'Aave', symbol: 'USDC' },
{ kind: 'withdraw', venue: 'Aave', symbol: 'USDC' },
{ kind: 'borrow', venue: 'Aave', symbol: 'USDC' },
diff --git a/apps/dapp/lib/chains/evm/client.ts b/apps/dapp/lib/chains/evm/client.ts
index 933b5334..2dd17c00 100644
--- a/apps/dapp/lib/chains/evm/client.ts
+++ b/apps/dapp/lib/chains/evm/client.ts
@@ -23,7 +23,10 @@ export interface EvmTransaction {
export interface EvmClient {
tokenBalance: (token: Address, account: Address) => Promise
+ allowance: (token: Address, owner: Address, spender: Address) => Promise
nativeBalance: (account: Address) => Promise
+ /** The account's contract code, `0x` or nothing for an ordinary wallet. */
+ code: (account: Address) => Promise
waitForReceipt: (hash: Hash) => Promise
/** A receipt doesn't carry what a transaction paid or called, so these are read apart from it. */
transaction: (hash: Hash) => Promise
@@ -51,7 +54,15 @@ export function evmClient(chain: EvmChain, transport?: Transport): EvmClient {
functionName: 'balanceOf',
args: [account],
}),
+ allowance: (token, owner, spender) =>
+ client.readContract({
+ address: token,
+ abi: erc20Abi,
+ functionName: 'allowance',
+ args: [owner, spender],
+ }),
nativeBalance: (address) => client.getBalance({ address }),
+ code: (address) => client.getCode({ address }),
waitForReceipt: (hash) =>
client.waitForTransactionReceipt({ hash, timeout: RECEIPT_TIMEOUT_MS }),
transaction: async (hash) => {
diff --git a/apps/dapp/lib/chains/evm/cow-rules.ts b/apps/dapp/lib/chains/evm/cow-rules.ts
new file mode 100644
index 00000000..91231c75
--- /dev/null
+++ b/apps/dapp/lib/chains/evm/cow-rules.ts
@@ -0,0 +1,357 @@
+import {
+ decodeFunctionData,
+ encodeFunctionData,
+ encodePacked,
+ erc20Abi,
+ getAddress,
+ hashTypedData,
+ keccak256,
+ parseAbi,
+ toHex,
+ type Address,
+ type Hex,
+} from 'viem'
+
+import { ChainActionRefused, type SigningPayload } from '../types'
+import { AVALANCHE_TOKENS } from './tokens'
+
+/**
+ * CoW Protocol on Avalanche, pinned from docs/research/2026-11-05-limit-orders/evm.md part A.
+ * Client-safe: the wallet adapter runs `assertCowOrderShape` again before it asks for a signature.
+ */
+
+export const COW_CHAIN_ID = 43114
+export const COW_SETTLEMENT = '0x9008D19f58AAbD9eD0D60971565AA8510560ab41'
+export const COW_VAULT_RELAYER = '0xC92E8bdf79f0507f65a392b0ab4667716BFE0110'
+export const WAVAX = '0xB31f66AA3C1e785363F0875A1B74E27b85FD66c7'
+/** What CoW takes as the buy token to pay out the chain's own coin. Never a sell token. */
+export const COW_BUY_NATIVE = '0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE'
+/** No hooks, no partner fee, nothing else. The order signs its hash. */
+export const COW_APP_DATA = '{}'
+export const COW_APP_DATA_HASH = keccak256(toHex(COW_APP_DATA))
+export const COW_ORDER_LIFETIME_S = 7 * 86_400
+/** Leeway over the seven days for a clock that runs ahead, and no more. */
+const MOST_LIFETIME_S = 8 * 86_400
+const WRAP_DATA = '0xd0e30db0'
+const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000'
+const MAX_UINT256 = BigInt(2) ** BigInt(256) - BigInt(1)
+
+export const COW_DOMAIN = {
+ name: 'Gnosis Protocol',
+ version: 'v2',
+ chainId: COW_CHAIN_ID,
+ verifyingContract: COW_SETTLEMENT,
+} as const
+
+export const COW_ORDER_TYPES = {
+ Order: [
+ { name: 'sellToken', type: 'address' },
+ { name: 'buyToken', type: 'address' },
+ { name: 'receiver', type: 'address' },
+ { name: 'sellAmount', type: 'uint256' },
+ { name: 'buyAmount', type: 'uint256' },
+ { name: 'validTo', type: 'uint32' },
+ { name: 'appData', type: 'bytes32' },
+ { name: 'feeAmount', type: 'uint256' },
+ { name: 'kind', type: 'string' },
+ { name: 'partiallyFillable', type: 'bool' },
+ { name: 'sellTokenBalance', type: 'string' },
+ { name: 'buyTokenBalance', type: 'string' },
+ ],
+} as const
+
+export const SETTLEMENT_ABI = parseAbi(['function invalidateOrder(bytes orderUid)'])
+
+/** An order as signed, amounts in decimal base units so it survives JSON. */
+export interface CowOrder {
+ sellToken: string
+ buyToken: string
+ receiver: string
+ sellAmount: string
+ buyAmount: string
+ validTo: number
+ appData: string
+ feeAmount: string
+ kind: 'sell'
+ partiallyFillable: boolean
+ sellTokenBalance: 'erc20'
+ buyTokenBalance: 'erc20'
+}
+
+export type CowTypedPayload = Extract
+
+const ORDER_KEYS = COW_ORDER_TYPES.Order.map((f) => f.name)
+
+function token(symbol: string): string {
+ const found = AVALANCHE_TOKENS.find((t) => t.symbol === symbol)
+ if (found === undefined) throw new Error(`${symbol} is not in the Avalanche token table`)
+ return found.id
+}
+
+const STABLES = [token('USDC'), token('USDT')].map((a) => a.toLowerCase())
+const SELLS = [WAVAX.toLowerCase(), ...STABLES]
+const BUYS = [COW_BUY_NATIVE.toLowerCase(), ...STABLES]
+
+function refuse(sentence: string): never {
+ throw new ChainActionRefused(`${sentence} Nothing was signed or sent.`)
+}
+
+function same(a: unknown, b: string): boolean {
+ return typeof a === 'string' && a.toLowerCase() === b.toLowerCase()
+}
+
+function isAddressText(value: unknown): value is string {
+ return typeof value === 'string' && /^0x[0-9a-fA-F]{40}$/.test(value)
+}
+
+function isUnits(value: unknown): value is string {
+ return typeof value === 'string' && /^[1-9]\d{0,77}$/.test(value) && BigInt(value) <= MAX_UINT256
+}
+
+function hasExactKeys(value: unknown, keys: readonly string[]): value is Record {
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) return false
+ const own = Object.keys(value)
+ return own.length === keys.length && keys.every((k) => own.includes(k))
+}
+
+export function cowOrder(fields: {
+ sellToken: string
+ buyToken: string
+ receiver: string
+ sellAmount: string
+ buyAmount: string
+ validTo: number
+}): CowOrder {
+ return {
+ ...fields,
+ appData: COW_APP_DATA_HASH,
+ feeAmount: '0',
+ kind: 'sell',
+ partiallyFillable: true,
+ sellTokenBalance: 'erc20',
+ buyTokenBalance: 'erc20',
+ }
+}
+
+export function cowTypedData(message: CowOrder): CowTypedPayload['typedData'] {
+ return {
+ domain: { ...COW_DOMAIN },
+ types: { Order: COW_ORDER_TYPES.Order.map((f) => ({ ...f })) },
+ primaryType: 'Order',
+ message,
+ }
+}
+
+/** What viem hashes and signs: the same order, with its amounts as numbers. */
+export function viemTypedData(message: CowOrder) {
+ return {
+ domain: { ...COW_DOMAIN, verifyingContract: COW_SETTLEMENT as Address },
+ types: COW_ORDER_TYPES,
+ primaryType: 'Order' as const,
+ message: {
+ sellToken: message.sellToken as Address,
+ buyToken: message.buyToken as Address,
+ receiver: message.receiver as Address,
+ sellAmount: BigInt(message.sellAmount),
+ buyAmount: BigInt(message.buyAmount),
+ validTo: message.validTo,
+ appData: message.appData as Hex,
+ feeAmount: BigInt(message.feeAmount),
+ kind: message.kind,
+ partiallyFillable: message.partiallyFillable,
+ sellTokenBalance: message.sellTokenBalance,
+ buyTokenBalance: message.buyTokenBalance,
+ },
+ }
+}
+
+/** The order's 56-byte id: its EIP-712 digest, its owner, and its validTo. Lower case. */
+export function cowOrderUid(message: CowOrder, owner: string): string {
+ const digest = hashTypedData(viemTypedData(message))
+ return encodePacked(
+ ['bytes32', 'address', 'uint32'],
+ [digest, getAddress(owner.toLowerCase()), message.validTo]
+ ).toLowerCase()
+}
+
+export function isCowOrderUid(value: string): boolean {
+ return /^0x[0-9a-fA-F]{112}$/.test(value)
+}
+
+/** The owner an order id names. */
+export function uidOwner(uid: string): string {
+ return `0x${uid.slice(66, 106)}`.toLowerCase()
+}
+
+/**
+ * What can be checked of a typed order without knowing what was asked: that it is a CoW order on
+ * Avalanche, for this account, paying this account, plain, and short-lived. The wallet adapter runs
+ * this too, so no typed message but this one is ever put in front of the wallet.
+ */
+export function assertCowOrderShape(
+ payload: SigningPayload,
+ account: string,
+ now: number = Date.now()
+): CowOrder {
+ if (payload.family !== 'evm-typed') refuse('That is not a CoW order to sign.')
+ if (!isAddressText(account)) refuse('That is not an Avalanche wallet address.')
+ if (payload.chainId !== COW_CHAIN_ID) refuse('That order is not for Avalanche.')
+ if (!same(payload.account, account)) refuse('That order is for another wallet.')
+ const { typedData } = payload
+ if (!hasExactKeys(typedData, ['domain', 'types', 'primaryType', 'message'])) {
+ refuse('That order has an unexpected shape.')
+ }
+ const { domain, types, primaryType } = typedData
+ if (
+ !hasExactKeys(domain, ['name', 'version', 'chainId', 'verifyingContract']) ||
+ domain['name'] !== COW_DOMAIN.name ||
+ domain['version'] !== COW_DOMAIN.version ||
+ domain['chainId'] !== COW_CHAIN_ID ||
+ !same(domain['verifyingContract'], COW_SETTLEMENT)
+ ) {
+ refuse('That order is not for CoW’s settlement contract on Avalanche.')
+ }
+ if (primaryType !== 'Order' || JSON.stringify(types) !== JSON.stringify(COW_ORDER_TYPES)) {
+ refuse('That is not a CoW order to sign.')
+ }
+
+ const message: unknown = typedData.message
+ if (!hasExactKeys(message, ORDER_KEYS)) refuse('That order has an unexpected shape.')
+ const { sellToken, buyToken, receiver, sellAmount, buyAmount, validTo } = message
+ if (!isAddressText(sellToken) || !SELLS.includes(sellToken.toLowerCase())) {
+ refuse('That order sells a token this app doesn’t trade on CoW.')
+ }
+ if (!isAddressText(buyToken) || !BUYS.includes(buyToken.toLowerCase())) {
+ refuse('That order buys a token this app doesn’t trade on CoW.')
+ }
+ if (STABLES.includes(sellToken.toLowerCase()) === STABLES.includes(buyToken.toLowerCase())) {
+ refuse('That order needs AVAX on one side and USDC or USDT on the other.')
+ }
+ if (!isAddressText(receiver) || same(receiver, ZERO_ADDRESS) || !same(receiver, account)) {
+ refuse('That order pays someone other than your wallet.')
+ }
+ if (!isUnits(sellAmount) || !isUnits(buyAmount)) refuse('That order has a malformed amount.')
+ const nowS = Math.floor(now / 1000)
+ if (typeof validTo !== 'number' || !Number.isInteger(validTo) || validTo <= nowS) {
+ refuse('That order has already expired.')
+ }
+ if (validTo > nowS + MOST_LIFETIME_S) refuse('That order lasts longer than this app allows.')
+ if (!same(message['appData'], COW_APP_DATA_HASH)) {
+ refuse('That order carries app data this app didn’t write, which could run hooks.')
+ }
+ if (message['feeAmount'] !== '0') refuse('That order signs a fee, and a limit order signs none.')
+ if (message['kind'] !== 'sell' || message['partiallyFillable'] !== true) {
+ refuse('That order is not a sell order that can fill in parts.')
+ }
+ if (message['sellTokenBalance'] !== 'erc20' || message['buyTokenBalance'] !== 'erc20') {
+ refuse('That order draws on a balance other than your wallet’s own tokens.')
+ }
+ return message as unknown as CowOrder
+}
+
+function assertTransaction(
+ payload: SigningPayload
+): asserts payload is Extract {
+ if (payload.family !== 'evm') refuse('That is not an Avalanche transaction.')
+ if (payload.chainId !== COW_CHAIN_ID) refuse('That transaction is not for Avalanche.')
+ if (!isAddressText(payload.to) || !/^\d+$/.test(payload.value)) {
+ refuse('That transaction is malformed.')
+ }
+}
+
+function assertWrap(payload: SigningPayload, context: Record): void {
+ assertTransaction(payload)
+ const most = context['amount']
+ if (!same(context['sellToken'], WAVAX) || !isUnits(most))
+ refuse('That wrap was not prepared here.')
+ if (!same(payload.to, WAVAX)) refuse('That wrap is not into WAVAX.')
+ if (payload.data.toLowerCase() !== WRAP_DATA) refuse('That transaction does more than wrap AVAX.')
+ const value = BigInt(payload.value)
+ if (value === BigInt(0) || value > BigInt(most)) {
+ refuse('That wraps more AVAX than this order sells.')
+ }
+}
+
+function assertApproval(payload: SigningPayload, context: Record): void {
+ assertTransaction(payload)
+ const sellToken = context['sellToken']
+ const amount = context['amount']
+ if (!isAddressText(sellToken) || !SELLS.includes(sellToken.toLowerCase()) || !isUnits(amount)) {
+ refuse('That approval was not prepared here.')
+ }
+ if (!same(payload.to, sellToken)) refuse('That approval is on another token.')
+ if (payload.value !== '0') refuse('An approval carries no AVAX.')
+ let decoded: { functionName: string; args: readonly unknown[] }
+ try {
+ decoded = decodeFunctionData({ abi: erc20Abi, data: payload.data as Hex })
+ } catch {
+ return refuse('That transaction is not an approval.')
+ }
+ const [spender, approved] = decoded.args
+ if (decoded.functionName !== 'approve' || !same(spender, COW_VAULT_RELAYER)) {
+ refuse('That approval is not to CoW’s vault relayer.')
+ }
+ if (approved !== BigInt(amount)) refuse('That approval is not for exactly this order’s amount.')
+ const exact = encodeFunctionData({
+ abi: erc20Abi,
+ functionName: 'approve',
+ args: [COW_VAULT_RELAYER, BigInt(amount)],
+ })
+ if (payload.data.toLowerCase() !== exact.toLowerCase()) {
+ refuse('That transaction is not an approval.')
+ }
+}
+
+function assertCancel(payload: SigningPayload, account: string, orderId: string): void {
+ assertTransaction(payload)
+ if (!isCowOrderUid(orderId) || !same(uidOwner(orderId), account)) {
+ refuse('That order belongs to another wallet.')
+ }
+ if (!same(payload.to, COW_SETTLEMENT)) refuse('That cancel is not on CoW’s settlement contract.')
+ if (payload.value !== '0') refuse('A cancel carries no AVAX.')
+ const exact = encodeFunctionData({
+ abi: SETTLEMENT_ABI,
+ functionName: 'invalidateOrder',
+ args: [orderId.toLowerCase() as Hex],
+ })
+ if (payload.data.toLowerCase() !== exact.toLowerCase()) refuse('That cancels a different order.')
+}
+
+function assertOrder(
+ payload: SigningPayload,
+ account: string,
+ context: Record,
+ now: number
+): void {
+ const order = assertCowOrderShape(payload, account, now)
+ if (
+ !same(order.sellToken, context['sellToken'] ?? '') ||
+ !same(order.buyToken, context['buyToken'] ?? '') ||
+ order.sellAmount !== context['sellAmount'] ||
+ order.buyAmount !== context['buyAmount'] ||
+ String(order.validTo) !== context['validTo']
+ ) {
+ refuse('That order is not the one that was asked for.')
+ }
+}
+
+/**
+ * CoW's `assertSafe`: a payload held to the step `prepare` issued. At submit the order is rebuilt
+ * from the context and its signature must recover to the account, so the context is only ever
+ * compared with what the wallet signed.
+ */
+export function cowAssertSafe(
+ payload: SigningPayload,
+ account: string,
+ context?: Record | undefined,
+ now: number = Date.now()
+): void {
+ if (!isAddressText(account)) refuse('That is not an Avalanche wallet address.')
+ const orderId = context?.['orderId']
+ if (orderId !== undefined) return assertCancel(payload, account, orderId)
+ const step = context?.['step']
+ if (step === 'wrap') return assertWrap(payload, context ?? {})
+ if (step === 'approve') return assertApproval(payload, context ?? {})
+ if (step === 'order') return assertOrder(payload, account, context ?? {}, now)
+ refuse('That is not a step this app prepared.')
+}
diff --git a/apps/dapp/lib/chains/evm/cow.ts b/apps/dapp/lib/chains/evm/cow.ts
new file mode 100644
index 00000000..3b5940a5
--- /dev/null
+++ b/apps/dapp/lib/chains/evm/cow.ts
@@ -0,0 +1,633 @@
+import {
+ encodeFunctionData,
+ erc20Abi,
+ getAddress,
+ recoverTypedDataAddress,
+ type Address,
+ type Hash,
+ type Hex,
+} from 'viem'
+import { z } from 'zod'
+
+import { limitOutput, limitPair, type LimitBook, type RestingOrder } from '../limit'
+import { fromBase } from '../sequence'
+import {
+ ChainActionRefused,
+ toBaseUnits,
+ tokenBySymbol,
+ type ChainActionRequest,
+ type ChainToken,
+ type ChainVenue,
+ type PreparedAction,
+ type SubmitInput,
+ type SubmitResult,
+} from '../types'
+import { evmClient, type EvmReceipt } from './client'
+import {
+ COW_APP_DATA,
+ COW_APP_DATA_HASH,
+ COW_BUY_NATIVE,
+ COW_CHAIN_ID,
+ COW_ORDER_LIFETIME_S,
+ COW_SETTLEMENT,
+ COW_VAULT_RELAYER,
+ cowAssertSafe,
+ cowOrder,
+ cowOrderUid,
+ cowTypedData,
+ isCowOrderUid,
+ SETTLEMENT_ABI,
+ uidOwner,
+ viemTypedData,
+ WAVAX,
+ type CowOrder,
+} from './cow-rules'
+import { EVM_HASH, sameAddress } from './submit'
+import { AVALANCHE_TOKENS, EVM_NATIVE_ID, evmExplorerUrl } from './tokens'
+
+/**
+ * Read off mainnet on 5 November 2026, quoting only (`evm-cow.live.test.ts`):
+ *
+ * 1. An owner's orders are at `GET /account/{owner}/orders`, as the research guessed. `/orders?owner=`
+ * answers 405.
+ * 2. Buying native AVAX through `0xEeee…` quotes, and real orders bought it that way and filled, so a
+ * purchase pays out in AVAX, not WAVAX.
+ */
+
+export const COW_VENUE_ID = 'cow-limit'
+const VENUE_NAME = 'CoW Protocol'
+const API = 'https://api.cow.fi/avalanche/api/v1'
+const EXPLORER = 'https://explorer.cow.fi/avax/orders/'
+const API_TIMEOUT_MS = 8_000
+/** Orders closed longer ago than this are left out of the book. */
+const SHOWN_AFTER_CLOSE_S = 7 * 86_400
+
+const NOTES = [
+ 'Your funds stay in your wallet until a solver fills this at your price or better.',
+ 'Expires in 7 days.',
+ 'Avalanche has few CoW solvers trading right now, so it may not fill.',
+ 'Cancelling costs a small network fee.',
+]
+const APPROVAL_NOTE =
+ 'This lets CoW move exactly this amount and no more. Cancelling doesn’t remove it.'
+const WRAP_NOTE = 'CoW trades WAVAX, not AVAX, so what doesn’t sell stays in your wallet as WAVAX.'
+const TOO_SMALL = "That is too small for CoW's solvers to fill. Try a few dollars more."
+
+const Amount = z.string().regex(/^\d+$/)
+const ApiError = z.object({ errorType: z.string() })
+const Uid = z.string().regex(/^0x[0-9a-fA-F]{112}$/)
+
+const ApiOrder = z.object({
+ uid: Uid,
+ owner: z.string(),
+ sellToken: z.string(),
+ buyToken: z.string(),
+ sellAmount: Amount,
+ buyAmount: Amount,
+ validTo: z.number().int(),
+ executedSellAmount: Amount,
+ executedBuyAmount: Amount,
+ status: z.string(),
+})
+
+type ApiOrder = z.infer
+
+function refused(sentence: string): never {
+ throw new ChainActionRefused(`${sentence} Nothing was signed or sent.`)
+}
+
+function assertAccount(account: string): Address {
+ if (!/^0x[0-9a-fA-F]{40}$/.test(account)) refused('That is not an Avalanche wallet address.')
+ return getAddress(account.toLowerCase())
+}
+
+async function cowApi(path: string, init?: { method: string; body: unknown }): Promise {
+ return fetch(`${API}${path}`, {
+ method: init?.method ?? 'GET',
+ headers: { 'content-type': 'application/json' },
+ ...(init !== undefined ? { body: JSON.stringify(init.body) } : {}),
+ signal: AbortSignal.timeout(API_TIMEOUT_MS),
+ })
+}
+
+async function errorType(response: Response): Promise {
+ const parsed = ApiError.safeParse(await response.json().catch(() => undefined))
+ return parsed.success ? parsed.data.errorType : undefined
+}
+
+function tableToken(symbol: string): ChainToken {
+ const token = tokenBySymbol(AVALANCHE_TOKENS, symbol)
+ if (token === undefined) refused(`I can't trade ${symbol} on CoW.`)
+ return token
+}
+
+/** What CoW names each side: wrapped AVAX to sell, the chain's own coin to buy. */
+function cowAddress(token: ChainToken, side: 'sell' | 'buy'): string {
+ if (token.id !== EVM_NATIVE_ID) return token.id
+ return side === 'sell' ? WAVAX : COW_BUY_NATIVE
+}
+
+/** A quote creates no order. It is asked so an order CoW can't fill is never set up or signed. */
+async function assertQuotable(
+ order: Pick,
+ account: Address
+): Promise {
+ let response: Response
+ try {
+ response = await cowApi('/quote', {
+ method: 'POST',
+ body: {
+ sellToken: order.sellToken,
+ buyToken: order.buyToken,
+ from: account,
+ receiver: account,
+ kind: 'sell',
+ sellAmountBeforeFee: order.sellAmount,
+ appData: COW_APP_DATA,
+ appDataHash: COW_APP_DATA_HASH,
+ partiallyFillable: true,
+ sellTokenBalance: 'erc20',
+ buyTokenBalance: 'erc20',
+ signingScheme: 'eip712',
+ },
+ })
+ } catch {
+ return refused('I couldn’t reach CoW just now, so try again in a moment.')
+ }
+ if (response.ok) return
+ if ((await errorType(response)) === 'SellAmountDoesNotCoverFee') refused(TOO_SMALL)
+ refused('CoW couldn’t price this order just now, so try again in a moment.')
+}
+
+async function mustRead(work: () => Promise): Promise {
+ try {
+ return await work()
+ } catch {
+ return refused('I couldn’t read your wallet on Avalanche just now, so try again in a moment.')
+ }
+}
+
+async function prepare(request: ChainActionRequest, account: string): Promise {
+ const owner = assertAccount(account)
+ if (request.kind !== 'limit')
+ refused(`${VENUE_NAME} only rests orders. It can’t ${request.kind}.`)
+ const pair = limitPair(request.tokenIn, request.tokenOut ?? '')
+ if (pair === undefined) refused('CoW orders here need USDC or USDT on one side.')
+ const input = tableToken(request.tokenIn)
+ const output = tableToken(request.tokenOut ?? '')
+ const sellToken = cowAddress(input, 'sell')
+ const buyToken = cowAddress(output, 'buy')
+ let sellAmount: string
+ try {
+ sellAmount = toBaseUnits(request.amount, input.decimals)
+ } catch (e) {
+ if (e instanceof ChainActionRefused) refused(e.message)
+ throw e
+ }
+ const limitPrice = request.limitPrice ?? ''
+ const bought = limitOutput(
+ request.amount,
+ limitPrice,
+ pair.selling,
+ input.decimals,
+ output.decimals
+ )
+ if (bought === BigInt(0)) refused('That order is too small to place.')
+ const buyAmount = bought.toString()
+
+ await assertQuotable({ sellToken, buyToken, sellAmount }, owner)
+ await assertNoOpenSale(owner, sellToken, input.symbol)
+
+ const client = evmClient('avalanche')
+ const code = await mustRead(() => client.code(owner))
+ // A delegated ordinary wallet (EIP-7702) still signs with its own key.
+ if (code !== undefined && code !== '0x' && !code.toLowerCase().startsWith('0xef0100')) {
+ refused(
+ 'This wallet is a smart contract, and CoW orders here need an ordinary wallet’s signature.'
+ )
+ }
+ const sold = BigInt(sellAmount)
+ const wraps = sellToken === WAVAX
+ const notes = wraps ? [...NOTES, WRAP_NOTE] : NOTES
+ const shared = { venueId: COW_VENUE_ID, venueName: VENUE_NAME, kind: 'limit' as const }
+
+ if (wraps) {
+ const held = await mustRead(() => client.tokenBalance(WAVAX, owner))
+ if (held < sold) {
+ const short = sold - held
+ const amount = fromBase(short, input.decimals)
+ return {
+ ...shared,
+ summary: `Wrap ${amount} AVAX into WAVAX, which CoW trades. You approve and sign the order next, and nothing is offered until then.`,
+ pay: { amount, symbol: 'AVAX' },
+ receive: { amount, symbol: 'WAVAX', approximate: false },
+ notes,
+ signing: {
+ family: 'evm',
+ chainId: COW_CHAIN_ID,
+ to: WAVAX,
+ data: '0xd0e30db0',
+ value: short.toString(),
+ },
+ context: { step: 'wrap', sellToken: WAVAX, amount: sellAmount },
+ thenPrepareAgain: true,
+ }
+ }
+ }
+
+ const allowed = await mustRead(() =>
+ client.allowance(sellToken as Address, owner, COW_VAULT_RELAYER)
+ )
+ if (allowed < sold) {
+ const symbol = wraps ? 'WAVAX' : input.symbol
+ return {
+ ...shared,
+ summary: `Let CoW move exactly ${request.amount} ${symbol} from your wallet. You sign the order itself next, and nothing is offered until then.`,
+ pay: { amount: '0', symbol },
+ notes: [APPROVAL_NOTE, ...notes],
+ signing: {
+ family: 'evm',
+ chainId: COW_CHAIN_ID,
+ to: sellToken,
+ data: encodeFunctionData({
+ abi: erc20Abi,
+ functionName: 'approve',
+ args: [COW_VAULT_RELAYER, sold],
+ }),
+ value: '0',
+ },
+ context: { step: 'approve', sellToken, amount: sellAmount },
+ thenPrepareAgain: true,
+ }
+ }
+
+ const validTo = Math.floor(Date.now() / 1000) + COW_ORDER_LIFETIME_S
+ const order = cowOrder({ sellToken, buyToken, receiver: owner, sellAmount, buyAmount, validTo })
+ const receive = fromBase(bought, output.decimals)
+ return {
+ ...shared,
+ summary: `Offer ${request.amount} ${input.symbol} on CoW for at least ${receive} ${output.symbol}, at ${limitPrice} ${pair.quote} per ${pair.base}.`,
+ pay: { amount: request.amount, symbol: input.symbol },
+ receive: { amount: receive, symbol: output.symbol, approximate: true },
+ notes,
+ signing: {
+ family: 'evm-typed',
+ chainId: COW_CHAIN_ID,
+ account: owner,
+ typedData: cowTypedData(order),
+ },
+ context: {
+ step: 'order',
+ sellToken,
+ buyToken,
+ sellAmount,
+ buyAmount,
+ validTo: String(validTo),
+ },
+ }
+}
+
+/** The token an order address names, as the book shows it. WAVAX is not in the table. */
+function bookToken(address: string): { symbol: string; decimals: number } | undefined {
+ if (sameAddress(address, WAVAX)) return { symbol: 'WAVAX', decimals: 18 }
+ if (sameAddress(address, COW_BUY_NATIVE)) return { symbol: 'AVAX', decimals: 18 }
+ const token = AVALANCHE_TOKENS.find((t) => t.id !== EVM_NATIVE_ID && sameAddress(t.id, address))
+ return token === undefined ? undefined : { symbol: token.symbol, decimals: token.decimals }
+}
+
+function bookPrice(
+ order: ApiOrder,
+ sell: { symbol: string; decimals: number },
+ buy: { symbol: string; decimals: number }
+): string | undefined {
+ const pair = limitPair(sell.symbol === 'WAVAX' ? 'AVAX' : sell.symbol, buy.symbol)
+ if (pair === undefined) return undefined
+ const given = Number(fromBase(BigInt(order.sellAmount), sell.decimals))
+ const got = Number(fromBase(BigInt(order.buyAmount), buy.decimals))
+ if (!(given > 0) || !(got > 0)) return undefined
+ return String(Number((pair.selling ? got / given : given / got).toPrecision(6)))
+}
+
+function restingOrder(order: ApiOrder): RestingOrder | undefined {
+ const sell = bookToken(order.sellToken)
+ const buy = bookToken(order.buyToken)
+ if (sell === undefined || buy === undefined || sell.symbol === 'AVAX') return undefined
+ const total = BigInt(order.sellAmount)
+ const spent = BigInt(order.executedSellAmount)
+ const left = spent >= total ? BigInt(0) : total - spent
+ const status: RestingOrder['status'] =
+ order.status === 'fulfilled'
+ ? 'filled'
+ : isOpen(order)
+ ? spent > BigInt(0)
+ ? 'partly'
+ : 'open'
+ : 'done'
+ const limitPrice = bookPrice(order, sell, buy)
+ return {
+ id: order.uid.toLowerCase(),
+ venueId: COW_VENUE_ID,
+ tokenIn: sell.symbol,
+ tokenOut: buy.symbol,
+ amountIn: fromBase(total, sell.decimals),
+ remainingIn: fromBase(status === 'filled' ? BigInt(0) : left, sell.decimals),
+ receivedOut: fromBase(BigInt(order.executedBuyAmount), buy.decimals),
+ status,
+ expiresAt: order.validTo * 1000,
+ ...(limitPrice !== undefined ? { limitPrice } : {}),
+ }
+}
+
+/** The account's latest orders on CoW, newest first, as CoW lists them. */
+async function accountOrders(owner: Address, signal?: AbortSignal): Promise {
+ const response = await fetch(`${API}/account/${owner}/orders?limit=100`, {
+ signal: signal ?? AbortSignal.timeout(API_TIMEOUT_MS),
+ })
+ if (!response.ok) throw new Error(`CoW answered ${response.status} for the account's orders`)
+ const rows = z.array(z.unknown()).parse(await response.json())
+ return rows.flatMap((row) => {
+ const parsed = ApiOrder.safeParse(row)
+ return parsed.success && sameAddress(parsed.data.owner, owner) ? [parsed.data] : []
+ })
+}
+
+function isOpen(order: ApiOrder): boolean {
+ return order.status === 'open' || order.status === 'presignaturePending'
+}
+
+/**
+ * One open order per token sold. Every order selling a token draws on the one allowance the vault
+ * relayer has for it, and on the one balance, so a second order would either rest on an allowance
+ * that can't cover both, or replace the one the first relies on. An unreadable book refuses too.
+ */
+async function assertNoOpenSale(owner: Address, sellToken: string, symbol: string): Promise {
+ let orders: ApiOrder[]
+ try {
+ orders = await accountOrders(owner)
+ } catch {
+ return refused('I couldn’t check your open CoW orders just now, so try again in a moment.')
+ }
+ if (orders.some((o) => isOpen(o) && sameAddress(o.sellToken, sellToken))) {
+ refused(
+ `You already have an open CoW order selling ${symbol}. Cancel it or wait for it to fill before placing another.`
+ )
+ }
+}
+
+async function openOrders(account: string, signal?: AbortSignal): Promise {
+ const owner = assertAccount(account)
+ const since = Math.floor(Date.now() / 1000) - SHOWN_AFTER_CLOSE_S
+ return (await accountOrders(owner, signal)).flatMap((order) => {
+ if (order.validTo < since) return []
+ const mapped = restingOrder(order)
+ return mapped === undefined ? [] : [mapped]
+ })
+}
+
+async function prepareExit(
+ account: string,
+ orderId: string,
+ action: 'cancel' | 'collect'
+): Promise {
+ const owner = assertAccount(account)
+ if (action === 'collect') {
+ refused('CoW pays what fills straight to your wallet, so there is nothing to collect.')
+ }
+ if (!isCowOrderUid(orderId)) refused('That is not a CoW order I can change.')
+ const uid = orderId.toLowerCase()
+ if (!sameAddress(uidOwner(uid), owner)) refused('That order belongs to another wallet.')
+ let order: ApiOrder
+ try {
+ const response = await cowApi(`/orders/${uid}`)
+ if (response.status === 404) refused('CoW has no such order.')
+ if (!response.ok) throw new Error(`CoW answered ${response.status}`)
+ order = ApiOrder.parse(await response.json())
+ } catch (e) {
+ if (e instanceof ChainActionRefused) throw e
+ return refused('I couldn’t read that order on CoW just now, so try again in a moment.')
+ }
+ if (!sameAddress(order.owner, owner)) refused('That order belongs to another wallet.')
+ if (order.uid.toLowerCase() !== uid) refused('That is not a CoW order I can change.')
+ if (!isOpen(order)) {
+ refused('That order is no longer open, so there is nothing to cancel.')
+ }
+ return {
+ venueId: COW_VENUE_ID,
+ venueName: VENUE_NAME,
+ kind: 'limit',
+ summary: 'Cancel your CoW order on Avalanche for good, on chain.',
+ pay: { amount: '0', symbol: 'AVAX' },
+ notes: [
+ 'Cancelling costs a small network fee.',
+ 'It could fill before this lands. Whatever filled is already in your wallet.',
+ 'CoW also has a free cancel, but a solver may still fill the order after it. This one is final.',
+ ],
+ signing: {
+ family: 'evm',
+ chainId: COW_CHAIN_ID,
+ to: COW_SETTLEMENT,
+ data: encodeFunctionData({
+ abi: SETTLEMENT_ABI,
+ functionName: 'invalidateOrder',
+ args: [uid as Hex],
+ }),
+ value: '0',
+ },
+ context: { orderId: uid },
+ }
+}
+
+export function cowBook(): LimitBook {
+ return { openOrders, prepareExit }
+}
+
+const SETUP_SELECTORS = [
+ '0xd0e30db0',
+ encodeFunctionData({
+ abi: erc20Abi,
+ functionName: 'approve',
+ args: [COW_VAULT_RELAYER, BigInt(0)],
+ }).slice(0, 10),
+ encodeFunctionData({ abi: SETTLEMENT_ABI, functionName: 'invalidateOrder', args: ['0x'] }).slice(
+ 0,
+ 10
+ ),
+]
+const CONTRACTS = [
+ WAVAX,
+ COW_SETTLEMENT,
+ ...AVALANCHE_TOKENS.filter((t) => t.id !== EVM_NATIVE_ID).map((t) => t.id),
+]
+
+function pending(error: string): SubmitResult {
+ return { ok: false, pending: true, error }
+}
+
+/**
+ * A wrap, an approval or a cancel the wallet already sent. Only a revert of one of this venue's own
+ * calls, from this account, proves nothing moved but the gas.
+ */
+async function confirmTransaction(account: string, hash: string): Promise {
+ if (!EVM_HASH.test(hash)) {
+ return pending(
+ "The wallet didn't return a transaction this app can follow. Check your wallet's activity before trying again."
+ )
+ }
+ const client = evmClient('avalanche')
+ let receipt: EvmReceipt
+ try {
+ receipt = await client.waitForReceipt(hash as Hash)
+ } catch {
+ return pending(
+ `This isn't confirmed on Avalanche yet, and it may still land, so don't send it again. Its transaction is ${hash}.`
+ )
+ }
+ const ours =
+ sameAddress(receipt.from, account) &&
+ receipt.to !== null &&
+ CONTRACTS.some((c) => sameAddress(c, receipt.to as string))
+ if (!ours) {
+ return pending(
+ `Transaction ${hash} is not this order's. Check your wallet's activity before trying again.`
+ )
+ }
+ if (receipt.status === 'reverted') {
+ let input: string
+ try {
+ ;({ input } = await client.transaction(hash as Hash))
+ } catch {
+ return pending(
+ `Transaction ${hash} failed, but it couldn't be read. Check your wallet's activity before trying again.`
+ )
+ }
+ if (!SETUP_SELECTORS.includes(input.slice(0, 10).toLowerCase())) {
+ return pending(
+ `Transaction ${hash} is not this order's. Check your wallet's activity before trying again.`
+ )
+ }
+ return {
+ ok: false,
+ error: `That failed on Avalanche (${hash}), so nothing moved apart from the gas. You can try again.`,
+ }
+ }
+ return { ok: true, hash, explorerUrl: evmExplorerUrl('avalanche', hash) }
+}
+
+/**
+ * Posts a signed order. The order is rebuilt from the context and checked again, and the signature
+ * must recover to the account, so whatever the browser changed, only what this wallet signed is
+ * posted, and only an order this app would have built.
+ */
+async function postOrder(input: SubmitInput, signature: string): Promise {
+ const { account, context } = input
+ let owner: Address
+ let order: CowOrder
+ try {
+ owner = assertAccount(account)
+ if (context?.['step'] !== 'order') refused('That is not an order this app prepared.')
+ const validTo = Number(context['validTo'])
+ order = cowOrder({
+ sellToken: context['sellToken'] ?? '',
+ buyToken: context['buyToken'] ?? '',
+ receiver: owner,
+ sellAmount: context['sellAmount'] ?? '',
+ buyAmount: context['buyAmount'] ?? '',
+ validTo,
+ })
+ cowAssertSafe(
+ {
+ family: 'evm-typed',
+ chainId: COW_CHAIN_ID,
+ account: owner,
+ typedData: cowTypedData(order),
+ },
+ owner,
+ context
+ )
+ } catch (e) {
+ if (e instanceof ChainActionRefused)
+ return {
+ ok: false,
+ error: e.message.replace('Nothing was signed or sent.', 'Nothing was sent.'),
+ }
+ throw e
+ }
+ if (!/^0x[0-9a-fA-F]{130}$/.test(signature)) {
+ return {
+ ok: false,
+ error: 'The wallet gave back a signature this app can’t read. Nothing was sent.',
+ }
+ }
+ let signer: string
+ try {
+ signer = await recoverTypedDataAddress({ ...viemTypedData(order), signature: signature as Hex })
+ } catch {
+ signer = ''
+ }
+ if (!sameAddress(signer, owner)) {
+ return {
+ ok: false,
+ error:
+ 'That signature isn’t from this wallet, so the order was not placed. A smart-contract wallet, such as a Safe, can’t sign a CoW order here yet. Nothing was sent.',
+ }
+ }
+
+ const uid = cowOrderUid(order, owner)
+ let response: Response
+ try {
+ response = await cowApi('/orders', {
+ method: 'POST',
+ body: {
+ ...order,
+ appData: COW_APP_DATA,
+ appDataHash: COW_APP_DATA_HASH,
+ signingScheme: 'eip712',
+ signature,
+ from: owner,
+ },
+ })
+ } catch {
+ return pending(
+ `CoW didn't answer, so the order may or may not be resting. Check your open orders before trying again.`
+ )
+ }
+ if (response.status >= 400 && response.status < 500) {
+ const type = await errorType(response)
+ // The same signed order again, after an answer that never arrived: it is resting already.
+ if (type === 'DuplicatedOrder') return { ok: true, hash: uid, explorerUrl: `${EXPLORER}${uid}` }
+ if (type === 'SellAmountDoesNotCoverFee')
+ return { ok: false, error: `${TOO_SMALL} The order was not placed.` }
+ return {
+ ok: false,
+ error: `CoW didn't take the order${type === undefined ? '' : ` (${type})`}, so nothing is resting.`,
+ }
+ }
+ if (!response.ok) {
+ return pending(
+ `CoW didn't answer clearly, so the order may or may not be resting. Check your open orders before trying again.`
+ )
+ }
+ const answered = await response.json().catch(() => undefined)
+ if (typeof answered !== 'string' || answered.toLowerCase() !== uid) {
+ return pending(
+ `CoW took an order, but not with the id this app worked out, so it may not be the one you signed. Check your open orders and cancel anything you don't recognise.`
+ )
+ }
+ return { ok: true, hash: uid, explorerUrl: `${EXPLORER}${uid}` }
+}
+
+async function submitCow(input: SubmitInput): Promise {
+ const { signed } = input
+ if (signed.family === 'evm-typed') return postOrder(input, signed.signature)
+ if (signed.family === 'evm') return confirmTransaction(input.account, signed.hash)
+ return { ok: false, error: 'That is not an Avalanche signature.' }
+}
+
+export const cowVenue: ChainVenue = {
+ id: COW_VENUE_ID,
+ name: VENUE_NAME,
+ kinds: ['limit'],
+ prepare,
+ assertSafe: (payload, account, context) => cowAssertSafe(payload, account, context),
+ submit: submitCow,
+}
diff --git a/apps/dapp/lib/chains/evm/module.ts b/apps/dapp/lib/chains/evm/module.ts
index 0bb2dd88..775c9cbe 100644
--- a/apps/dapp/lib/chains/evm/module.ts
+++ b/apps/dapp/lib/chains/evm/module.ts
@@ -13,6 +13,7 @@ import {
import { readEvmBalances } from './balances'
import { beefyMonadVenue } from './beefy'
import { createBullaVenue } from './bulla'
+import { COW_VENUE_ID, cowBook, cowVenue } from './cow'
import { curvanceVenue } from './curvance'
import { createEulerMonadVenue } from './euler'
import { createFastlaneVenue, SHMON, shmonPriceUsd } from './fastlane'
@@ -49,9 +50,12 @@ export function buildEvmVenues(
env: Record = process.env
): ChainVenue[] {
const oneClick = canIssueOneClick(env)
+ // CoW has no deployment on Monad, so its limit orders are Avalanche's alone.
+ const limit = chain === 'avalanche' ? [cowVenue] : []
return [
...(oneClick ? [oneClickOriginVenue(chain)] : []),
...ONCHAIN_VENUES[chain](),
+ ...limit,
...(oneClick ? [createSendVenue(chain)] : []),
]
}
@@ -102,9 +106,15 @@ function evmModule(
chain,
tokens: EVM_CHAINS[chain].tokens,
venues: buildEvmVenues(chain),
- defaultVenue: { swap: `1click-${chain}`, send: SEND_VENUE_ID, ...defaults },
+ defaultVenue: {
+ swap: `1click-${chain}`,
+ send: SEND_VENUE_ID,
+ ...defaults,
+ ...(chain === 'avalanche' ? { limit: COW_VENUE_ID } : {}),
+ },
priceUsd: (token) => priceOf(chain, token),
- // Every venue here lands its own transactions, and no venue means no submit gets this far.
+ // Every venue here lands its own transactions or posts its own orders, and no venue means no
+ // submit gets this far.
submit: async () => ({
ok: false,
pending: true,
@@ -113,6 +123,7 @@ function evmModule(
}),
balances: (account, signal) => readEvmBalances(chain, account, undefined, signal),
...(tokenHints !== undefined ? { tokenHints } : {}),
+ ...(chain === 'avalanche' ? { limitBook: cowBook() } : {}),
}
}
diff --git a/apps/dapp/lib/chains/evm/sign-typed.ts b/apps/dapp/lib/chains/evm/sign-typed.ts
new file mode 100644
index 00000000..72b698b9
--- /dev/null
+++ b/apps/dapp/lib/chains/evm/sign-typed.ts
@@ -0,0 +1,68 @@
+import { BaseError, UserRejectedRequestError, type Address, type Hex } from 'viem'
+
+import { isUserRejection } from '../../wallet/connector'
+import { walletErrorMessage } from '../../wallet/error-message'
+import { ChainActionRefused, type SignedOutcome, type SigningPayload } from '../types'
+import { assertCowOrderShape, viemTypedData } from './cow-rules'
+
+/** What signing a typed message needs of a wallet. wagmi's actions in the app, fakes in tests. */
+export interface TypedWallet {
+ account: () => { status: string; address?: string | undefined; chainId?: number | undefined }
+ switchChain: (chainId: number) => Promise
+ signTypedData: (args: { account: Address } & ReturnType) => Promise
+}
+
+/** A wallet's failure as the card reads it: a decline, or the wallet's own short words. */
+export function walletFailure(e: unknown): SignedOutcome {
+ // viem's message goes on to list the whole request, so its short one is what is read.
+ const detail = e instanceof BaseError ? e.shortMessage : walletErrorMessage(e)
+ const declined =
+ (e instanceof BaseError &&
+ e.walk((cause) => cause instanceof UserRejectedRequestError) !== null) ||
+ isUserRejection(detail)
+ return declined
+ ? { ok: false, reason: 'rejected' }
+ : { ok: false, reason: 'wallet_error', detail }
+}
+
+/**
+ * Signs a typed message, which only ever is a CoW order on Avalanche. A wallet shows it as fields
+ * few people can read, so it is checked here, again, against everything that does not depend on
+ * what was asked, and refused before the wallet is touched. Nothing else typed is ever signed.
+ */
+export async function signTypedOn(
+ name: string,
+ chainId: number,
+ payload: SigningPayload,
+ wallet: TypedWallet,
+ now: number = Date.now()
+): Promise {
+ const refuse = (detail: string): SignedOutcome => ({ ok: false, reason: 'wallet_error', detail })
+ if (payload.family !== 'evm-typed') return refuse('That is not an order to sign.')
+ if (payload.chainId !== chainId) return refuse(`That order is not for ${name}.`)
+ const { status, address, chainId: walletChain } = wallet.account()
+ if (status !== 'connected' || address === undefined) {
+ return refuse('Reconnect your wallet and try again.')
+ }
+ if (address.toLowerCase() !== payload.account.toLowerCase()) {
+ return refuse('This order was made for another wallet. Switch back to that wallet to sign it.')
+ }
+ let order
+ try {
+ order = assertCowOrderShape(payload, address, now)
+ } catch (e) {
+ if (e instanceof ChainActionRefused) return refuse(e.message)
+ throw e
+ }
+ try {
+ if (walletChain !== chainId) await wallet.switchChain(chainId)
+ const signature = await wallet.signTypedData({
+ account: address as Address,
+ ...viemTypedData(order),
+ })
+ if (!/^0x[0-9a-fA-F]+$/.test(signature)) return refuse('The wallet gave back no signature.')
+ return { ok: true, signed: { family: 'evm-typed', signature: signature as Hex } }
+ } catch (e) {
+ return walletFailure(e)
+ }
+}
diff --git a/apps/dapp/lib/chains/evm/venues.ts b/apps/dapp/lib/chains/evm/venues.ts
index e7e39057..0cf1ba4d 100644
--- a/apps/dapp/lib/chains/evm/venues.ts
+++ b/apps/dapp/lib/chains/evm/venues.ts
@@ -35,6 +35,18 @@ export const evmVenues: Venue[] = [
url: ONECLICK_DOCS,
bestFor: 'Swaps between MON, USDC and USDT0, priced by competing market makers',
},
+ {
+ id: 'cow-limit',
+ family: 'evm',
+ name: 'CoW Protocol',
+ category: 'orderbook',
+ chains: ['Avalanche'],
+ url: 'https://cow.fi',
+ bestFor: 'Selling or buying AVAX at a price you name, for up to 7 days',
+ integration: 'executes',
+ capability:
+ 'Places, reads and cancels AVAX orders against USDC or USDT, and checks every order and approval before you sign. Fills arrive in your wallet. Mainnet only. No order has been signed through this app yet.',
+ },
uniswapAvalancheVenue,
uniswapMonadVenue,
pharaohVenue,
diff --git a/apps/dapp/lib/chains/explain.ts b/apps/dapp/lib/chains/explain.ts
index 12f20916..8114debc 100644
--- a/apps/dapp/lib/chains/explain.ts
+++ b/apps/dapp/lib/chains/explain.ts
@@ -6,6 +6,7 @@ const EXAMPLE: Record string
supply: (_symbol, venue) => `supply 25 USDC on ${venue}`,
stake: (symbol, venue) => `stake 1 ${symbol} with ${venue}`,
send: (symbol) => `send 5 ${symbol} to an address`,
+ limit: (symbol) => `sell 1 ${symbol} at $110`,
withdraw: (symbol, venue) => `withdraw 25 ${symbol} from ${venue}`,
unstake: (symbol, venue) => `unstake 1 ${symbol} from ${venue}`,
borrow: (symbol, venue) => `borrow 25 ${symbol} on ${venue}`,
@@ -50,7 +51,10 @@ export function chainHelp(chainName: string, capabilities: readonly ChainCapabil
) {
examples.push(`"${EXAMPLE.send('USDC', '')}"`)
}
- return `I couldn't read that as something I can do on ${chainName}. Try ${examples.join(' or ')}.`
+ const orders = capabilities.some((c) => c.kind === 'limit')
+ ? ' Say "my orders" to see, cancel or collect a limit order.'
+ : ''
+ return `I couldn't read that as something I can do on ${chainName}. Try ${examples.join(' or ')}.${orders}`
}
export const RATE_LIMITED = "You're sending messages quickly. Wait a minute and try again."
diff --git a/apps/dapp/lib/chains/limit.ts b/apps/dapp/lib/chains/limit.ts
new file mode 100644
index 00000000..2b1a0773
--- /dev/null
+++ b/apps/dapp/lib/chains/limit.ts
@@ -0,0 +1,124 @@
+import { chainTokenWord } from './parse-action'
+import { ChainActionRefused, type ChainWithActions, type PreparedAction } from './types'
+
+/** Chains where a limit order is refused, and why. */
+export const LIMIT_NOT_HERE: Partial> = {
+ monad:
+ 'Monad has no limit order venue I would trust with your money yet. The only real option, Kuru, keeps deposits in an upgradeable contract with no audit I could find. Nothing was signed or sent.',
+}
+
+const STABLES = ['USDC', 'USDT']
+
+export interface RestingOrder {
+ id: string
+ venueId: string
+ tokenIn: string
+ tokenOut: string
+ amountIn: string
+ remainingIn: string
+ receivedOut: string
+ collectable?: { amount: string; symbol: string }[]
+ limitPrice?: string
+ status: 'open' | 'partly' | 'filled' | 'done'
+ expiresAt?: number
+ note?: string
+}
+
+export interface LimitBook {
+ openOrders(account: string, signal?: AbortSignal): Promise
+ /** Must refuse an order the account does not own. The route checks the signing, not ownership. */
+ prepareExit(
+ account: string,
+ orderId: string,
+ action: 'cancel' | 'collect'
+ ): Promise
+}
+
+/** The token traded against a stablecoin, which side is given up, and nothing for any other pair. */
+export function limitPair(
+ tokenIn: string,
+ tokenOut: string
+): { base: string; quote: string; selling: boolean } | undefined {
+ const given = chainTokenWord(tokenIn).toUpperCase()
+ const got = chainTokenWord(tokenOut).toUpperCase()
+ const inStable = STABLES.includes(given)
+ const outStable = STABLES.includes(got)
+ if (inStable === outStable) return undefined
+ return inStable
+ ? { base: got, quote: given, selling: false }
+ : { base: given, quote: got, selling: true }
+}
+
+function scaled(text: string, what: string): { digits: bigint; places: number } {
+ if (!/^\d+(\.\d+)?$/.test(text.trim())) {
+ throw new ChainActionRefused(`"${text}" is not a ${what}. Nothing was signed or sent.`)
+ }
+ const [whole = '0', fraction = ''] = text.trim().split('.')
+ return { digits: BigInt(`${whole}${fraction}`), places: fraction.length }
+}
+
+function ceilDiv(top: bigint, bottom: bigint): bigint {
+ return (top + bottom - BigInt(1)) / bottom
+}
+
+/**
+ * What the order must deliver, in the output token's base units, so it never accepts less than the
+ * stated price. Exact arithmetic, rounded up.
+ */
+export function limitOutput(
+ amountIn: string,
+ price: string,
+ selling: boolean,
+ _decimalsIn: number,
+ decimalsOut: number
+): bigint {
+ const amount = scaled(amountIn, 'amount')
+ const rate = scaled(price, 'price')
+ if (rate.digits === BigInt(0)) {
+ throw new ChainActionRefused('The price must be more than zero. Nothing was signed or sent.')
+ }
+ const ten = (n: number): bigint => BigInt(10) ** BigInt(n)
+ const amountScale = ten(amount.places)
+ const priceScale = ten(rate.places)
+ return selling
+ ? ceilDiv(amount.digits * rate.digits * ten(decimalsOut), amountScale * priceScale)
+ : ceilDiv(amount.digits * priceScale * ten(decimalsOut), amountScale * rate.digits)
+}
+
+function shown(market: number): string {
+ return String(Number(market.toPrecision(4)))
+}
+
+/**
+ * Refuses a price that would fill at once at a worse price than the market, or that is so far from
+ * it that it is likely a typo.
+ */
+export function assertRestingPrice(
+ price: string,
+ marketUsd: number,
+ selling: boolean,
+ symbol: string
+): void {
+ const m = shown(marketUsd)
+ if (!/^\d+(\.\d+)?$/.test(price.trim()) || !(Number(price) > 0)) {
+ throw new ChainActionRefused(
+ `"${price}" is not a price. Use a plain figure such as 110 or 110.50. Nothing was signed or sent.`
+ )
+ }
+ const figure = Number(price)
+ if (selling && figure < marketUsd) {
+ throw new ChainActionRefused(
+ `That would sell below today's ${symbol} price of about $${m}, so it would fill at once. Name a price above it. Nothing was signed or sent.`
+ )
+ }
+ if (!selling && figure > marketUsd) {
+ throw new ChainActionRefused(
+ `That would buy above today's ${symbol} price of about $${m}, so it would fill at once. Name a price below it. Nothing was signed or sent.`
+ )
+ }
+ if (figure > marketUsd * 1.5 || figure < marketUsd * 0.5) {
+ throw new ChainActionRefused(
+ `$${price} is far from today's ${symbol} price of about $${m}. Check the figure and send it again. Nothing was signed or sent.`
+ )
+ }
+}
diff --git a/apps/dapp/lib/chains/near/capabilities.ts b/apps/dapp/lib/chains/near/capabilities.ts
index bdd6ba40..98df43fa 100644
--- a/apps/dapp/lib/chains/near/capabilities.ts
+++ b/apps/dapp/lib/chains/near/capabilities.ts
@@ -14,4 +14,5 @@ export const nearCapabilities: ChainCapability[] = [
{ kind: 'withdraw', venue: 'Rhea Lend', symbol: 'USDC' },
{ kind: 'borrow', venue: 'Rhea Lend', symbol: 'USDC' },
{ kind: 'repay', venue: 'Rhea Lend', symbol: 'USDC' },
+ { kind: 'limit', venue: 'Rhea', symbol: 'NEAR' },
]
diff --git a/apps/dapp/lib/chains/near/module.ts b/apps/dapp/lib/chains/near/module.ts
index 62432cf7..e083a26c 100644
--- a/apps/dapp/lib/chains/near/module.ts
+++ b/apps/dapp/lib/chains/near/module.ts
@@ -4,6 +4,7 @@ import type { ChainModule, ChainVenue } from '../types'
import { readNearBalances } from './balances'
import { EARN_VENUE_ID, earnVenue } from './earn'
import { assetIdOf, oneClickPrice } from './oneclick'
+import { RHEA_VENUE_ID, rheaBook, rheaVenue } from './rhea-dcl'
import { RHEA_LEND_VENUE_ID, rheaLendVenue } from './rhea-lend'
import { RHEA_DEX_VENUE_ID, rheaDexVenue } from './rhea-swap'
import { confirmStake, metapoolVenue, METAPOOL_VENUE_ID, rnearVenue } from './stake'
@@ -21,6 +22,7 @@ export function buildNearVenues(
metapoolVenue,
rnearVenue,
earnVenue,
+ rheaVenue,
...(canIssueOneClick(env) ? [createSendVenue('near')] : []),
]
}
@@ -45,6 +47,7 @@ export const nearModule: ChainModule = {
*/
stake: METAPOOL_VENUE_ID,
send: SEND_VENUE_ID,
+ limit: RHEA_VENUE_ID,
},
venueForToken: {
unstake: { STNEAR: METAPOOL_VENUE_ID, RNEAR: 'rnear' },
@@ -53,4 +56,5 @@ export const nearModule: ChainModule = {
priceUsd: (token) => oneClickPrice(assetIdOf(token), token.decimals),
submit: confirmStake,
balances: readNearBalances,
+ limitBook: rheaBook(),
}
diff --git a/apps/dapp/lib/chains/near/rhea-dcl-rules.ts b/apps/dapp/lib/chains/near/rhea-dcl-rules.ts
new file mode 100644
index 00000000..57f7c6f3
--- /dev/null
+++ b/apps/dapp/lib/chains/near/rhea-dcl-rules.ts
@@ -0,0 +1,135 @@
+import { limitPair } from '../limit'
+import { ChainActionRefused, tokenBySymbol, type ChainToken } from '../types'
+import { nearTokens, TGAS, USDC_NEAR, WRAP_NEAR } from './tokens'
+
+export const DCL_CONTRACT = 'dclv2.ref-labs.near'
+/** USDC is token x and wNEAR token y, at a 0.01% fee: the only pool here with real depth. */
+export const DCL_POOL = `${USDC_NEAR}|${WRAP_NEAR}|100`
+export const DCL_POINT_DELTA = 1
+export const DCL_STORAGE_CAP = BigInt('500000000000000000000000')
+export const DCL_ORDER_GAS = BigInt(250) * TGAS
+export const DCL_EXIT_GAS = BigInt(180) * TGAS
+
+const POINT_BASE = 1.0001
+const USDC_PER_NEAR_AT_POINT_ZERO = 1e18
+
+function refused(sentence: string): never {
+ throw new ChainActionRefused(`${sentence} Nothing was signed or sent.`)
+}
+
+/** USDC per NEAR at a point on this pool. A higher point is a cheaper NEAR. */
+export function priceAtPoint(point: number): number {
+ return USDC_PER_NEAR_AT_POINT_ZERO / POINT_BASE ** point
+}
+
+/**
+ * The point of a resting order: on the pool's step, and never worse than asked. A sale rounds to the
+ * lower point, a dearer NEAR, and a purchase to the higher point, a cheaper one.
+ */
+export function pointForPrice(priceUsdcPerNear: number, selling: boolean, delta: number): number {
+ if (!Number.isFinite(priceUsdcPerNear) || priceUsdcPerNear <= 0) {
+ refused('That is not a price I can place on Rhea.')
+ }
+ const exact = Math.log(USDC_PER_NEAR_AT_POINT_ZERO / priceUsdcPerNear) / Math.log(POINT_BASE)
+ const steps = selling ? Math.floor(exact / delta) : Math.ceil(exact / delta)
+ let point = steps * delta
+ while (
+ selling ? priceAtPoint(point) < priceUsdcPerNear : priceAtPoint(point) > priceUsdcPerNear
+ ) {
+ point += selling ? -delta : delta
+ }
+ return point
+}
+
+/**
+ * `LimitOrderWithSwap` swaps a marketable part at once, with no minimum out in the message, so an order
+ * must rest beyond the pool's own price: a sale below its current point, a purchase above it. Read
+ * just before building, so it can still cross before it lands. Why not plain `LimitOrder`: see near.md.
+ */
+export function assertRestingPoint(point: number, currentPoint: number, selling: boolean): void {
+ if (selling ? point >= currentPoint : point <= currentPoint) {
+ refused(
+ `Rhea's own NEAR price is about $${Number(priceAtPoint(currentPoint).toPrecision(4))}, so that would fill at once with no minimum.`
+ )
+ }
+}
+
+export interface RheaPair {
+ input: ChainToken
+ output: ChainToken
+ selling: boolean
+}
+
+/** Only NEAR against USDC. Anything else is refused here, wherever the request came from. */
+export function rheaPair(tokenIn: string, tokenOut: string): RheaPair {
+ const pair = limitPair(tokenIn, tokenOut)
+ const input = tokenBySymbol(nearTokens, tokenIn)
+ const output = tokenBySymbol(nearTokens, tokenOut)
+ if (
+ pair?.base !== 'NEAR' ||
+ pair.quote !== 'USDC' ||
+ input === undefined ||
+ output === undefined
+ ) {
+ refused('Rhea here only trades NEAR against USDC.')
+ }
+ return { input, output, selling: pair.selling }
+}
+
+/** The token an order selling this one buys, or nothing for a token not on this pool. */
+export function dclBuyToken(sellToken: string): string | undefined {
+ if (sellToken === WRAP_NEAR) return USDC_NEAR
+ return sellToken === USDC_NEAR ? WRAP_NEAR : undefined
+}
+
+/** The exact text an order carries. Key order matches what Rhea's own interface sends. */
+export function dclOrderMsg(buyToken: string, point: number): string {
+ return JSON.stringify({
+ LimitOrderWithSwap: { client_id: '', pool_id: DCL_POOL, buy_token: buyToken, point },
+ })
+}
+
+export function isDclOrderId(orderId: unknown): orderId is string {
+ return (
+ typeof orderId === 'string' &&
+ orderId.startsWith(`${DCL_POOL}#`) &&
+ /^\d{1,20}$/.test(orderId.slice(DCL_POOL.length + 1))
+ )
+}
+
+/** Yes only for one of this venue's calls. The point is an order's, for finding it afterwards. */
+export function readExecuted(
+ receiver: string,
+ calls: readonly { method: string; args: Record }[]
+): { belongs: boolean; point?: number } {
+ let point: number | undefined
+ const belongs =
+ calls.length > 0 &&
+ calls.every(({ method, args }) => {
+ if (method === 'storage_deposit') return true
+ if (receiver === DCL_CONTRACT)
+ return method === 'cancel_order' && isDclOrderId(args['order_id'])
+ if (method === 'near_deposit') return receiver === WRAP_NEAR
+ if (method !== 'ft_transfer_call' || args['receiver_id'] !== DCL_CONTRACT) return false
+ point = pointOf(args['msg'])
+ return point !== undefined
+ })
+ const known = receiver === DCL_CONTRACT || dclBuyToken(receiver) !== undefined
+ return known && belongs
+ ? { belongs: true, ...(point === undefined ? {} : { point }) }
+ : { belongs: false }
+}
+
+function pointOf(msg: unknown): number | undefined {
+ if (typeof msg !== 'string') return undefined
+ try {
+ const order = (
+ JSON.parse(msg) as { LimitOrderWithSwap?: { pool_id?: unknown; point?: unknown } }
+ ).LimitOrderWithSwap
+ return order?.pool_id === DCL_POOL && Number.isSafeInteger(order.point)
+ ? (order.point as number)
+ : undefined
+ } catch {
+ return undefined
+ }
+}
diff --git a/apps/dapp/lib/chains/near/rhea-dcl.ts b/apps/dapp/lib/chains/near/rhea-dcl.ts
new file mode 100644
index 00000000..78dbf6be
--- /dev/null
+++ b/apps/dapp/lib/chains/near/rhea-dcl.ts
@@ -0,0 +1,509 @@
+import { z } from 'zod'
+
+import { limitOutput, type LimitBook, type RestingOrder } from '../limit'
+import { fromBase } from '../sequence'
+import {
+ ChainActionRefused,
+ toBaseUnits,
+ type ChainActionRequest,
+ type ChainToken,
+ type ChainVenue,
+ type NearTransaction,
+ type PreparedAction,
+ type SubmitInput,
+ type SubmitResult,
+} from '../types'
+import { NEAR_ACCOUNT, NEAR_TX_HASH } from './account'
+import {
+ assertRestingPoint,
+ DCL_CONTRACT,
+ DCL_EXIT_GAS,
+ DCL_ORDER_GAS,
+ DCL_POINT_DELTA,
+ DCL_POOL,
+ DCL_STORAGE_CAP,
+ dclOrderMsg,
+ isDclOrderId,
+ pointForPrice,
+ priceAtPoint,
+ readExecuted,
+ rheaPair,
+ type RheaPair,
+} from './rhea-dcl-rules'
+import { isRegistered, viewCall, waitForTransaction } from './rpc'
+import { assertRheaDclSafe, STORAGE_DEPOSIT_YOCTO, STORAGE_GAS, WRAP_GAS } from './safety'
+import { explorerUrl, formatUnits, nearTokens, USDC_NEAR, WRAP_NEAR } from './tokens'
+
+/**
+ * Read off mainnet on 5 November 2026, with nothing signed (`near-rhea-dcl.live.test.ts`, and
+ * docs/research/2026-11-05-limit-orders/near.md for the transactions behind each):
+ *
+ * 1. Several transactions don't carry a first use safely. NEAR Connect hands the array to whichever
+ * wallet is connected, promises neither their order nor to stop after a failure, and returns
+ * nothing if it fails partway. So storage is its own signature, with `thenPrepareAgain`, and the
+ * order is only built once the chain shows the storage is there.
+ * 2. `cancel_order` with `{order_id, amount}` pays bought or returned wNEAR as native NEAR, because the
+ * contract unwraps it unless told not to with `skip_unwrap_near`. So a purchase pays out in NEAR.
+ * 3. `bought_amount` includes `unclaimed_amount`, and a filled order stays in `list_active_orders`
+ * with `remain_amount` "0" until it is collected.
+ * 4. An `amount` above what is left is cut to what is left, so a cancel survives a fill before it lands.
+ */
+
+export const RHEA_VENUE_ID = 'rhea-limit'
+const VENUE_NAME = 'Rhea'
+const NEAR_DECIMALS = 24
+const CONFIRM_WAIT_MS = 25_000
+const ORDER_SEEN_WAIT_MS = 8_000
+const ORDER_SEEN_POLL_MS = 1_500
+
+const NOTES = [
+ 'When it fills, press Collect to receive it. That needs a second signature.',
+ 'First use sets aside up to 0.5 NEAR for storage on Rhea. I could not confirm it comes back.',
+ 'A purchase pays out in NEAR.',
+ 'This stays open until it fills or you cancel it.',
+]
+
+const Amount = z.string().regex(/^\d+$/)
+
+const Pool = z.object({
+ token_x: z.string(),
+ token_y: z.string(),
+ point_delta: z.number(),
+ current_point: z.number().int(),
+ state: z.string(),
+ tax: z.unknown(),
+})
+
+const Metadata = z.object({ state: z.string() })
+
+const StorageDetail = z
+ .object({
+ max_slots: z.number(),
+ cur_order_slots: z.number(),
+ cur_liquidity_slots: z.number(),
+ })
+ .nullable()
+
+const Bounds = z.object({ min: Amount })
+
+const Order = z.object({
+ order_id: z.string(),
+ owner_id: z.string(),
+ pool_id: z.string(),
+ point: z.number().int(),
+ sell_token: z.string(),
+ buy_token: z.string(),
+ original_amount: Amount,
+ remain_amount: Amount,
+ bought_amount: Amount,
+ unclaimed_amount: Amount.nullable(),
+})
+
+type DclOrder = z.infer
+
+function refused(sentence: string): never {
+ throw new ChainActionRefused(`${sentence} Nothing was signed or sent.`)
+}
+
+function assertAccount(account: string): void {
+ if (!NEAR_ACCOUNT.test(account)) refused('That is not a NEAR account.')
+}
+
+/** A read needed before anything can be built. Failing means building nothing, said in words. */
+async function mustRead(work: () => Promise, what: string): Promise {
+ try {
+ return await work()
+ } catch (e) {
+ if (e instanceof ChainActionRefused) throw e
+ return refused(`I couldn’t read ${what} just now, so try again in a moment.`)
+ }
+}
+
+function functionCall(
+ methodName: string,
+ args: Record,
+ gas: bigint,
+ deposit: string
+): NearTransaction['actions'][number] {
+ return { type: 'FunctionCall', params: { methodName, args, gas: gas.toString(), deposit } }
+}
+
+function storageTransaction(receiverId: string, account: string, deposit: string): NearTransaction {
+ return {
+ receiverId,
+ actions: [
+ functionCall(
+ 'storage_deposit',
+ { account_id: account, registration_only: true },
+ STORAGE_GAS,
+ deposit
+ ),
+ ],
+ }
+}
+
+type StorageRecord = z.infer
+
+interface Setup {
+ record: StorageRecord
+ /** The storage still owed, in the order it is paid. Empty once the order can be placed. */
+ transactions: NearTransaction[]
+ owed: bigint
+}
+
+/** What the chain says this account still has to pay for, and refuses more than the cap. */
+async function readSetup(account: string, pair: RheaPair): Promise {
+ const [detail, buyRegistered, wrapRegistered] = await Promise.all([
+ viewCall(DCL_CONTRACT, 'get_user_storage_detail', { user_id: account }),
+ isRegistered(pair.output.id, account),
+ pair.selling ? isRegistered(WRAP_NEAR, account) : true,
+ ])
+ const record = StorageDetail.parse(detail)
+ const transactions: NearTransaction[] = []
+ let owed = BigInt(0)
+ const pay = (receiverId: string, deposit: string): void => {
+ transactions.push(storageTransaction(receiverId, account, deposit))
+ owed += BigInt(deposit)
+ }
+ if (record === null) {
+ const { min } = Bounds.parse(await viewCall(DCL_CONTRACT, 'storage_balance_bounds'))
+ if (BigInt(min) > DCL_STORAGE_CAP) {
+ refused('Rhea now asks for more than the 0.5 NEAR of storage I accept.')
+ }
+ pay(DCL_CONTRACT, min)
+ }
+ if (!buyRegistered) pay(pair.output.id, STORAGE_DEPOSIT_YOCTO)
+ if (!wrapRegistered) pay(WRAP_NEAR, STORAGE_DEPOSIT_YOCTO)
+ return { record, transactions, owed }
+}
+
+async function limitCostNow(
+ request: ChainActionRequest,
+ account: string
+): Promise {
+ const { owed } = await readSetup(account, rheaPair(request.tokenIn, request.tokenOut ?? ''))
+ return owed === BigInt(0) ? undefined : { amount: fromBase(owed, NEAR_DECIMALS), symbol: 'NEAR' }
+}
+
+/** The pool has to be the one this was checked against, and open. */
+async function readCurrentPoint(): Promise {
+ const [poolRow, metadata] = await Promise.all([
+ viewCall(DCL_CONTRACT, 'get_pool', { pool_id: DCL_POOL }),
+ viewCall(DCL_CONTRACT, 'get_metadata'),
+ ])
+ const pool = Pool.parse(poolRow)
+ if (Metadata.parse(metadata).state !== 'Running' || pool.state !== 'Running') {
+ refused('Rhea is not taking orders on this pool right now.')
+ }
+ if (
+ pool.token_x !== USDC_NEAR ||
+ pool.token_y !== WRAP_NEAR ||
+ pool.point_delta !== DCL_POINT_DELTA ||
+ (pool.tax !== null && pool.tax !== undefined)
+ ) {
+ refused('This Rhea pool has changed since I last checked it.')
+ }
+ return pool.current_point
+}
+
+function baseUnits(amount: string, token: ChainToken): string {
+ try {
+ return toBaseUnits(amount, token.decimals)
+ } catch (e) {
+ if (e instanceof ChainActionRefused) refused(e.message)
+ throw e
+ }
+}
+
+function orderTransaction(pair: RheaPair, amount: string, point: number): NearTransaction {
+ const order = functionCall(
+ 'ft_transfer_call',
+ { receiver_id: DCL_CONTRACT, amount, msg: dclOrderMsg(pair.output.id, point) },
+ DCL_ORDER_GAS,
+ '1'
+ )
+ return {
+ receiverId: pair.input.id,
+ actions: pair.selling ? [functionCall('near_deposit', {}, WRAP_GAS, amount), order] : [order],
+ }
+}
+
+async function prepare(request: ChainActionRequest, account: string): Promise {
+ assertAccount(account)
+ if (request.kind !== 'limit') {
+ refused(`${VENUE_NAME} only rests orders. It can’t ${request.kind}.`)
+ }
+ const pair = rheaPair(request.tokenIn, request.tokenOut ?? '')
+ const { input, output, selling } = pair
+ const amount = baseUnits(request.amount, input)
+ const limitPrice = request.limitPrice ?? ''
+ const received = limitOutput(request.amount, limitPrice, selling, input.decimals, output.decimals)
+ if (received === BigInt(0)) refused('That order is too small to place.')
+ const point = pointForPrice(Number(limitPrice), selling, DCL_POINT_DELTA)
+
+ assertRestingPoint(point, await mustRead(readCurrentPoint, 'Rhea'), selling)
+ const setup = await mustRead(() => readSetup(account, pair), 'Rhea')
+ const context = { sellToken: input.id, amount, point: String(point) }
+
+ if (setup.transactions.length > 0) {
+ const cost = fromBase(setup.owed, NEAR_DECIMALS)
+ return {
+ venueId: RHEA_VENUE_ID,
+ venueName: VENUE_NAME,
+ kind: 'limit',
+ summary: `Set up your account on Rhea for ${cost} NEAR of storage. You sign the order itself next, and nothing is offered until then.`,
+ pay: { amount: cost, symbol: 'NEAR' },
+ notes: NOTES,
+ signing: { family: 'near', transactions: setup.transactions },
+ context,
+ thenPrepareAgain: true,
+ }
+ }
+
+ const { record } = setup
+ if (
+ record !== null &&
+ record.cur_order_slots + record.cur_liquidity_slots + 1 > record.max_slots
+ ) {
+ refused('Your Rhea account has no free order slot. Cancel or collect an order first.')
+ }
+ const existing = await mustRead(
+ () =>
+ viewCall(DCL_CONTRACT, 'find_order', {
+ account_id: account,
+ pool_id: DCL_POOL,
+ point,
+ }),
+ 'Rhea'
+ )
+ if (existing !== null) {
+ refused('You already have a Rhea order at that price. Cancel it or name another price.')
+ }
+
+ const receive = formatUnits(received.toString(), output.decimals)
+ return {
+ venueId: RHEA_VENUE_ID,
+ venueName: VENUE_NAME,
+ kind: 'limit',
+ summary: `Offer ${request.amount} ${input.symbol} on Rhea for about ${receive} ${output.symbol}, at ${limitPrice} USDC per NEAR.`,
+ pay: { amount: request.amount, symbol: input.symbol },
+ receive: { amount: receive, symbol: output.symbol, approximate: true },
+ notes: NOTES,
+ signing: { family: 'near', transactions: [orderTransaction(pair, amount, point)] },
+ context,
+ }
+}
+
+function tokenOn(id: string): ChainToken {
+ const token = nearTokens.find((t) => t.id === id)
+ if (token === undefined) throw new Error(`${id} is not in the token table`)
+ return token
+}
+
+function waitingAmount(order: DclOrder): bigint {
+ return BigInt(order.unclaimed_amount ?? '0')
+}
+
+function restingOrder(order: DclOrder): RestingOrder {
+ const sell = tokenOn(order.sell_token)
+ const buy = tokenOn(order.buy_token)
+ const waiting = waitingAmount(order)
+ const open = BigInt(order.remain_amount) > BigInt(0)
+ const started = BigInt(order.bought_amount) > BigInt(0) || waiting > BigInt(0)
+ return {
+ id: order.order_id,
+ venueId: RHEA_VENUE_ID,
+ tokenIn: sell.symbol,
+ tokenOut: buy.symbol,
+ amountIn: formatUnits(order.original_amount, sell.decimals),
+ remainingIn: formatUnits(order.remain_amount, sell.decimals),
+ receivedOut: formatUnits(order.bought_amount, buy.decimals),
+ limitPrice: String(Number(priceAtPoint(order.point).toPrecision(6))),
+ status: !open ? 'filled' : started ? 'partly' : 'open',
+ ...(waiting > BigInt(0)
+ ? {
+ collectable: [
+ { amount: formatUnits(waiting.toString(), buy.decimals), symbol: buy.symbol },
+ ],
+ note: 'What has filled waits on Rhea until you press Collect.',
+ }
+ : {}),
+ }
+}
+
+async function openOrders(account: string, signal?: AbortSignal): Promise {
+ assertAccount(account)
+ const rows = z
+ .array(Order)
+ .parse(
+ await viewCall(DCL_CONTRACT, 'list_active_orders', { account_id: account }, signal)
+ )
+ return rows.filter((order) => order.pool_id === DCL_POOL).map(restingOrder)
+}
+
+async function prepareExit(
+ account: string,
+ orderId: string,
+ action: 'cancel' | 'collect'
+): Promise {
+ assertAccount(account)
+ if (!isDclOrderId(orderId)) refused('That is not a Rhea order I can change.')
+ const order = await mustRead(
+ async () =>
+ Order.nullable().parse(
+ await viewCall(DCL_CONTRACT, 'get_order', { order_id: orderId })
+ ),
+ 'that order'
+ )
+ if (order === null) refused('That order is no longer open.')
+ if (order.owner_id !== account) refused('That order belongs to another wallet.')
+ if (order.order_id !== orderId || order.pool_id !== DCL_POOL) {
+ refused('That is not a Rhea order I can change.')
+ }
+ const sell = tokenOn(order.sell_token)
+ const buy = tokenOn(order.buy_token)
+ const exit = (amount: string): PreparedAction['signing'] => ({
+ family: 'near',
+ transactions: [
+ {
+ receiverId: DCL_CONTRACT,
+ actions: [functionCall('cancel_order', { order_id: orderId, amount }, DCL_EXIT_GAS, '0')],
+ },
+ ],
+ })
+ const shared = {
+ venueId: RHEA_VENUE_ID,
+ venueName: VENUE_NAME,
+ kind: 'limit' as const,
+ context: { orderId },
+ }
+
+ if (action === 'collect') {
+ const waiting = waitingAmount(order)
+ if (waiting === BigInt(0)) refused('That order has nothing to collect yet.')
+ const amount = formatUnits(waiting.toString(), buy.decimals)
+ return {
+ ...shared,
+ summary: `Collect about ${amount} ${buy.symbol} from your Rhea order into your wallet.`,
+ pay: { amount: '0', symbol: sell.symbol },
+ receive: { amount, symbol: buy.symbol, approximate: true },
+ notes: ['Any part still open stays open.'],
+ signing: exit('0'),
+ }
+ }
+
+ if (order.remain_amount === '0') {
+ refused('That order has already filled, so there is nothing to cancel. Collect it instead.')
+ }
+ const remaining = formatUnits(order.remain_amount, sell.decimals)
+ return {
+ ...shared,
+ summary: `Cancel your Rhea order and get back about ${remaining} ${sell.symbol}, plus anything it has bought.`,
+ pay: { amount: '0', symbol: sell.symbol },
+ receive: { amount: remaining, symbol: sell.symbol, approximate: true },
+ notes: ['It could fill before this lands. Whatever filled comes back to your wallet too.'],
+ signing: exit(order.remain_amount),
+ }
+}
+
+export function rheaBook(): LimitBook {
+ return { openOrders, prepareExit }
+}
+
+function delay(ms: number): Promise {
+ return new Promise((resolve) => setTimeout(resolve, ms))
+}
+
+/**
+ * Whether the order is resting. A token transaction succeeds even when Rhea sends the tokens straight
+ * back, so its own status can't say so. A view reads at `final`, a moment behind the transaction.
+ */
+async function orderSeen(account: string, point: number): Promise {
+ const giveUpAt = Date.now() + ORDER_SEEN_WAIT_MS
+ while (Date.now() < giveUpAt) {
+ try {
+ const order = await viewCall(DCL_CONTRACT, 'find_order', {
+ account_id: account,
+ pool_id: DCL_POOL,
+ point,
+ })
+ if (order !== null) return true
+ } catch {
+ // A brief RPC failure means look again.
+ }
+ await delay(ORDER_SEEN_POLL_MS)
+ }
+ return false
+}
+
+/**
+ * Confirms what a NEAR wallet already sent. Each transaction must have run as this account and be one
+ * of this venue's own calls. What the calls did is read off the chain, and never from the browser.
+ */
+async function confirmRhea(input: SubmitInput): Promise {
+ const { account, signed } = input
+ if (signed.family !== 'near') return { ok: false, error: 'That is not a NEAR transaction.' }
+ const hash = signed.hashes[signed.hashes.length - 1]
+ if (hash === undefined || signed.hashes.some((h) => !NEAR_TX_HASH.test(h))) {
+ return {
+ ok: false,
+ pending: true,
+ error: 'The wallet did not return a transaction hash to follow.',
+ }
+ }
+
+ const giveUpAt = Date.now() + CONFIRM_WAIT_MS
+ let placed: number | undefined
+ for (const each of signed.hashes) {
+ const outcome = await waitForTransaction(each, account, Math.max(giveUpAt - Date.now(), 1))
+ if (outcome.state === 'unknown') {
+ return {
+ ok: false,
+ pending: true,
+ error: `Your transaction was sent (${each}) but hasn't been confirmed yet. Don't send it again. Check ${explorerUrl(each)} in a minute.`,
+ }
+ }
+ const read = readExecuted(outcome.receiver, outcome.calls)
+ if (outcome.signer !== account || !read.belongs) {
+ return {
+ ok: false,
+ pending: true,
+ error: `Transaction ${each} is not this account's order on Rhea. Check your wallet's activity.`,
+ }
+ }
+ if (outcome.state === 'failed') {
+ return read.point === undefined
+ ? {
+ ok: false,
+ error: `That failed on NEAR (${each}), so what it attached came back, apart from the gas. You can try again.`,
+ }
+ : {
+ ok: false,
+ pending: true,
+ error: `The order failed on NEAR (${each}). Check your wallet's activity before trying again.`,
+ }
+ }
+ placed = read.point ?? placed
+ }
+
+ if (placed !== undefined && !(await orderSeen(account, placed))) {
+ return {
+ ok: false,
+ pending: true,
+ error: `Your transaction went through (${hash}), but I can't see the order resting on Rhea. It may have filled at once, or your tokens may have come back, wrapped if you sold NEAR. Check your wallet before trying again.`,
+ }
+ }
+ return { ok: true, hash, explorerUrl: explorerUrl(hash) }
+}
+
+/** `limitCost` is the most first use comes to. `limitCostNow` is what the reserve goes by. */
+export const rheaVenue: ChainVenue = {
+ id: RHEA_VENUE_ID,
+ name: VENUE_NAME,
+ kinds: ['limit'],
+ prepare,
+ assertSafe: assertRheaDclSafe,
+ submit: confirmRhea,
+ limitCost: { amount: '0.5', symbol: 'NEAR' },
+ limitCostNow,
+}
diff --git a/apps/dapp/lib/chains/near/safety.ts b/apps/dapp/lib/chains/near/safety.ts
index dbe953a0..7639aa47 100644
--- a/apps/dapp/lib/chains/near/safety.ts
+++ b/apps/dapp/lib/chains/near/safety.ts
@@ -1,5 +1,15 @@
import { ChainActionRefused, type NearTransaction, type SigningPayload } from '../types'
import { NEAR_ACCOUNT } from './account'
+import {
+ DCL_CONTRACT,
+ DCL_EXIT_GAS,
+ DCL_ORDER_GAS,
+ DCL_POINT_DELTA,
+ DCL_STORAGE_CAP,
+ dclBuyToken,
+ dclOrderMsg,
+ isDclOrderId,
+} from './rhea-dcl-rules'
import { METAPOOL_CONTRACT, RNEAR_CONTRACT, TGAS, USDC_NEAR, USDT_NEAR, WRAP_NEAR } from './tokens'
/**
@@ -7,13 +17,16 @@ import { METAPOOL_CONTRACT, RNEAR_CONTRACT, TGAS, USDC_NEAR, USDT_NEAR, WRAP_NEA
*
* A NEAR wallet sends what it signs, so the server sees only hashes afterwards.
* These checks are the last server-side look before signing, and each one fails
- * closed. Anything not named here is refused, and that includes `ft_transfer_call`:
- * nothing in this app needs it, and it would run code on the receiving account.
+ * closed. Anything not named here is refused, and that includes `ft_transfer_call`,
+ * which runs code on the receiving account. It has one exception, `assertDclOrderSafe`:
+ * an order to `dclv2.ref-labs.near`, on one pinned pool, whose message is compared
+ * whole with the one this app built. Every other check here still refuses it.
*
* Bounds:
* - `ft_transfer` attaches exactly 1 yoctoNEAR, as NEP-141 requires.
- * - Storage attaches exactly 0.00125 NEAR, the `storage_balance_bounds` minimum
- * on every contract here.
+ * - Storage attaches exactly 0.00125 NEAR, the `storage_balance_bounds` minimum on
+ * every token here. Rhea's DCL contract is the exception: its minimum is 0.5 NEAR,
+ * and `assertDclOrderSafe` holds it to that as a cap.
* - Gas is limited per method below, and to 300 TGas for a whole transaction.
* - An exit attaches nothing but the 1 yoctoNEAR `intents.near` requires, and what it pays
* out goes to the signer: the staking contracts pay the caller, and a withdrawal
@@ -382,3 +395,163 @@ export function assertIntentsWithdrawalSafe(
assertStorageDeposit(registration, [account])
}
}
+
+export interface DclOrderExpected {
+ /** The NEP-141 contract the order sells: `wrap.near` or USDC. */
+ sellToken: string
+ /** Base units. */
+ amount: string
+ point: number
+}
+
+/** Storage for yourself, on Rhea, up to the cap. The amount is its own `storage_balance_bounds`. */
+function assertDclStorage(call: Call, account: string): void {
+ assertGas(call, STORAGE_GAS)
+ if (call.deposit === BigInt(0) || call.deposit > DCL_STORAGE_CAP) {
+ refuse('the storage payment on Rhea is outside what this app allows')
+ }
+ if (
+ !hasExactKeys(call.args, ['account_id', 'registration_only']) ||
+ call.args['account_id'] !== account ||
+ call.args['registration_only'] !== true
+ ) {
+ refuse('the storage payment on Rhea has unexpected arguments')
+ }
+}
+
+/** Storage only, each on its own transaction, in the order the order needs it and at most once. */
+function assertDclSetup(
+ transactions: (NearTransaction & { calls: Call[] })[],
+ account: string,
+ sellToken: string,
+ buyToken: string
+): void {
+ const allowed = [DCL_CONTRACT, buyToken, ...(sellToken === WRAP_NEAR ? [WRAP_NEAR] : [])]
+ let next = 0
+ for (const { receiverId, calls } of transactions) {
+ const at = allowed.indexOf(receiverId, next)
+ if (at === -1) refuse(`${receiverId} takes no storage payment for this order`)
+ next = at + 1
+ const [call] = calls
+ if (calls.length !== 1 || call?.method !== 'storage_deposit') {
+ refuse('a setup transaction does more than pay for storage')
+ }
+ if (receiverId === DCL_CONTRACT) assertDclStorage(call, account)
+ else assertStorageDeposit(call, [account])
+ }
+}
+
+/** [near_deposit, ft_transfer_call] on `wrap.near`, or [ft_transfer_call] on USDC. */
+function assertDclPlacement(
+ transaction: NearTransaction & { calls: Call[] },
+ expected: DclOrderExpected,
+ buyToken: string
+): void {
+ const { receiverId, calls } = transaction
+ const isWrapped = receiverId === WRAP_NEAR
+ if (receiverId !== expected.sellToken) refuse('the order sells a different token')
+ if (calls.length !== (isWrapped ? 2 : 1)) refuse('the order carries the wrong number of calls')
+
+ const transfer = calls[calls.length - 1] as Call
+ if (transfer.method !== 'ft_transfer_call') refuse('it does not end in the order')
+ assertGas(transfer, DCL_ORDER_GAS)
+ if (transfer.deposit !== BigInt(1)) refuse('the order does not attach exactly 1 yoctoNEAR')
+ if (!hasExactKeys(transfer.args, ['receiver_id', 'amount', 'msg'])) {
+ refuse('the order has unexpected arguments')
+ }
+ if (transfer.args['receiver_id'] !== DCL_CONTRACT) refuse('the order does not go to Rhea')
+
+ const amountText = transfer.args['amount']
+ if (amountText !== expected.amount || !isDigits(amountText)) {
+ refuse('the order is not for the amount that was asked')
+ }
+ const amount = BigInt(amountText)
+ if (amount === BigInt(0)) refuse('the order sells nothing')
+ // Whole text against whole text: pool, buy token, point, client id and any extra key all at once.
+ if (transfer.args['msg'] !== dclOrderMsg(buyToken, expected.point)) {
+ refuse('the order is not the one that was asked for')
+ }
+
+ if (!isWrapped) return
+ const wrap = calls[0] as Call
+ if (wrap.method !== 'near_deposit') refuse(`${wrap.method} is not allowed`)
+ assertGas(wrap, WRAP_GAS)
+ if (Object.keys(wrap.args).length !== 0) refuse('near_deposit has unexpected arguments')
+ if (wrap.deposit !== amount) refuse('the amount wrapped is not the amount sold')
+}
+
+/**
+ * The one place `ft_transfer_call` is allowed. A payload is either setup, storage and nothing else,
+ * or the order alone. Never both: a wallet given several transactions promises neither their order
+ * nor to stop after one fails, so an order signed beside its storage could land without it, and fail.
+ */
+export function assertDclOrderSafe(
+ payload: SigningPayload,
+ account: string,
+ expected: DclOrderExpected
+): void {
+ if (payload.family !== 'near') refuse('it is not a NEAR transaction')
+ if (!NEAR_ACCOUNT.test(account)) refuse('the account is not a NEAR account')
+ const buyToken = dclBuyToken(expected.sellToken)
+ if (buyToken === undefined) refuse(`${expected.sellToken} is not a token this order sells`)
+ if (!Number.isSafeInteger(expected.point) || expected.point % DCL_POINT_DELTA !== 0) {
+ refuse('the order has no usable price point')
+ }
+ const { transactions } = payload
+ if (!Array.isArray(transactions) || transactions.length === 0) {
+ refuse('it has no transactions')
+ }
+ const read = transactions.map(readTransaction)
+ if (read.some((t) => t.calls.some((c) => c.method !== 'storage_deposit'))) {
+ if (read.length !== 1) refuse('the order is not alone in what is being signed')
+ assertDclPlacement(read[0] as NearTransaction & { calls: Call[] }, expected, buyToken)
+ return
+ }
+ assertDclSetup(read, account, expected.sellToken, buyToken)
+}
+
+/** `cancel_order` on Rhea for this one order on the pinned pool, with no deposit. */
+export function assertDclExitSafe(payload: SigningPayload, account: string, orderId: string): void {
+ const transaction = readSingleTransaction(payload, account)
+ if (transaction.receiverId !== DCL_CONTRACT) refuse(`${transaction.receiverId} is not Rhea`)
+ if (!isDclOrderId(orderId)) refuse('that is not an order on the pool this app trades')
+ const [call] = transaction.calls
+ if (transaction.calls.length !== 1 || call?.method !== 'cancel_order') {
+ refuse('it does more than change one order')
+ }
+ assertGas(call, DCL_EXIT_GAS)
+ if (call.deposit !== BigInt(0)) refuse('changing an order should attach nothing')
+ if (!hasExactKeys(call.args, ['order_id', 'amount'])) {
+ refuse('changing an order has unexpected arguments')
+ }
+ if (call.args['order_id'] !== orderId) refuse('it changes a different order')
+ if (!isDigits(call.args['amount'])) refuse('changing an order has a malformed amount')
+}
+
+/**
+ * Rhea's `assertSafe`, holding a payload to what `prepare` issued. Only ever runs on what this server
+ * just built: NEAR wallets send for themselves, so the submit route has no bytes to bring back here.
+ */
+export function assertRheaDclSafe(
+ payload: SigningPayload,
+ account: string,
+ context?: Record | undefined
+): void {
+ const orderId = context?.['orderId']
+ if (orderId !== undefined) {
+ assertDclExitSafe(payload, account, orderId)
+ return
+ }
+ const sellToken = context?.['sellToken']
+ const amount = context?.['amount']
+ const point = context?.['point']
+ if (
+ sellToken === undefined ||
+ !isDigits(amount) ||
+ point === undefined ||
+ !/^-?\d+$/.test(point)
+ ) {
+ refuse('it does not say what it was built for')
+ }
+ assertDclOrderSafe(payload, account, { sellToken, amount, point: Number(point) })
+}
diff --git a/apps/dapp/lib/chains/near/venues.ts b/apps/dapp/lib/chains/near/venues.ts
index f14926cd..1c1fa5c3 100644
--- a/apps/dapp/lib/chains/near/venues.ts
+++ b/apps/dapp/lib/chains/near/venues.ts
@@ -33,6 +33,18 @@ export const nearVenues: Venue[] = [
url: 'https://docs.near-intents.org/integration/distribution-channels/1click-api/about-1click-api',
},
rheaDexListing,
+ {
+ id: 'rhea-limit',
+ family: 'near',
+ name: 'Rhea',
+ category: 'orderbook',
+ chains: NEAR,
+ bestFor: 'Selling or buying NEAR at a price you name, until it fills or you cancel it',
+ url: 'https://rhea.finance',
+ integration: 'executes',
+ capability:
+ 'Places, reads, cancels and collects NEAR orders against USDC, and checks every transaction before you sign. What fills waits on Rhea until you collect it. Mainnet only. No order has been signed through this app yet.',
+ },
{
id: 'rnear',
family: 'near',
diff --git a/apps/dapp/lib/chains/oneclick-origin.ts b/apps/dapp/lib/chains/oneclick-origin.ts
index 94ad3b84..6669984d 100644
--- a/apps/dapp/lib/chains/oneclick-origin.ts
+++ b/apps/dapp/lib/chains/oneclick-origin.ts
@@ -542,6 +542,13 @@ export function oneClickOriginVenue(chain: OneClickOriginChain): ChainVenue {
error: `${why} so it can't be followed from here. Your wallet may already have sent it${hash === undefined ? '' : ` as ${hash}`}, so check its activity before trying again.`,
}
}
+ // A signed message can't have moved anything, whatever wallet signed it.
+ if (signed.family === 'evm-typed') {
+ return {
+ ok: false,
+ error: 'That is a signed message, not a transaction, so nothing was sent.',
+ }
+ }
if (signed.family !== origin.family) {
return sends
? notFollowed(`That is not ${withArticle(origin.chainName)} transaction,`)
diff --git a/apps/dapp/lib/chains/orders-view.ts b/apps/dapp/lib/chains/orders-view.ts
new file mode 100644
index 00000000..1a3ab386
--- /dev/null
+++ b/apps/dapp/lib/chains/orders-view.ts
@@ -0,0 +1,107 @@
+import { limitPair, type RestingOrder } from './limit'
+
+export type OrderExit = 'cancel' | 'collect'
+
+export const GONE = 'That order is no longer open. Nothing was signed or sent.'
+
+const ORDERS_WORDS = /\b(?:my|open|limit)\s+(?:(?:open|limit)\s+)?orders?\b/i
+
+/**
+ * Whether the person is asking about their resting orders. A message with a figure in it is placing
+ * or changing something and not asking, and "order 5 SOL" or "in order to" are not about orders.
+ */
+export function ordersAsked(text: string): boolean {
+ return !/\d/.test(text) && ORDERS_WORDS.test(text)
+}
+
+/** What can be done with an order as listed. */
+export function exitsFor(order: RestingOrder): OrderExit[] {
+ const waiting = (order.collectable ?? []).length > 0
+ if (order.status === 'open') return ['cancel']
+ if (order.status === 'partly') return waiting ? ['cancel', 'collect'] : ['cancel']
+ if (order.status === 'filled') return ['collect']
+ return []
+}
+
+/**
+ * What a press means now, read again just before it builds. A cancel on an order that has since
+ * filled becomes a collect, and an order that is no longer listed, or has nothing left to do, is
+ * gone.
+ */
+export function freshExit(
+ listed: RestingOrder,
+ now: RestingOrder | undefined,
+ action: OrderExit
+): OrderExit | 'gone' {
+ if (now === undefined || now.id !== listed.id) return 'gone'
+ const exits = exitsFor(now)
+ if (exits.includes(action)) return action
+ if (action === 'cancel' && exits.includes('collect')) return 'collect'
+ return 'gone'
+}
+
+function scaled(text: string): { digits: bigint; places: number } | undefined {
+ if (!/^\d+(\.\d+)?$/.test(text)) return undefined
+ const [whole = '0', fraction = ''] = text.split('.')
+ return { digits: BigInt(`${whole}${fraction}`), places: fraction.length }
+}
+
+/** `a - b` as decimal text, or nothing when either is not a plain figure or b is the larger. */
+function minus(a: string, b: string): string | undefined {
+ const x = scaled(a)
+ const y = scaled(b)
+ if (x === undefined || y === undefined) return undefined
+ const places = Math.max(x.places, y.places)
+ const lift = (v: { digits: bigint; places: number }): bigint =>
+ v.digits * BigInt(10) ** BigInt(places - v.places)
+ const left = lift(x) - lift(y)
+ if (left < BigInt(0)) return undefined
+ const padded = left.toString().padStart(places + 1, '0')
+ const whole = padded.slice(0, padded.length - places)
+ const fraction = padded.slice(padded.length - places).replace(/0+$/, '')
+ return fraction === '' ? whole : `${whole}.${fraction}`
+}
+
+const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
+
+function dayOf(ms: number): string {
+ const date = new Date(ms)
+ return `${MONTHS[date.getUTCMonth()] ?? ''} ${date.getUTCDate()}`
+}
+
+/** One order as a line: what was ordered, how much has filled, and when it ends. */
+export function orderLine(order: RestingOrder): string {
+ const waiting = order.collectable ?? []
+ const lead =
+ waiting.length > 0
+ ? `${waiting.map((w) => `${w.amount} ${w.symbol}`).join(' and ')} ready to collect`
+ : ''
+ if (order.amountIn === '0' || order.amountIn === '') return lead
+
+ const pair = limitPair(order.tokenIn, order.tokenOut)
+ const price = order.limitPrice !== undefined ? ` at $${order.limitPrice}` : ''
+ const what =
+ pair === undefined || pair.selling
+ ? `Sell ${order.amountIn} ${order.tokenIn}${price}`
+ : `Buy ${pair.base} with ${order.amountIn} ${order.tokenIn}${price}`
+ const parts = [what]
+ const filled = minus(order.amountIn, order.remainingIn)
+ if (order.status === 'filled' || order.status === 'done') {
+ parts.push('filled')
+ } else if (filled !== undefined && filled !== '0') {
+ parts.push(`${filled}${pair !== undefined && !pair.selling ? ` ${order.tokenIn}` : ''} filled`)
+ }
+ if (order.expiresAt !== undefined && order.status !== 'filled' && order.status !== 'done') {
+ parts.push(`expires ${dayOf(order.expiresAt)}`)
+ }
+ const line = parts.join(', ')
+ return lead === '' ? line : `${lead}. ${line}`
+}
+
+/** What is said once a limit order is resting, and what is still to do for it. */
+export function limitSettledNote(chain: string): string {
+ const base = 'Your order is resting. Say "my orders" to see it, cancel it or collect from it.'
+ return chain === 'sui' || chain === 'near'
+ ? `${base} Collecting what it fills takes another signature.`
+ : base
+}
diff --git a/apps/dapp/lib/chains/parse-limit.ts b/apps/dapp/lib/chains/parse-limit.ts
new file mode 100644
index 00000000..1b07f8c5
--- /dev/null
+++ b/apps/dapp/lib/chains/parse-limit.ts
@@ -0,0 +1,192 @@
+import { limitPair } from './limit'
+import { AMOUNT, amountOf, chainTokenWord } from './parse-action'
+import {
+ canonicalNumber,
+ figureValue,
+ spacedTurns,
+ tokenPattern,
+ tokenSaid,
+ WORD_END,
+} from './sequence-flow'
+import type { ChainActionRequest } from './types'
+
+/** A count of tokens: a dollar figure here would be dollars, which a limit order does not take. */
+const COUNT = AMOUNT.replace(String.raw`\$?`, '')
+const TOKEN = String.raw`([a-z][a-z0-9.+]{0,11})`
+const AT = String.raw`(?:at|@|when\s+it\s+(?:hits|reaches)|if\s+it\s+(?:hits|reaches))\s*`
+const END = String.raw`\s*[.!]?\s*$`
+
+const SELL = new RegExp(
+ String.raw`^(?:please\s+)?(?:limit\s+)?sell\s+${COUNT}\s*${TOKEN}(?:\s+(?:for|to|into)\s+${TOKEN})?\s+${AT}${AMOUNT}${END}`,
+ 'i'
+)
+const SWAP = new RegExp(
+ String.raw`^(?:please\s+)?(?:limit\s+)?(?:swap|convert|exchange|trade|change)\s+${COUNT}\s*${TOKEN}\s+(?:to|for|into)\s+${TOKEN}\s+${AT}${AMOUNT}${END}`,
+ 'i'
+)
+const BUY_PAYING_FIRST = new RegExp(
+ String.raw`^(?:please\s+)?(?:limit\s+)?buy\s+${TOKEN}\s+(?:with|using)\s+${COUNT}\s*${TOKEN}\s+${AT}${AMOUNT}${END}`,
+ 'i'
+)
+const BUY_PRICE_FIRST = new RegExp(
+ String.raw`^(?:please\s+)?(?:limit\s+)?buy\s+${TOKEN}\s+${AT}${AMOUNT}\s+(?:with|using)\s+${COUNT}\s*${TOKEN}${END}`,
+ 'i'
+)
+
+function order(
+ amount: string,
+ given: string,
+ got: string,
+ price: string
+): ChainActionRequest | null {
+ const tokenIn = chainTokenWord(given)
+ const tokenOut = chainTokenWord(got)
+ if (limitPair(tokenIn, tokenOut) === undefined) return null
+ return {
+ kind: 'limit',
+ amount: amountOf(amount),
+ tokenIn,
+ tokenOut,
+ limitPrice: amountOf(price),
+ venue: undefined,
+ }
+}
+
+/**
+ * Reads "sell 1 SOL at $110" or "buy SOL with 50 USDC at 90" into a limit order, or nothing. A strict
+ * shape: the amount is a count of the token given up, the price is in a stablecoin, and anything
+ * else, such as an amount received, is left unread.
+ */
+export function parseChainLimit(text: string): ChainActionRequest | null {
+ const typed = text.trim()
+ const sell = SELL.exec(typed)
+ if (sell !== null) {
+ return order(sell[1] as string, sell[2] as string, sell[3] ?? 'USDC', sell[4] as string)
+ }
+ const swap = SWAP.exec(typed)
+ if (swap !== null) {
+ return order(swap[1] as string, swap[2] as string, swap[3] as string, swap[4] as string)
+ }
+ const paying = BUY_PAYING_FIRST.exec(typed)
+ if (paying !== null) {
+ return order(paying[2] as string, paying[3] as string, paying[1] as string, paying[4] as string)
+ }
+ const early = BUY_PRICE_FIRST.exec(typed)
+ if (early !== null) {
+ return order(early[3] as string, early[4] as string, early[1] as string, early[2] as string)
+ }
+ return null
+}
+
+const SHAPE_VERB = /^(?:please\s+)?(?:limit\s+)?(?:sell|buy|swap|convert|exchange|trade|change)\b/i
+const PRICE_AT_END = new RegExp(
+ String.raw`(?:\bat|@|\bwhen\s+it\s+(?:hits|reaches)|\bif\s+it\s+(?:hits|reaches))\s*\$?(\d[\d,]*(?:\.\d+)?)${END}`,
+ 'i'
+)
+const BUY_BY_COUNT = new RegExp(
+ String.raw`^(?:please\s+)?(?:limit\s+)?buy\s+${COUNT}\s*${TOKEN}\s+`,
+ 'i'
+)
+const SELL_BY_DOLLARS = new RegExp(
+ String.raw`^(?:please\s+)?(?:limit\s+)?sell\s+\$[\d,.]+\s*(?:of\s+)?${TOKEN}\b`,
+ 'i'
+)
+
+export const LIMIT_EXAMPLES = 'for example "sell 1 SOL at $110" or "buy SOL with 180 USDC at $90"'
+
+/**
+ * What to tell someone who gave a price but not an order the strict reader can take, such as a
+ * purchase sized in what it gets or a sale in dollars. Nothing for a message that names no price,
+ * so a plain swap is left alone.
+ */
+export function limitShapeRefusal(text: string): string | undefined {
+ const typed = text.trim()
+ const price = PRICE_AT_END.exec(typed)
+ if (!SHAPE_VERB.test(typed) || price === null) return undefined
+ const buy = BUY_BY_COUNT.exec(typed)
+ if (buy !== null) {
+ const symbol = chainTokenWord(buy[2] as string).toUpperCase()
+ return `Say how much you will spend, for example "buy ${symbol} with 180 USDC at $${price[1] as string}". Nothing was signed or sent.`
+ }
+ const sell = SELL_BY_DOLLARS.exec(typed)
+ if (sell !== null) {
+ const symbol = chainTokenWord(sell[1] as string).toUpperCase()
+ return `Say how much ${symbol}, for example "sell 0.05 ${symbol} at $110". Nothing was signed or sent.`
+ }
+ return `I could not read that as a limit order. Say it in full, ${LIMIT_EXAMPLES}. Nothing was signed or sent.`
+}
+
+const NUMBER = String.raw`(\d[\d,]*(?:\.\d+)?|\.\d+)(?![\w%]|[.,]\d)`
+
+/**
+ * Whether the person themselves said this amount of this token and this price. A model's text never
+ * sets what is signed. The amount is a count by its token, the price follows "at", "@" or "hits",
+ * and each must be the last said, so a correction wins. This does not go through
+ * `sequenceWordsMatch`, which refuses a count with a dollar figure after it, the very shape of a
+ * limit order. Pass what the person typed, not the model's lines.
+ */
+export function limitSaid(request: ChainActionRequest, turns: readonly string[]): boolean {
+ if (request.limitPrice === undefined) return false
+ const symbol = chainTokenWord(request.tokenIn)
+ const joined = spacedTurns(symbol, turns).join(' ')
+ const counted = new RegExp(
+ String.raw`(?:^|[^\w.,$₦£€])${NUMBER}\s*(?:of\s+)?(?:my\s+|the\s+)?(?:${tokenPattern(symbol).token})${WORD_END}`,
+ 'gi'
+ )
+ const priced = new RegExp(String.raw`(?:\b(?:at|hits?|reach(?:es)?)\s+|@\s*)\$?${NUMBER}`, 'gi')
+ const amount = [...joined.matchAll(counted)].at(-1)
+ const price = [...joined.matchAll(priced)].at(-1)
+ if (
+ !tokenSaid(request.tokenIn, turns) ||
+ amount === undefined ||
+ price === undefined ||
+ figureValue(amount[1] as string) !== canonicalNumber(request.amount) ||
+ figureValue(price[1] as string) !== canonicalNumber(request.limitPrice)
+ ) {
+ return false
+ }
+ return sideAndTokenOutSaid(request, joined, amount)
+}
+
+/**
+ * The side and the token received must be the person's too. Selling names the token received after
+ * the amount, except that USDC may go unnamed after "sell", the parser's own default. A purchase
+ * names it anywhere. "buy" is never a sale, and "sell" is never a purchase unless it is a swap.
+ */
+const GETTING_WORDS = /\b(?:get|want|grab|pick\s+up|buy|acquire|snag|load\s+up|i['’]d\s+like)\b/i
+
+function gettingSaid(joined: string): boolean {
+ return GETTING_WORDS.test(joined.replace(/\b(?:(?:want|need)\s+to\s+)?get\s+rid\s+of\b/gi, ' '))
+}
+
+const SELLING_WORDS = /\b(?:sell|get\s+rid\s+of|dump|unload|offload|cash\s+in)\b/i
+
+function sideAndTokenOutSaid(
+ request: ChainActionRequest,
+ joined: string,
+ amount: RegExpMatchArray
+): boolean {
+ const tokenOut = request.tokenOut
+ if (tokenOut === undefined) return false
+ const pair = limitPair(request.tokenIn, tokenOut)
+ if (pair === undefined) return false
+ const sell = /\bsell\b/i.test(joined)
+ const buy = /\bbuy\b/i.test(joined)
+ const swap = /\b(?:swap|convert|exchange|trade|change)\b/i.test(joined)
+ if (pair.selling ? buy && !sell : sell && !buy && !swap) return false
+ const end = (amount.index ?? 0) + amount[0].length
+ const after = joined.slice(end)
+ const named = pair.selling
+ ? tokenSaid(tokenOut, [after], true)
+ : tokenSaid(tokenOut, [joined], true)
+ // Selling without naming a stablecoin is for USDC, unless the person named the other one. It takes
+ // a word that means selling: "get me 1 SOL at 110" or "grab 1 SOL" are purchases.
+ const implied =
+ pair.selling &&
+ !buy &&
+ SELLING_WORDS.test(joined) &&
+ !gettingSaid(joined) &&
+ chainTokenWord(tokenOut).toUpperCase() === 'USDC' &&
+ !tokenSaid('USDT', [joined], true)
+ return named || implied
+}
diff --git a/apps/dapp/lib/chains/parse-sequence.ts b/apps/dapp/lib/chains/parse-sequence.ts
index fc57e860..84152aed 100644
--- a/apps/dapp/lib/chains/parse-sequence.ts
+++ b/apps/dapp/lib/chains/parse-sequence.ts
@@ -5,6 +5,9 @@ import {
routeCashout,
type CashoutRoute,
} from '../weave/parse-cashout'
+import { LIMIT_NOT_HERE } from './limit'
+import { ordersAsked } from './orders-view'
+import { LIMIT_EXAMPLES, limitSaid, limitShapeRefusal, parseChainLimit } from './parse-limit'
import { AMOUNT, amountOf, chainTokenWord, dollarsOn, parseChainAction } from './parse-action'
import {
SEQUENCE_CASHOUT_CHAINS,
@@ -112,6 +115,8 @@ export type ChainMessageRead =
| { kind: 'refusal'; refusal: string }
| { kind: 'sequence'; request: ChainSequenceRequest }
| { kind: 'action'; action: ChainActionRequest }
+ | { kind: 'limit'; action: ChainActionRequest }
+ | { kind: 'orders' }
| { kind: 'cashout'; route: CashoutGoes }
| { kind: 'model' }
@@ -161,6 +166,19 @@ export function readChainMessage(
}
const currency = swapIntoCurrency(text, chain, chainName)
if (currency !== undefined) return currency
+ // Before the plain parser, which would read "swap 50 USDC to SOL at 90" as a swap on a venue "90".
+ const notHere = LIMIT_NOT_HERE[chain]
+ const limit = parseChainLimit(text)
+ if (limit !== null) {
+ return notHere === undefined
+ ? { kind: 'limit', action: limit }
+ : { kind: 'refusal', refusal: notHere }
+ }
+ const shape = limitShapeRefusal(text)
+ if (shape !== undefined) return { kind: 'refusal', refusal: notHere ?? shape }
+ if (ordersAsked(text)) {
+ return notHere === undefined ? { kind: 'orders' } : { kind: 'refusal', refusal: notHere }
+ }
const action = parseChainAction(text)
return action === null ? { kind: 'model' } : { kind: 'action', action }
}
@@ -178,7 +196,7 @@ export function messageStartsAction(route: CashoutRoute, read: ChainMessageRead
if (cashoutStarts(route)) return true
if (read === null) return false
if (read.kind === 'cashout') return cashoutStarts(read.route)
- return read.kind === 'sequence' || read.kind === 'action'
+ return read.kind === 'sequence' || read.kind === 'action' || read.kind === 'limit'
}
/**
@@ -195,6 +213,8 @@ export interface ReadSwap {
followOn: { kind: string; venue: string; venueNamed?: true; recipient?: string } | null
/** A follow-on the server read and this chain can't honour. It must not become a plain swap. */
followOnRefused?: { kind: 'lend' | 'offramp'; venue: string } | undefined
+ /** How the model read the order: a `limit` must never run as a market swap. */
+ style?: string | undefined
}
const EXAMPLE_AFTER: Record = {
@@ -413,6 +433,34 @@ export type ChainRoute =
| { action: ChainActionRequest }
| { refusal: string }
+/** A limit order a model read, held to the person's words, or why not. Nothing when it is no limit. */
+function limitRouteFromReading(
+ read: ReadSwap,
+ instruction: string,
+ said: readonly string[],
+ chain: ChainWithActions | undefined
+): ChainRoute | undefined {
+ const limit = parseChainLimit(instruction)
+ const notHere = chain === undefined ? undefined : LIMIT_NOT_HERE[chain]
+ if (limit !== null) {
+ if (notHere !== undefined) return { refusal: notHere }
+ if (limitSaid(limit, said)) return { action: limit }
+ return {
+ refusal: `I could not find that amount, token and price in what you typed, and only your own words decide what is signed. Say it in full, ${LIMIT_EXAMPLES}. Nothing was signed or sent.`,
+ }
+ }
+ const shape = limitShapeRefusal(instruction)
+ if (shape !== undefined) return { refusal: notHere ?? shape }
+ if (read.style === 'limit') {
+ return {
+ refusal:
+ notHere ??
+ `I could not read that as a limit order. Say it in full, ${LIMIT_EXAMPLES}. Nothing was signed or sent.`,
+ }
+ }
+ return undefined
+}
+
/**
* Where an orchestrator reading goes off Stellar, so it never reaches Stellar's swap card.
*
@@ -426,8 +474,11 @@ export type ChainRoute =
export function chainRouteFromReading(
read: ReadSwap & { action: string },
instruction: string,
- said: readonly string[]
+ said: readonly string[],
+ chain?: ChainWithActions
): ChainRoute | null {
+ const limit = limitRouteFromReading(read, instruction, said, chain)
+ if (limit !== undefined) return limit
if (read.followOn !== null || read.followOnRefused !== undefined) {
const out = sequenceFromReading(read, said)
return out === null ? null : 'refusal' in out ? out : { sequence: out }
diff --git a/apps/dapp/lib/chains/prepare.ts b/apps/dapp/lib/chains/prepare.ts
index 98ad63e9..7a43c531 100644
--- a/apps/dapp/lib/chains/prepare.ts
+++ b/apps/dapp/lib/chains/prepare.ts
@@ -1,5 +1,6 @@
import { CHAIN_DESCRIPTORS } from '@intent/config'
+import { assertRestingPrice, LIMIT_NOT_HERE, limitPair } from './limit'
import { resolveRecipient, withResolvedName, type ResolvedName } from './names/resolve'
import { WRAP_NEAR } from './near/tokens'
import { fromBase, toBase } from './sequence'
@@ -87,7 +88,7 @@ export function assertKnownTokens(chainApi: ChainModule, request: ChainActionReq
`I can't confirm ${request.tokenIn} as a verified token on this chain, so I won't touch it.${hintFor(request.tokenIn)}`
)
}
- if (request.kind === 'swap') {
+ if (request.kind === 'swap' || request.kind === 'limit') {
if (
request.tokenOut === undefined ||
tokenBySymbol(chainApi.tokens, request.tokenOut) === undefined
@@ -139,6 +140,7 @@ const SPENDS_FROM_WALLET: ReadonlySet = new Set([
'repay',
'stream',
'pay_invoice',
+ 'limit',
])
/**
@@ -187,6 +189,68 @@ export async function assertFollowOnCanRun(
await venue.assertCanFollow(step, account)
}
+export { LIMIT_NOT_HERE }
+
+/**
+ * Refuses a limit order that cannot run here, before any venue is asked: no safe venue on the chain,
+ * a pair with no stablecoin, no venue, no live market price, or a price that would fill at once.
+ */
+export async function assertLimitCanRun(
+ chainApi: ChainModule,
+ request: ChainActionRequest,
+ account: string
+): Promise {
+ const notHere = LIMIT_NOT_HERE[chainApi.chain]
+ if (notHere !== undefined) throw new ChainActionRefused(notHere)
+ const pair = limitPair(request.tokenIn, request.tokenOut ?? '')
+ if (pair === undefined) {
+ throw new ChainActionRefused(
+ 'Limit orders here need USDC or USDT on one side. Nothing was signed or sent.'
+ )
+ }
+ pickVenue(chainApi, request)
+ const base = tokenBySymbol(chainApi.tokens, pair.base)
+ const market = base === undefined ? undefined : await chainApi.priceUsd(base)
+ if (market === undefined || !Number.isFinite(market) || !(market > 0)) {
+ throw new ChainActionRefused(
+ `I have no live price for ${pair.base}, so I can't check that price. Nothing was signed or sent.`
+ )
+ }
+ assertRestingPrice(request.limitPrice ?? '', market, pair.selling, pair.base)
+ await assertHeldForLimit(chainApi, request, account)
+}
+
+/**
+ * Refuses an order for more than the wallet holds of what it gives up, matched on the token's id,
+ * before a venue can ask for any setup. NEAR's table entry is `wrap.near` and stands for the native
+ * coin too, so a sale there counts both. A read that fails or runs late doesn't refuse.
+ */
+async function assertHeldForLimit(
+ chainApi: ChainModule,
+ request: ChainActionRequest,
+ account: string
+): Promise {
+ const tokenIn = tokenBySymbol(chainApi.tokens, request.tokenIn)
+ if (tokenIn === undefined) return
+ const reading = await readBalancesBriefly(chainApi, account)
+ if (reading === undefined) return
+ const amount = holdingOf(reading, tokenIn)
+ if (amount === undefined) return
+ const native =
+ chainApi.chain === 'near' && tokenIn.id === WRAP_NEAR
+ ? reading.tokens.find((t) => t.native)
+ : undefined
+ const token = toBase(amount, tokenIn.decimals)
+ const coin = native === undefined ? BigInt(0) : toBase(native.amount, tokenIn.decimals)
+ const asked = toBase(request.amount, tokenIn.decimals)
+ if (token === undefined || coin === undefined || asked === undefined) return
+ const held = token + coin
+ if (asked <= held) return
+ throw new ChainActionRefused(
+ `You hold ${fromBase(held, tokenIn.decimals)} ${tokenIn.symbol}, and this order needs ${request.amount}. Nothing was signed or sent.`
+ )
+}
+
/**
* Prepares an action, with the same order of checks as every other money route:
* known tokens, a real amount, then the build, then a check of what the venue's
@@ -213,6 +277,7 @@ export async function prepareChainAction(
const exit = SLOT_OF[asked.kind] !== undefined
const exitVenue = exit ? await pickExitVenue(chainApi, asked, account) : undefined
const request = exitVenue === undefined ? asked : await withWholeAmount(exitVenue, asked, account)
+ if (request.kind === 'limit') await assertLimitCanRun(chainApi, request, account)
await assertNativeReserve(chainApi, request, account)
await assertHeldForSend(chainApi, request, tokenIn, account)
@@ -442,6 +507,28 @@ async function assertHeldForSend(
)
}
+async function limitCostOf(
+ chainApi: ChainModule,
+ request: ChainActionRequest,
+ account: string
+): Promise {
+ let timer: ReturnType | undefined
+ try {
+ const venue = pickVenue(chainApi, request)
+ if (venue.limitCostNow === undefined) return venue.limitCost
+ return await Promise.race([
+ venue.limitCostNow(request, account),
+ new Promise((resolve) => {
+ timer = setTimeout(() => resolve(undefined), RESERVE_READ_MS)
+ }),
+ ])
+ } catch {
+ return undefined
+ } finally {
+ if (timer !== undefined) clearTimeout(timer)
+ }
+}
+
/**
* Refuses spending a wallet's whole native balance, which would leave nothing for the fee.
* Reads the balance once and compares in base units. A read that fails or runs late passes:
@@ -460,7 +547,10 @@ export async function assertNativeReserve(
if (!SPENDS_FROM_WALLET.has(request.kind)) return
const tokenIn = tokenBySymbol(chainApi.tokens, request.tokenIn)
if (tokenIn === undefined) return
- if (tokenIn.symbol.toUpperCase() !== NATIVE_SYMBOL[chainApi.chain]) return
+ const cost = request.kind === 'limit' ? await limitCostOf(chainApi, request, account) : undefined
+ const costed = cost !== undefined && cost.symbol.toUpperCase() === NATIVE_SYMBOL[chainApi.chain]
+ const sellsNative = tokenIn.symbol.toUpperCase() === NATIVE_SYMBOL[chainApi.chain]
+ if (!sellsNative && !costed) return
const reading = await readBalancesBriefly(chainApi, account)
if (reading === undefined) return
@@ -469,18 +559,31 @@ export async function assertNativeReserve(
if (native === undefined) return
const isNative =
tokenIn.id === native.id || (chainApi.chain === 'near' && tokenIn.id === WRAP_NEAR)
- if (!isNative) return
+ const spends = sellsNative && isNative
+ if (!spends && !costed) return
const held = toBase(native.amount, native.decimals)
const reserve = toBase(NATIVE_FEE_RESERVE[chainApi.chain], native.decimals)
- const asked = toBase(request.amount, native.decimals)
+ const asked = spends ? toBase(request.amount, native.decimals) : BigInt(0)
if (held === undefined || reserve === undefined || asked === undefined) return
- if (asked <= held - reserve) return
- const spendable = held > reserve ? held - reserve : BigInt(0)
+ let kept = reserve
+ let keepText = `${NATIVE_FEE_RESERVE[chainApi.chain]} ${native.symbol} for network fees`
+ if (costed) {
+ const costBase = toBase(cost.amount, native.decimals)
+ if (costBase !== undefined) {
+ kept = reserve + costBase
+ keepText = `${fromBase(kept, native.decimals)} ${native.symbol} for network fees and the ${cost.amount} ${cost.symbol} this order costs to set up`
+ }
+ }
+ if (asked <= held - kept) return
+
+ const spendable = held > kept ? held - kept : BigInt(0)
throw new ChainActionRefused(
- `Keep about ${NATIVE_FEE_RESERVE[chainApi.chain]} ${native.symbol} for network fees. ` +
- `You can spend up to ${fromBase(spendable, native.decimals)} ${native.symbol} here. ` +
+ `Keep about ${keepText}. ` +
+ (spends
+ ? `You can spend up to ${fromBase(spendable, native.decimals)} ${native.symbol} here. `
+ : `You hold ${native.amount} ${native.symbol}. `) +
'Nothing was signed or sent.'
)
}
diff --git a/apps/dapp/lib/chains/send.ts b/apps/dapp/lib/chains/send.ts
index c5bd4be5..9dd03e20 100644
--- a/apps/dapp/lib/chains/send.ts
+++ b/apps/dapp/lib/chains/send.ts
@@ -343,6 +343,13 @@ export function createSendVenue(chain: ChainWithActions): ChainVenue {
}
}
+ // A signed message can't have moved anything, whatever wallet signed it.
+ if (signed.family === 'evm-typed') {
+ return {
+ ok: false,
+ error: 'That is a signed message, not a transaction, so nothing was sent.',
+ }
+ }
if (signed.family !== mover.family) {
return sends ? unconfirmed() : { ok: false, error: NOT_THE_TRANSFER }
}
diff --git a/apps/dapp/lib/chains/sequence-flow.ts b/apps/dapp/lib/chains/sequence-flow.ts
index d86a485a..42da2515 100644
--- a/apps/dapp/lib/chains/sequence-flow.ts
+++ b/apps/dapp/lib/chains/sequence-flow.ts
@@ -762,13 +762,13 @@ const BARE_FIGURE = /^\s*(?:$|[,.!?;:)]|of\s+(?:it|that|this|them)\b|please\b)/i
* is a decimal point, and dropping it would read many times the amount, while reading it as a point
* is a guess, so neither is the amount. See `AMOUNT`.
*/
-function figureValue(text: string): string | undefined {
+export function figureValue(text: string): string | undefined {
const plain = text.replace(/,+$/, '')
if (plain.includes(',') && !/^[1-9]\d{0,2}(?:,\d{3})+(?:\.\d+)?$/.test(plain)) return undefined
return canonicalNumber(plain)
}
-function canonicalNumber(text: string): string {
+export function canonicalNumber(text: string): string {
const [whole = '', fraction = ''] = text.replace(/,/g, '').split('.')
const trimmed = fraction.replace(/0+$/, '')
const digits = whole.replace(/^0+(?=\d)/, '') || '0'
@@ -898,19 +898,19 @@ export function cashoutSaid(
: sequenceWordsMatch(route, turns)
}
-function tokenPattern(symbol: string): { token: string; names: string[] } {
+export function tokenPattern(symbol: string): { token: string; names: string[] } {
const names = chainNamesOf(symbol)
const token = [symbol, ...names].map((t) => t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('|')
return { token, names }
}
/** "5sol" read as "5 sol", and "$ 5" as "$5". */
-function spacedTurns(symbol: string, turns: readonly string[]): string[] {
+export function spacedTurns(symbol: string, turns: readonly string[]): string[] {
const glued = new RegExp(String.raw`(\d)(${tokenPattern(symbol).token})${WORD_END}`, 'gi')
return turns.map((turn) => turn.replace(glued, '$1 $2').replace(/([$₦£€])\s+/g, '$1'))
}
-const WORD_END = String.raw`(?![A-Za-z0-9+]|\.[A-Za-z0-9])`
+export const WORD_END = String.raw`(?![A-Za-z0-9+]|\.[A-Za-z0-9])`
/**
* Whether the person themselves named this token, alone as a word. A chain's name counts as its
diff --git a/apps/dapp/lib/chains/signing-flow.ts b/apps/dapp/lib/chains/signing-flow.ts
index 5786b161..1ae9b8c1 100644
--- a/apps/dapp/lib/chains/signing-flow.ts
+++ b/apps/dapp/lib/chains/signing-flow.ts
@@ -1,5 +1,11 @@
import { RACE_HEADER } from '../api/network-fetch'
-import type { PreparedAction, SignedOutcome, SignedPayload, SigningPayload } from './types'
+import type {
+ ChainActionRequest,
+ PreparedAction,
+ SignedOutcome,
+ SignedPayload,
+ SigningPayload,
+} from './types'
import {
CHECK_WALLET,
accountMismatch,
@@ -132,6 +138,8 @@ export interface SignAttempt {
amount: string
at: number
hash?: string
+ /** An exit from a resting order, as `action:orderId`. Its amount is 0, so this tells exits apart. */
+ exit?: string
}
export type AttemptStorage = Pick
@@ -158,7 +166,8 @@ function isAttempt(value: unknown): value is SignAttempt {
typeof a.tokenIn === 'string' &&
typeof a.amount === 'string' &&
typeof a.at === 'number' &&
- (a.hash === undefined || typeof a.hash === 'string')
+ (a.hash === undefined || typeof a.hash === 'string') &&
+ (a.exit === undefined || typeof a.exit === 'string')
)
}
@@ -192,7 +201,8 @@ function sameAttempt(a: SignAttempt, b: SignAttempt): boolean {
a.chain === b.chain &&
a.account === b.account &&
a.tokenIn === b.tokenIn &&
- a.amount === b.amount
+ a.amount === b.amount &&
+ a.exit === b.exit
)
}
@@ -274,7 +284,7 @@ function sameAmount(a: string, b: string): boolean {
/** The newest attempt at this same action inside the window, if there is one. */
export function earlierAttempt(
storage: AttemptStorage | undefined,
- asked: Pick,
+ asked: Pick & { exit?: string },
now: number = Date.now()
): SignAttempt | undefined {
const matches = readAttempts(storage, now).filter(
@@ -282,7 +292,8 @@ export function earlierAttempt(
a.chain === asked.chain &&
accountMismatch(a.account, asked.account) === undefined &&
a.tokenIn.toUpperCase() === asked.tokenIn.toUpperCase() &&
- sameAmount(a.amount, asked.amount)
+ a.exit === asked.exit &&
+ (asked.exit !== undefined || sameAmount(a.amount, asked.amount))
)
return matches[matches.length - 1]
}
@@ -312,3 +323,20 @@ export function attemptNotice(attempt: SignAttempt, now: number = Date.now()): A
...(explorerUrl !== undefined ? { explorerUrl } : {}),
}
}
+
+/** What a card was built from, so a setup step can build the next one from the same thing. */
+export type ActionSource =
+ | { kind: 'request'; request: ChainActionRequest; asked: string }
+ | { kind: 'exit'; orderId: string; action: 'cancel' | 'collect'; asked: string }
+
+/**
+ * The source to build again once a setup step has settled, however many rounds there are, and
+ * nothing while a build is under way or when the card was not a setup step.
+ */
+export function continueSource(
+ phase: string,
+ prepared: Pick | undefined,
+ source: ActionSource | undefined
+): ActionSource | undefined {
+ return phase === 'settled' && prepared?.thenPrepareAgain === true ? source : undefined
+}
diff --git a/apps/dapp/lib/chains/solana/__fixtures__/limo-order.json b/apps/dapp/lib/chains/solana/__fixtures__/limo-order.json
new file mode 100644
index 00000000..84bfe993
--- /dev/null
+++ b/apps/dapp/lib/chains/solana/__fixtures__/limo-order.json
@@ -0,0 +1,6 @@
+{
+ "address": "1Cphp42FuCRk9sUUYveKAPU8qm9jtjMyZfUSLob1SXw",
+ "lamports": 3841920,
+ "owner": "LiMoM9rMhrdYrfzUCxQppvxCSG1FcrUK9G8uLq4A1GF",
+ "data": "hq3fuU1WHDMMQpyFDVNgrF1W4FnKY5vb2djszgtD5ZGktSwlzO1dag3PEJa09B1K425qC0ZMyQvgdW03Z9/g5rg/zdIHpVDsBpuIV/6rgYT7aH9jRhjANdrEOdwa6ztVmKDwAAAAAAEG3fbh12Whk9nL4UbO63msHLSF7V9bN5E6jPWFfv8Aqcb6evO+2606PWXzaqvJdDGxu+TC0vbg5HymAgNFL11hBt324ddloZPZy+FGzut5rBy0he1fWzeROoz1hX7/AKkAwusLAAAAANytOAIAAAAAAAAAAAAAAADcrTgCAAAAAB5fAAAAAAAAAQAAAAAAAAAAAfwAAAAAAFEMBGkAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=="
+}
diff --git a/apps/dapp/lib/chains/solana/assert-safe.ts b/apps/dapp/lib/chains/solana/assert-safe.ts
index dc50bf2d..ddc33490 100644
--- a/apps/dapp/lib/chains/solana/assert-safe.ts
+++ b/apps/dapp/lib/chains/solana/assert-safe.ts
@@ -36,6 +36,11 @@ export interface SolanaVenuePolicy {
programs: Readonly>
/** Whether wrapping SOL (a transfer to the account's own wrapped SOL account) is expected. */
mayWrapSol: boolean
+ /**
+ * The one extra signer a venue may ask for: a brand new account this same transaction creates,
+ * paid for by the wallet, at exactly this size and rent, and owned by this program.
+ */
+ createsSigner?: { owner: string; space: bigint; lamports: bigint }
/** Whether closing an empty token account of the person's, rent back to them, is expected. */
mayCloseOwnAccounts?: boolean
}
@@ -119,28 +124,31 @@ export function decodeSolanaTransaction(base64: string): DecodedSolanaTransactio
}
/**
- * The owner's token account for a mint. Written out rather than taken from the
- * kit's async derivation because `assertSafe` is synchronous by contract.
+ * A program's derived address. Written out rather than taken from the kit's async
+ * derivation because `assertSafe` is synchronous by contract.
*/
-export function associatedTokenAccount(owner: string, mint: string): string {
- const encoder = getAddressEncoder()
+export function programAddress(seeds: readonly Uint8Array[], program: string): string {
const decoder = getAddressDecoder()
- const seeds = [
- encoder.encode(owner as Address),
- encoder.encode(TOKEN_PROGRAM as Address),
- encoder.encode(mint as Address),
- ]
- const program = encoder.encode(ASSOCIATED_TOKEN_PROGRAM as Address)
+ const programBytes = Uint8Array.from(getAddressEncoder().encode(program as Address))
for (let bump = 255; bump >= 0; bump -= 1) {
const hash = createHash('sha256')
- for (const seed of seeds) hash.update(Uint8Array.from(seed))
+ for (const seed of seeds) hash.update(seed)
hash.update(Uint8Array.of(bump))
- hash.update(Uint8Array.from(program))
+ hash.update(programBytes)
hash.update('ProgramDerivedAddress')
const candidate = decoder.decode(hash.digest())
if (isOffCurveAddress(candidate)) return candidate
}
- throw new Error('No program address for this owner.')
+ throw new Error('No program address for these seeds.')
+}
+
+/** The owner's token account for a mint. */
+export function associatedTokenAccount(owner: string, mint: string): string {
+ const encoder = getAddressEncoder()
+ return programAddress(
+ [owner, TOKEN_PROGRAM, mint].map((part) => Uint8Array.from(encoder.encode(part as Address))),
+ ASSOCIATED_TOKEN_PROGRAM
+ )
}
export function ownWrappedSolAccount(owner: string): string {
@@ -185,6 +193,30 @@ function checkComputeBudget(instructions: readonly DecodedInstruction[]): void {
}
}
+/** Whether this is the policy's one permitted account creation, paid for by the wallet. */
+function createsExtraSigner(
+ transaction: DecodedSolanaTransaction,
+ instruction: DecodedInstruction,
+ account: string,
+ policy: SolanaVenuePolicy,
+ extraSigner: string | undefined
+): boolean {
+ const { data } = instruction
+ const allowed = policy.createsSigner
+ return (
+ allowed !== undefined &&
+ extraSigner !== undefined &&
+ data.length === 52 &&
+ readLittleEndian(data, 0, 4) === BigInt(0) &&
+ instruction.accounts.length === 2 &&
+ staticAccount(transaction, instruction, 0) === account &&
+ staticAccount(transaction, instruction, 1) === extraSigner &&
+ readLittleEndian(data, 4, 8) === allowed.lamports &&
+ readLittleEndian(data, 12, 8) === allowed.space &&
+ getAddressDecoder().decode(data.subarray(20)) === allowed.owner
+ )
+}
+
function checkSystem(
transaction: DecodedSolanaTransaction,
instruction: DecodedInstruction,
@@ -262,9 +294,12 @@ export function solanaAssertSafe(policy: SolanaVenuePolicy) {
if (transaction.feePayer !== account) {
refuse(`This transaction’s network fee would not be paid by your wallet, ${account}.`)
}
- if (transaction.signerCount !== 1 || transaction.readonlySignerCount !== 0) {
+ const mayHaveExtra = policy.createsSigner !== undefined && transaction.signerCount === 2
+ if ((transaction.signerCount !== 1 && !mayHaveExtra) || transaction.readonlySignerCount !== 0) {
refuse('This transaction needs a signature from someone other than you.')
}
+ const extraSigner = mayHaveExtra ? transaction.staticAccounts[1] : undefined
+ let created = 0
const budget: DecodedInstruction[] = []
for (const instruction of transaction.instructions) {
@@ -273,8 +308,14 @@ export function solanaAssertSafe(policy: SolanaVenuePolicy) {
refuse('This transaction calls a program hidden in a lookup table.')
}
if (program === COMPUTE_BUDGET_PROGRAM) budget.push(instruction)
- else if (program === SYSTEM_PROGRAM) checkSystem(transaction, instruction, account, policy)
- else if (program === TOKEN_PROGRAM) checkToken(transaction, instruction, account, policy)
+ else if (
+ program === SYSTEM_PROGRAM &&
+ createsExtraSigner(transaction, instruction, account, policy, extraSigner)
+ ) {
+ created += 1
+ } else if (program === SYSTEM_PROGRAM) {
+ checkSystem(transaction, instruction, account, policy)
+ } else if (program === TOKEN_PROGRAM) checkToken(transaction, instruction, account, policy)
else if (program === ASSOCIATED_TOKEN_PROGRAM) {
checkAssociatedToken(transaction, instruction, account)
} else {
@@ -289,6 +330,9 @@ export function solanaAssertSafe(policy: SolanaVenuePolicy) {
}
}
}
+ if (mayHaveExtra && created !== 1) {
+ refuse('This transaction needs a signature from someone other than you.')
+ }
checkComputeBudget(budget)
}
}
diff --git a/apps/dapp/lib/chains/solana/balances.ts b/apps/dapp/lib/chains/solana/balances.ts
index c85fce48..bb3dc8b3 100644
--- a/apps/dapp/lib/chains/solana/balances.ts
+++ b/apps/dapp/lib/chains/solana/balances.ts
@@ -18,7 +18,11 @@ const SOL = SOLANA_TOKENS[0]!
/** The node answered, but refused the call. Anything else, a timeout included, is not caught here. */
class RpcRefused extends Error {}
-async function rpc(method: string, params: unknown[], signal: AbortSignal): Promise {
+export async function rpc(
+ method: string,
+ params: unknown[],
+ signal: AbortSignal
+): Promise {
const url = process.env['SOLANA_RPC_URL']?.trim() || DEFAULT_RPC_URL
const response = await fetch(url, {
method: 'POST',
diff --git a/apps/dapp/lib/chains/solana/capabilities.ts b/apps/dapp/lib/chains/solana/capabilities.ts
index 12da2b2d..4a358cdd 100644
--- a/apps/dapp/lib/chains/solana/capabilities.ts
+++ b/apps/dapp/lib/chains/solana/capabilities.ts
@@ -11,4 +11,5 @@ export const solanaCapabilities: ChainCapability[] = [
{ kind: 'stake', venue: 'Jito', symbol: 'SOL' },
{ kind: 'unstake', venue: 'Jito', symbol: 'JitoSOL' },
{ kind: 'claim', venue: 'Solayer', symbol: 'SOL' },
+ { kind: 'limit', venue: 'Kamino', symbol: 'SOL' },
]
diff --git a/apps/dapp/lib/chains/solana/limo-rules.ts b/apps/dapp/lib/chains/solana/limo-rules.ts
new file mode 100644
index 00000000..9fd0fdd7
--- /dev/null
+++ b/apps/dapp/lib/chains/solana/limo-rules.ts
@@ -0,0 +1,549 @@
+import { createPublicKey, verify } from 'node:crypto'
+
+import {
+ AccountRole,
+ getAddressDecoder,
+ getAddressEncoder,
+ getTransactionDecoder,
+ getU64Decoder,
+ getU64Encoder,
+ type AccountMeta,
+ type Address,
+ type Instruction,
+} from '@solana/kit'
+
+import { bytesFromBase64 } from '../base64'
+import { limitPair } from '../limit'
+import { ChainActionRefused, tokenBySymbol, type ChainToken, type SigningPayload } from '../types'
+import {
+ ASSOCIATED_TOKEN_PROGRAM,
+ associatedTokenAccount,
+ decodeSolanaTransaction,
+ ownWrappedSolAccount,
+ programAddress,
+ solanaAssertSafe,
+ SYSTEM_PROGRAM,
+ TOKEN_PROGRAM,
+ type DecodedSolanaTransaction,
+} from './assert-safe'
+import {
+ LIMO_GLOBAL_CONFIG,
+ LIMO_ORDER_BYTES,
+ LIMO_ORDER_RENT,
+ LIMO_POLICY,
+ LIMO_PROGRAM,
+} from './programs'
+import { SOLANA_TOKENS } from './tokens'
+
+const COMPUTE_BUDGET_PROGRAM = 'ComputeBudget111111111111111111111111111111'
+const WRAPPED_SOL = 'So11111111111111111111111111111111111111112'
+const MAX_U64 = BigInt('18446744073709551615')
+/** The DER header of an ed25519 public key, which the raw 32 bytes of an address follow. */
+const ED25519_PUBLIC_KEY_PREFIX = Buffer.from('302a300506032b6570032100', 'hex')
+
+const CREATE_ORDER_DATA = [141, 54, 37, 207, 237, 210, 250, 215]
+const CLOSE_ORDER_DATA = [244, 27, 12, 226, 45, 247, 230, 43]
+const ORDER_DISCRIMINATOR = [134, 173, 223, 185, 77, 86, 28, 51]
+const CONFIG_DISCRIMINATOR = [149, 8, 156, 202, 160, 252, 176, 217]
+const CONFIG_BYTES = 2168
+/** A bid, which is what the SDK sends for every order. */
+const ORDER_TYPE_BID = 0
+
+/** The base-58 of `ORDER_DISCRIMINATOR`, for a filter on it. */
+export const ORDER_DISCRIMINATOR_BASE58 = 'PXZJQQ2HEmx'
+
+/** What the app trades here. Common enough that their vaults exist and searchers fill them. */
+export const LIMO_TOKENS: readonly ChainToken[] = SOLANA_TOKENS.filter((t) =>
+ ['SOL', 'USDC', 'USDT'].includes(t.symbol)
+)
+
+export function limoToken(mint: string): ChainToken | undefined {
+ return LIMO_TOKENS.find((t) => t.id === mint)
+}
+
+const encodeAddress = (address: string): Uint8Array =>
+ Uint8Array.from(getAddressEncoder().encode(address as Address))
+const text = (word: string): Uint8Array => new TextEncoder().encode(word)
+
+export function pdaAuthority(): string {
+ return programAddress([text('authority'), encodeAddress(LIMO_GLOBAL_CONFIG)], LIMO_PROGRAM)
+}
+
+export function inputVault(mint: string): string {
+ return programAddress(
+ [text('escrow_vault'), encodeAddress(LIMO_GLOBAL_CONFIG), encodeAddress(mint)],
+ LIMO_PROGRAM
+ )
+}
+
+export function eventAuthority(): string {
+ return programAddress([text('__event_authority')], LIMO_PROGRAM)
+}
+
+function meta(address: string, role: AccountRole): AccountMeta {
+ return { address: address as Address, role }
+}
+
+const writable = (address: string): AccountMeta => meta(address, AccountRole.WRITABLE)
+const readonly = (address: string): AccountMeta => meta(address, AccountRole.READONLY)
+const signer = (address: string): AccountMeta => meta(address, AccountRole.WRITABLE_SIGNER)
+
+function u64(value: bigint): number[] {
+ return [...getU64Encoder().encode(value)]
+}
+
+/** Creates the account's token account for a mint if it isn't there, paid for by the account. */
+function createTokenAccount(account: string, mint: string): Instruction {
+ return {
+ programAddress: ASSOCIATED_TOKEN_PROGRAM as Address,
+ accounts: [
+ signer(account),
+ writable(associatedTokenAccount(account, mint)),
+ readonly(account),
+ readonly(mint),
+ readonly(SYSTEM_PROGRAM),
+ readonly(TOKEN_PROGRAM),
+ ],
+ data: Uint8Array.of(1),
+ }
+}
+
+function closeWrappedSol(account: string): Instruction {
+ return {
+ programAddress: TOKEN_PROGRAM as Address,
+ accounts: [
+ writable(ownWrappedSolAccount(account)),
+ writable(account),
+ meta(account, AccountRole.READONLY_SIGNER),
+ ],
+ data: Uint8Array.of(9),
+ }
+}
+
+export interface LimoCreate {
+ account: string
+ order: string
+ inputMint: string
+ outputMint: string
+ inputAmount: bigint
+ outputAmount: bigint
+}
+
+/**
+ * Every instruction of a new order, in the SDK's order. The output token account is an addition: the
+ * SDK leaves it out, and a fill fails without one unless what is bought is SOL, which arrives native.
+ */
+export function createInstructions(create: LimoCreate): Instruction[] {
+ const { account, order, inputMint, outputMint, inputAmount, outputAmount } = create
+ const sellsSol = inputMint === WRAPPED_SOL
+ return [
+ {
+ programAddress: SYSTEM_PROGRAM as Address,
+ accounts: [signer(account), signer(order)],
+ data: Uint8Array.from([
+ 0,
+ 0,
+ 0,
+ 0,
+ ...u64(BigInt(LIMO_ORDER_RENT)),
+ ...u64(BigInt(LIMO_ORDER_BYTES)),
+ ...encodeAddress(LIMO_PROGRAM),
+ ]),
+ },
+ ...(sellsSol
+ ? [
+ createTokenAccount(account, WRAPPED_SOL),
+ {
+ programAddress: SYSTEM_PROGRAM as Address,
+ accounts: [signer(account), writable(ownWrappedSolAccount(account))],
+ data: Uint8Array.from([2, 0, 0, 0, ...u64(inputAmount)]),
+ },
+ {
+ programAddress: TOKEN_PROGRAM as Address,
+ accounts: [writable(ownWrappedSolAccount(account))],
+ data: Uint8Array.of(17),
+ },
+ ]
+ : []),
+ ...(outputMint === WRAPPED_SOL ? [] : [createTokenAccount(account, outputMint)]),
+ {
+ programAddress: LIMO_PROGRAM as Address,
+ accounts: [
+ signer(account),
+ writable(LIMO_GLOBAL_CONFIG),
+ readonly(pdaAuthority()),
+ writable(order),
+ readonly(inputMint),
+ readonly(outputMint),
+ writable(associatedTokenAccount(account, inputMint)),
+ writable(inputVault(inputMint)),
+ readonly(TOKEN_PROGRAM),
+ readonly(TOKEN_PROGRAM),
+ readonly(SYSTEM_PROGRAM),
+ readonly(eventAuthority()),
+ readonly(LIMO_PROGRAM),
+ ],
+ data: Uint8Array.from([
+ ...CREATE_ORDER_DATA,
+ ...u64(inputAmount),
+ ...u64(outputAmount),
+ ORDER_TYPE_BID,
+ ]),
+ },
+ ...(sellsSol ? [closeWrappedSol(account)] : []),
+ ]
+}
+
+export interface LimoClose {
+ account: string
+ order: string
+ inputMint: string
+ outputMint: string
+}
+
+/**
+ * Closes an order, whether it is open or filled, as the one instruction does both: what is left comes
+ * back with its tips and the SOL it locked. SOL returns wrapped, so it is unwrapped in the same
+ * transaction.
+ */
+export function closeInstructions(close: LimoClose): Instruction[] {
+ const { account, order, inputMint, outputMint } = close
+ return [
+ createTokenAccount(account, inputMint),
+ {
+ programAddress: LIMO_PROGRAM as Address,
+ accounts: [
+ signer(account),
+ writable(order),
+ writable(LIMO_GLOBAL_CONFIG),
+ writable(pdaAuthority()),
+ readonly(inputMint),
+ readonly(outputMint),
+ writable(associatedTokenAccount(account, inputMint)),
+ writable(inputVault(inputMint)),
+ readonly(TOKEN_PROGRAM),
+ readonly(SYSTEM_PROGRAM),
+ readonly(eventAuthority()),
+ readonly(LIMO_PROGRAM),
+ ],
+ data: Uint8Array.from(CLOSE_ORDER_DATA),
+ },
+ ...(inputMint === WRAPPED_SOL ? [closeWrappedSol(account)] : []),
+ ]
+}
+
+const CREATE_ORDER_ACCOUNTS = [
+ 'maker',
+ 'global config',
+ 'authority',
+ 'order account',
+ 'input mint',
+ 'output mint',
+ 'token account',
+ 'vault',
+ 'input token program',
+ 'output token program',
+ 'system program',
+ 'event authority',
+ 'program',
+]
+
+const CLOSE_ORDER_ACCOUNTS = [
+ 'maker',
+ 'order account',
+ 'global config',
+ 'authority',
+ 'input mint',
+ 'output mint',
+ 'token account',
+ 'vault',
+ 'token program',
+ 'system program',
+ 'event authority',
+ 'program',
+]
+
+function refused(sentence: string): never {
+ throw new ChainActionRefused(`${sentence} Nothing was signed or sent.`)
+}
+
+function sameBytes(a: ArrayLike, b: ArrayLike): boolean {
+ if (a.length !== b.length) return false
+ for (let i = 0; i < a.length; i += 1) if (a[i] !== b[i]) return false
+ return true
+}
+
+/**
+ * Holds a transaction to exactly what this file would build. Compute budget settings are the one
+ * thing left free here: `solanaAssertSafe` has already bounded them.
+ */
+function assertExactly(
+ transaction: DecodedSolanaTransaction,
+ expected: readonly Instruction[],
+ what: string,
+ accountNames: readonly string[]
+): void {
+ const steps = transaction.instructions.filter(
+ (ix) => transaction.staticAccounts[ix.programIndex] !== COMPUTE_BUDGET_PROGRAM
+ )
+ if (steps.length !== expected.length) {
+ refused(`This is not ${what} you asked for: it has other steps in it.`)
+ }
+ expected.forEach((want, i) => {
+ const step = steps[i]
+ const isLimo = want.programAddress === LIMO_PROGRAM
+ if (
+ step === undefined ||
+ transaction.staticAccounts[step.programIndex] !== want.programAddress
+ ) {
+ refused(`This is not ${what} you asked for: its steps are not the ones expected.`)
+ }
+ const accounts = step.accounts.map((index) => transaction.staticAccounts[index])
+ const wanted = want.accounts ?? []
+ if (accounts.length !== wanted.length) {
+ refused(`This is not ${what} you asked for: one of its steps has other accounts.`)
+ }
+ wanted.forEach((meta_, position) => {
+ if (accounts[position] !== meta_.address) {
+ const name = isLimo ? (accountNames[position] ?? 'account') : 'step’s account'
+ refused(`This is not ${what} you asked for: its ${name} is not the one expected.`)
+ }
+ })
+ if (!sameBytes(step.data, want.data ?? [])) {
+ refused(
+ isLimo
+ ? `This is not ${what} you asked for: its amounts or type are different.`
+ : `This is not ${what} you asked for: one of its steps moves a different amount.`
+ )
+ }
+ })
+}
+
+function amountIn(context: Record | undefined, key: string): bigint {
+ const value = context?.[key]
+ if (value === undefined || !/^\d{1,20}$/.test(value)) {
+ refused('This order lost track of what you asked for, so I stopped here.')
+ }
+ const amount = BigInt(value)
+ if (amount === BigInt(0) || amount > MAX_U64) {
+ refused('This order’s amounts are not ones it can hold.')
+ }
+ return amount
+}
+
+/** Written out with node's ed25519 rather than the kit's because `assertSafe` is synchronous. */
+function signedBy(address: string, message: Uint8Array, signature: Uint8Array): boolean {
+ try {
+ const key = createPublicKey({
+ key: Buffer.concat([ED25519_PUBLIC_KEY_PREFIX, encodeAddress(address)]),
+ format: 'der',
+ type: 'spki',
+ })
+ return verify(null, message, key, signature)
+ } catch {
+ return false
+ }
+}
+
+function assertCreate(
+ transaction: DecodedSolanaTransaction,
+ account: string,
+ context: Record | undefined,
+ signedPayload: string
+): void {
+ const order = transaction.staticAccounts[1]
+ if (transaction.signerCount !== 2 || order === undefined) {
+ refused('This order’s account was not created by this transaction.')
+ }
+ const inputMint = context?.['inputMint'] ?? ''
+ const outputMint = context?.['outputMint'] ?? ''
+ const input = limoToken(inputMint)
+ const output = limoToken(outputMint)
+ if (
+ input === undefined ||
+ output === undefined ||
+ limitPair(input.symbol, output.symbol) === undefined
+ ) {
+ refused('Kamino limit orders here take SOL against USDC or USDT only.')
+ }
+ assertExactly(
+ transaction,
+ createInstructions({
+ account,
+ order,
+ inputMint,
+ outputMint,
+ inputAmount: amountIn(context, 'inputAmount'),
+ outputAmount: amountIn(context, 'outputAmount'),
+ }),
+ 'the limit order',
+ CREATE_ORDER_ACCOUNTS
+ )
+ // A wallet that drops it or changes the message would otherwise leave a transaction the network can
+ // only reject, and an answer submit can't read as a clean failure. "Nothing was signed" would be
+ // false by here, since this can only fail on what a wallet returned.
+ const { signatures, messageBytes } = getTransactionDecoder().decode(
+ bytesFromBase64(signedPayload)
+ )
+ const signature = signatures[order as Address] ?? null
+ if (
+ signature === null ||
+ !signedBy(order, Uint8Array.from(messageBytes), Uint8Array.from(signature))
+ ) {
+ throw new ChainActionRefused(
+ 'This order’s signature no longer matches the transaction, so the network would reject it. Your wallet may have changed it. Build it again. Nothing was sent.'
+ )
+ }
+}
+
+function assertClose(
+ transaction: DecodedSolanaTransaction,
+ account: string,
+ close: DecodedSolanaTransaction['instructions'][number]
+): void {
+ const at = (position: number): string => {
+ const index = close.accounts[position]
+ return (index === undefined ? undefined : transaction.staticAccounts[index]) ?? ''
+ }
+ const inputMint = at(4)
+ const outputMint = at(5)
+ if (limoToken(inputMint) === undefined || limoToken(outputMint) === undefined) {
+ refused('Kamino limit orders here take SOL, USDC and USDT only.')
+ }
+ assertExactly(
+ transaction,
+ closeInstructions({ account, order: at(1), inputMint, outputMint }),
+ 'the order close',
+ CLOSE_ORDER_ACCOUNTS
+ )
+}
+
+const generic = solanaAssertSafe(LIMO_POLICY)
+
+/**
+ * Passes only what this file would have built for this account. A close carries everything it needs
+ * in its own accounts, and the program refuses a maker who isn't the order's.
+ *
+ * A create's mints and amounts are compared with `context`, which shows only that the two agree.
+ * Bytes and context both come from `orderAmounts`, so this does not bind either to the person's words:
+ * only the tests of those sums do. At submit the browser hands the context back unproven, so a browser
+ * rewriting both passes on the amounts and on which offered pair. No proof is asked for, because anyone
+ * holding the wallet's signature can broadcast directly. The wallet, config, vault, accounts and steps
+ * are still held to what they must be.
+ */
+export function limoAssertSafe(
+ payload: SigningPayload,
+ account: string,
+ context?: Record | undefined
+): void {
+ try {
+ assertLimoSafe(payload, account, context)
+ } catch (e) {
+ // Once the wallet has signed, as at submit, "nothing was signed" would be false.
+ if (e instanceof ChainActionRefused && walletSigned(payload, account)) {
+ throw new ChainActionRefused(
+ e.message.replace(/Nothing was signed or sent\.$/, 'Nothing was sent.')
+ )
+ }
+ throw e
+ }
+}
+
+function walletSigned(payload: SigningPayload, account: string): boolean {
+ if (payload.family !== 'solana') return false
+ try {
+ const { signatures } = getTransactionDecoder().decode(bytesFromBase64(payload.transaction))
+ return (signatures[account as Address] ?? null) !== null
+ } catch {
+ return false
+ }
+}
+
+function assertLimoSafe(
+ payload: SigningPayload,
+ account: string,
+ context?: Record | undefined
+): void {
+ generic(payload, account)
+ if (payload.family !== 'solana') return
+ const transaction = decodeSolanaTransaction(payload.transaction)
+ const calls = transaction.instructions.filter(
+ (ix) => transaction.staticAccounts[ix.programIndex] === LIMO_PROGRAM
+ )
+ const [call] = calls
+ if (calls.length !== 1 || call === undefined) {
+ refused('This transaction is not one limit order.')
+ }
+ const isClose = sameBytes(call.data.subarray(0, 8), CLOSE_ORDER_DATA)
+ if (isClose) assertClose(transaction, account, call)
+ else assertCreate(transaction, account, context, payload.transaction)
+}
+
+/** What each side of a new order is, or a refusal for any pair this app does not offer. */
+export function limoPair(
+ tokenIn: string,
+ tokenOut: string
+): { input: ChainToken; output: ChainToken; selling: boolean; base: string; quote: string } {
+ const input = tokenBySymbol(LIMO_TOKENS, tokenIn)
+ const output = tokenBySymbol(LIMO_TOKENS, tokenOut)
+ const pair = limitPair(tokenIn, tokenOut)
+ if (input === undefined || output === undefined || pair === undefined) {
+ refused('Kamino limit orders here take SOL against USDC or USDT only.')
+ }
+ return { input, output, selling: pair.selling, base: pair.base, quote: pair.quote }
+}
+
+export interface LimoOrder {
+ globalConfig: string
+ maker: string
+ inputMint: string
+ outputMint: string
+ initialInput: bigint
+ expectedOutput: bigint
+ remainingInput: bigint
+ filledOutput: bigint
+ tip: bigint
+ /** 0 active, 1 filled, 2 cancelled. */
+ status: number
+}
+
+/** An order account's fields, or nothing if these bytes are not one. Layout from the SDK's `Order`. */
+export function decodeOrder(bytes: Uint8Array): LimoOrder | undefined {
+ if (bytes.length !== LIMO_ORDER_BYTES) return undefined
+ if (!sameBytes(bytes.subarray(0, 8), ORDER_DISCRIMINATOR)) return undefined
+ const address = getAddressDecoder()
+ const amount = getU64Decoder()
+ return {
+ globalConfig: address.decode(bytes, 8),
+ maker: address.decode(bytes, 40),
+ inputMint: address.decode(bytes, 72),
+ outputMint: address.decode(bytes, 136),
+ initialInput: amount.decode(bytes, 200),
+ expectedOutput: amount.decode(bytes, 208),
+ remainingInput: amount.decode(bytes, 216),
+ filledOutput: amount.decode(bytes, 224),
+ tip: amount.decode(bytes, 232),
+ status: bytes[249] as number,
+ }
+}
+
+export interface LimoConfig {
+ newOrdersBlocked: boolean
+ emergencyMode: boolean
+ /** Lamports `create_order` takes from the maker, inside the call where signed bytes can't show it. */
+ cost: bigint
+ pdaAuthority: string
+}
+
+/** A global config's fields, or nothing if these bytes are not one. Layout from the SDK. */
+export function decodeGlobalConfig(bytes: Uint8Array): LimoConfig | undefined {
+ if (bytes.length !== CONFIG_BYTES) return undefined
+ if (!sameBytes(bytes.subarray(0, 8), CONFIG_DISCRIMINATOR)) return undefined
+ const amount = getU64Decoder()
+ return {
+ emergencyMode: bytes[8] !== 0,
+ newOrdersBlocked: bytes[10] !== 0,
+ pdaAuthority: getAddressDecoder().decode(bytes, 120),
+ cost: amount.decode(bytes, 224) + amount.decode(bytes, 232),
+ }
+}
diff --git a/apps/dapp/lib/chains/solana/limo.ts b/apps/dapp/lib/chains/solana/limo.ts
new file mode 100644
index 00000000..b0871d7a
--- /dev/null
+++ b/apps/dapp/lib/chains/solana/limo.ts
@@ -0,0 +1,456 @@
+import {
+ appendTransactionMessageInstructions,
+ compileTransaction,
+ createTransactionMessage,
+ generateKeyPair,
+ getAddressFromPublicKey,
+ getBase64EncodedWireTransaction,
+ getU32Encoder,
+ getU64Encoder,
+ isAddress,
+ partiallySignTransaction,
+ pipe,
+ setTransactionMessageFeePayer,
+ setTransactionMessageLifetimeUsingBlockhash,
+ type Address,
+ type Blockhash,
+ type Instruction,
+} from '@solana/kit'
+import { z } from 'zod'
+
+import { limitOutput, limitPair, type LimitBook, type RestingOrder } from '../limit'
+import {
+ ChainActionRefused,
+ toBaseUnits,
+ type ChainActionRequest,
+ type ChainToken,
+ type ChainVenue,
+ type PreparedAction,
+} from '../types'
+import { TOKEN_PROGRAM } from './assert-safe'
+import { rpc } from './balances'
+import {
+ closeInstructions,
+ createInstructions,
+ decodeGlobalConfig,
+ decodeOrder,
+ inputVault,
+ limoAssertSafe,
+ limoPair,
+ limoToken,
+ ORDER_DISCRIMINATOR_BASE58,
+ type LimoOrder,
+} from './limo-rules'
+import { LIMO_GLOBAL_CONFIG, LIMO_ORDER_BYTES, LIMO_ORDER_RENT, LIMO_PROGRAM } from './programs'
+import { fromBaseUnits } from './tokens'
+
+const VENUE_ID = 'kamino-limit'
+const VENUE_NAME = 'Kamino Limit Orders'
+const SOL_DECIMALS = 9
+const READ_TIMEOUT_MS = 15_000
+const MAX_U64 = BigInt('18446744073709551615')
+/**
+ * `create_order` moves this from the maker onto the order account inside the program, where the signed
+ * bytes can't show it, so the live config is read and held to this. It is 0.003 SOL today, and in
+ * simulation on 5 November 2026 it came back at close.
+ */
+const MAX_ORDER_COST_LAMPORTS = BigInt(10_000_000)
+
+const COMPUTE_BUDGET_PROGRAM = 'ComputeBudget111111111111111111111111111111' as Address
+/**
+ * Simulated at 38,524 units for SOL to USDC on 5 November 2026, with both token accounts already
+ * there. Creating them adds about 30,000 each.
+ */
+const COMPUTE_UNIT_LIMIT = 200_000
+/**
+ * About 0.00002 SOL at the limit above. A wallet can't add a priority fee of its own to an order,
+ * because that would break the order key's signature.
+ */
+const COMPUTE_UNIT_PRICE = BigInt(100_000)
+
+export interface Lifetime {
+ blockhash: string
+ lastValidBlockHeight: number
+}
+
+function refused(sentence: string): never {
+ throw new ChainActionRefused(`${sentence} Nothing was signed or sent.`)
+}
+
+function compile(account: string, instructions: readonly Instruction[], lifetime: Lifetime) {
+ return pipe(
+ createTransactionMessage({ version: 'legacy' }),
+ (m) => setTransactionMessageFeePayer(account as Address, m),
+ (m) =>
+ setTransactionMessageLifetimeUsingBlockhash(
+ {
+ blockhash: lifetime.blockhash as Blockhash,
+ lastValidBlockHeight: BigInt(lifetime.lastValidBlockHeight),
+ },
+ m
+ ),
+ (m) =>
+ appendTransactionMessageInstructions(
+ [
+ {
+ programAddress: COMPUTE_BUDGET_PROGRAM,
+ data: Uint8Array.from([2, ...getU32Encoder().encode(COMPUTE_UNIT_LIMIT)]),
+ },
+ {
+ programAddress: COMPUTE_BUDGET_PROGRAM,
+ data: Uint8Array.from([3, ...getU64Encoder().encode(COMPUTE_UNIT_PRICE)]),
+ },
+ ...instructions,
+ ],
+ m
+ ),
+ (m) => compileTransaction(m)
+ )
+}
+
+function baseUnits(amount: string, token: ChainToken): bigint {
+ try {
+ return BigInt(toBaseUnits(amount, token.decimals))
+ } catch (e) {
+ if (e instanceof ChainActionRefused) refused(e.message)
+ throw e
+ }
+}
+
+export interface BuildCreateInput {
+ account: string
+ tokenIn: string
+ tokenOut: string
+ amount: string
+ limitPrice: string
+ lifetime: Lifetime
+}
+
+export interface BuiltCreate {
+ /** Base64, with the order key's signature in place and the wallet's slot empty. */
+ transaction: string
+ order: string
+ /** What `limoAssertSafe` compares a create with. */
+ context: { inputMint: string; outputMint: string; inputAmount: string; outputAmount: string }
+}
+
+function orderAmounts(tokenIn: string, tokenOut: string, amount: string, limitPrice: string) {
+ const { input, output, selling } = limoPair(tokenIn, tokenOut)
+ const inputAmount = baseUnits(amount, input)
+ const outputAmount = limitOutput(amount, limitPrice, selling, input.decimals, output.decimals)
+ if (inputAmount > MAX_U64 || outputAmount > MAX_U64 || outputAmount === BigInt(0)) {
+ refused('That amount and price are outside what an order can hold.')
+ }
+ return { input, output, inputAmount, outputAmount }
+}
+
+/**
+ * The order's account needs a key to be created, and nothing else ever uses it. So one is made here,
+ * can't be exported, signs this one message, and is gone when this returns. The wallet's signature is
+ * added to the same bytes: the order key's stays valid, as the wallet signs the same message.
+ */
+export async function buildCreate(input: BuildCreateInput): Promise {
+ const { account, tokenIn, tokenOut, amount, limitPrice, lifetime } = input
+ const amounts = orderAmounts(tokenIn, tokenOut, amount, limitPrice)
+ const keyPair = await generateKeyPair()
+ const order = await getAddressFromPublicKey(keyPair.publicKey)
+ const inputMint = amounts.input.id
+ const outputMint = amounts.output.id
+ const instructions = createInstructions({
+ account,
+ order,
+ inputMint,
+ outputMint,
+ inputAmount: amounts.inputAmount,
+ outputAmount: amounts.outputAmount,
+ })
+ const signed = await partiallySignTransaction([keyPair], compile(account, instructions, lifetime))
+ return {
+ transaction: getBase64EncodedWireTransaction(signed),
+ order,
+ context: {
+ inputMint,
+ outputMint,
+ inputAmount: amounts.inputAmount.toString(),
+ outputAmount: amounts.outputAmount.toString(),
+ },
+ }
+}
+
+const Account = z.object({
+ data: z.tuple([z.string(), z.string()]),
+ owner: z.string(),
+ lamports: z.number(),
+})
+
+const Accounts = z.object({ value: z.array(Account.nullable()) })
+const OneAccount = z.object({ value: Account.nullable() })
+const Blockhashed = z.object({
+ value: z.object({ blockhash: z.string(), lastValidBlockHeight: z.number() }),
+})
+const Listed = z.array(z.object({ pubkey: z.string(), account: Account }))
+
+const bytesOf = (account: z.infer): Uint8Array =>
+ Uint8Array.from(Buffer.from(account.data[0], 'base64'))
+
+/** A read needed before anything can be built. Failing means building nothing, said in words. */
+async function mustRead(method: string, params: unknown[], what: string): Promise {
+ try {
+ return await rpc(method, params, AbortSignal.timeout(READ_TIMEOUT_MS))
+ } catch {
+ return refused(`I couldn’t read ${what} just now, so try again in a moment.`)
+ }
+}
+
+async function readLifetime(): Promise {
+ const reply = Blockhashed.safeParse(
+ await mustRead('getLatestBlockhash', [{ commitment: 'confirmed' }], 'the Solana network')
+ )
+ if (!reply.success) refused('I couldn’t read the Solana network just now.')
+ return reply.data.value
+}
+
+/**
+ * Holds the live config to what the signed bytes can't show, and refuses a mint with no vault: making
+ * one is rent the person would never get back, for a vault everybody else then shares. Returns the
+ * lamports `create_order` will take.
+ */
+async function checkedCost(inputMint: string, symbol: string): Promise {
+ const reply = Accounts.safeParse(
+ await mustRead(
+ 'getMultipleAccounts',
+ [
+ [LIMO_GLOBAL_CONFIG, inputVault(inputMint)],
+ { encoding: 'base64', commitment: 'confirmed' },
+ ],
+ 'Kamino’s settings'
+ )
+ )
+ const [configAccount, vault] = reply.success ? reply.data.value : []
+ const config =
+ configAccount !== null && configAccount !== undefined && configAccount.owner === LIMO_PROGRAM
+ ? decodeGlobalConfig(bytesOf(configAccount))
+ : undefined
+ if (config === undefined) refused('I couldn’t read Kamino’s settings just now.')
+ if (config.newOrdersBlocked || config.emergencyMode) {
+ refused('Kamino is not taking new orders right now.')
+ }
+ if (config.cost > MAX_ORDER_COST_LAMPORTS) {
+ refused(
+ `Kamino now takes about ${fromBaseUnits(config.cost.toString(), SOL_DECIMALS)} SOL to place an order, above the ${fromBaseUnits(MAX_ORDER_COST_LAMPORTS.toString(), SOL_DECIMALS)} SOL I accept.`
+ )
+ }
+ if (vault === null || vault === undefined || vault.owner !== TOKEN_PROGRAM) {
+ refused(`Kamino has no vault for ${symbol} yet, so I can’t place this.`)
+ }
+ return config.cost
+}
+
+function shortSol(lamports: bigint): string {
+ return String(Number(Number(fromBaseUnits(lamports.toString(), SOL_DECIMALS)).toFixed(5)))
+}
+
+async function prepare(request: ChainActionRequest, account: string): Promise {
+ if (!isAddress(account)) refused('That is not a Solana wallet address.')
+ if (request.kind !== 'limit')
+ refused(`${VENUE_NAME} only rests orders. It can’t ${request.kind}.`)
+ const { tokenIn, amount } = request
+ const tokenOut = request.tokenOut ?? ''
+ const limitPrice = request.limitPrice ?? ''
+ const { input, output, base, quote } = limoPair(tokenIn, tokenOut)
+ const { outputAmount } = orderAmounts(tokenIn, tokenOut, amount, limitPrice)
+
+ const cost = await checkedCost(input.id, input.symbol)
+ const built = await buildCreate({
+ account,
+ tokenIn,
+ tokenOut,
+ amount,
+ limitPrice,
+ lifetime: await readLifetime(),
+ })
+
+ const received = fromBaseUnits(outputAmount.toString(), output.decimals)
+ return {
+ venueId: VENUE_ID,
+ venueName: VENUE_NAME,
+ kind: 'limit',
+ summary: `Offer ${amount} ${input.symbol} on Kamino for at least ${received} ${output.symbol}, at ${limitPrice} ${quote} per ${base}.`,
+ pay: { amount, symbol: input.symbol },
+ receive: { amount: received, symbol: output.symbol, approximate: false },
+ notes: [
+ 'This stays open until it fills or you cancel it. There is no expiry.',
+ `Locks about ${shortSol(cost + BigInt(LIMO_ORDER_RENT))} SOL until you close the order, then it comes back, less the network fee. Each token account opened for this order locks about 0.002 SOL more, which comes back only if you close that account.`,
+ 'Only Pyth Express Relay searchers fill it. It may never fill, and small orders are less likely to.',
+ 'Kamino can upgrade this program, so your escrowed amount carries that risk.',
+ ],
+ signing: { family: 'solana', transaction: built.transaction },
+ context: built.context,
+ }
+}
+
+export function createKaminoLimitVenue(): ChainVenue {
+ return {
+ id: VENUE_ID,
+ name: VENUE_NAME,
+ kinds: ['limit'],
+ prepare,
+ assertSafe: limoAssertSafe,
+ limitCost: { amount: '0.00684', symbol: 'SOL' },
+ }
+}
+
+function solBack(lamports: number, order: LimoOrder): string {
+ return fromBaseUnits((BigInt(lamports) + order.tip).toString(), SOL_DECIMALS)
+}
+
+/** The stablecoin per token, the way up a person named it, or nothing for a pair of two stablecoins. */
+function priceOf(order: LimoOrder, input: ChainToken, output: ChainToken): string | undefined {
+ const pair = limitPair(input.symbol, output.symbol)
+ if (pair === undefined) return undefined
+ const given = Number(fromBaseUnits(order.initialInput.toString(), input.decimals))
+ const got = Number(fromBaseUnits(order.expectedOutput.toString(), output.decimals))
+ const price = pair.selling ? got / given : given / got
+ return String(Number(price.toPrecision(6)))
+}
+
+function restingOrder(
+ address: string,
+ lamports: number,
+ order: LimoOrder
+): RestingOrder | undefined {
+ const input = limoToken(order.inputMint)
+ const output = limoToken(order.outputMint)
+ if (input === undefined || output === undefined) return undefined
+ const limitPrice = priceOf(order, input, output)
+ const fields: RestingOrder = {
+ id: address,
+ venueId: VENUE_ID,
+ tokenIn: input.symbol,
+ tokenOut: output.symbol,
+ amountIn: fromBaseUnits(order.initialInput.toString(), input.decimals),
+ remainingIn: fromBaseUnits(order.remainingInput.toString(), input.decimals),
+ receivedOut: fromBaseUnits(order.filledOutput.toString(), output.decimals),
+ status: 'done',
+ ...(limitPrice !== undefined ? { limitPrice } : {}),
+ }
+ if (order.status === 0) {
+ return { ...fields, status: order.remainingInput < order.initialInput ? 'partly' : 'open' }
+ }
+ if (order.status !== 1) return fields
+ const back = solBack(lamports, order)
+ return {
+ ...fields,
+ status: 'filled',
+ collectable: [{ amount: back, symbol: 'SOL' }],
+ note: `It filled, and your ${output.symbol} was paid then. Closing it brings back about ${back} SOL, what it locked plus any tips.`,
+ }
+}
+
+async function openOrders(account: string, signal?: AbortSignal): Promise {
+ if (!isAddress(account)) refused('That is not a Solana wallet address.')
+ const listed = Listed.parse(
+ await rpc(
+ 'getProgramAccounts',
+ [
+ LIMO_PROGRAM,
+ {
+ encoding: 'base64',
+ commitment: 'confirmed',
+ filters: [
+ { dataSize: LIMO_ORDER_BYTES },
+ { memcmp: { offset: 0, bytes: ORDER_DISCRIMINATOR_BASE58, encoding: 'base58' } },
+ { memcmp: { offset: 8, bytes: LIMO_GLOBAL_CONFIG, encoding: 'base58' } },
+ { memcmp: { offset: 40, bytes: account, encoding: 'base58' } },
+ ],
+ },
+ ],
+ signal ?? AbortSignal.timeout(READ_TIMEOUT_MS)
+ )
+ )
+ return listed.flatMap(({ pubkey, account: held }) => {
+ const order = decodeOrder(bytesOf(held))
+ const resting = order === undefined ? undefined : restingOrder(pubkey, held.lamports, order)
+ return resting === undefined ? [] : [resting]
+ })
+}
+
+async function prepareExit(
+ account: string,
+ orderId: string,
+ action: 'cancel' | 'collect'
+): Promise {
+ if (!isAddress(account)) refused('That is not a Solana wallet address.')
+ if (!isAddress(orderId)) refused('That is not a Kamino order.')
+ const reply = OneAccount.safeParse(
+ await mustRead(
+ 'getAccountInfo',
+ [orderId, { encoding: 'base64', commitment: 'confirmed' }],
+ 'that order'
+ )
+ )
+ if (!reply.success) refused('I couldn’t read that order just now.')
+ const held = reply.data.value
+ if (held === null) refused('That order is not there any more, so it may already be closed.')
+ const order = held.owner === LIMO_PROGRAM ? decodeOrder(bytesOf(held)) : undefined
+ if (order === undefined || order.globalConfig !== LIMO_GLOBAL_CONFIG) {
+ refused('That is not a Kamino order I can change.')
+ }
+ if (order.maker !== account) refused('That order belongs to another wallet.')
+ const input = limoToken(order.inputMint)
+ const output = limoToken(order.outputMint)
+ if (input === undefined || output === undefined) {
+ refused('That order is on a token this app doesn’t trade, so close it on Kamino.')
+ }
+
+ if (order.status > 1) refused('That order has already been cancelled.')
+ if (action === 'collect' && order.status === 0) {
+ refused('That order is still open, and closing it would cancel it. Cancel it if you meant to.')
+ }
+ if (action === 'cancel' && order.status === 1) {
+ refused('That order has already filled, so there is nothing to cancel. Collect it instead.')
+ }
+
+ const transaction = getBase64EncodedWireTransaction(
+ compile(
+ account,
+ closeInstructions({
+ account,
+ order: orderId,
+ inputMint: order.inputMint,
+ outputMint: order.outputMint,
+ }),
+ await readLifetime()
+ )
+ )
+
+ const back = solBack(held.lamports, order)
+ const signing = { family: 'solana' as const, transaction }
+ if (action === 'collect') {
+ return {
+ venueId: VENUE_ID,
+ venueName: VENUE_NAME,
+ kind: 'limit',
+ summary: `Close your filled Kamino order and get about ${back} SOL back, what it locked plus any tips.`,
+ pay: { amount: '0', symbol: 'SOL' },
+ receive: { amount: back, symbol: 'SOL', approximate: false },
+ notes: [`Your ${output.symbol} was paid when it filled, so this doesn’t change that.`],
+ signing,
+ }
+ }
+ const remaining = fromBaseUnits(order.remainingInput.toString(), input.decimals)
+ return {
+ venueId: VENUE_ID,
+ venueName: VENUE_NAME,
+ kind: 'limit',
+ summary: `Cancel your Kamino order and get back ${remaining} ${input.symbol}, plus about ${back} SOL, what it locked plus any tips.`,
+ pay: { amount: '0', symbol: input.symbol },
+ receive: { amount: remaining, symbol: input.symbol, approximate: false },
+ notes: ['A searcher could still fill it before this lands. Whatever filled stays yours.'],
+ signing,
+ }
+}
+
+export function limoBook(): LimitBook {
+ return { openOrders, prepareExit }
+}
diff --git a/apps/dapp/lib/chains/solana/module.ts b/apps/dapp/lib/chains/solana/module.ts
index 1ae91850..dbf6a5ad 100644
--- a/apps/dapp/lib/chains/solana/module.ts
+++ b/apps/dapp/lib/chains/solana/module.ts
@@ -6,6 +6,7 @@ import { createJitoVenue } from './jito'
import { createJupiterLendVenue } from './jupiter-lend'
import { createJupiterSwapVenue } from './jupiter-swap'
import { createKaminoVenue } from './kamino'
+import { createKaminoLimitVenue, limoBook } from './limo'
import { createKaminoLendVenue } from './kamino-lend'
import { createLuloVenue } from './lulo'
import { createMarinadeVenue } from './marinade'
@@ -30,6 +31,7 @@ export function buildSolanaVenues(
createJupiterLendVenue({ env }),
// After Earn, so "supply on Kamino" stays the vault it has always meant.
createKaminoVenue(),
+ createKaminoLimitVenue(),
createKaminoLendVenue({ env }),
...(luloKey ? [createLuloVenue({ apiKey: luloKey })] : []),
createJitoVenue({ env }),
@@ -56,9 +58,11 @@ export const solanaModule: ChainModule = {
stream: 'streamflow',
cancel: 'streamflow',
send: SEND_VENUE_ID,
+ limit: 'kamino-limit',
},
venueForToken: { unstake: { MSOL: 'marinade', JITOSOL: 'jito', SSOL: 'solayer' } },
priceUsd,
submit: createSolanaSubmit({ deadlineMs: SUBMIT_DEADLINE_MS }),
balances: readSolanaBalances,
+ limitBook: limoBook(),
}
diff --git a/apps/dapp/lib/chains/solana/programs.ts b/apps/dapp/lib/chains/solana/programs.ts
index 5ff023af..4f0f9872 100644
--- a/apps/dapp/lib/chains/solana/programs.ts
+++ b/apps/dapp/lib/chains/solana/programs.ts
@@ -24,6 +24,20 @@ export const KFARMS_PROGRAM = 'FarmsPZpWu9i7Kky8tPN37rs2TpmMrAZrC7S7vJa91Hr'
/** https://lulo.fi/docs/integration-guide, "Program ID". */
export const LULO_PROGRAM = 'FL3X2pRsQ9zHENpZSKDRREtccwJuei8yg9fwDu9UN69Q'
+/**
+ * Kamino Limo, the limit order program, and the global config its own app's orders sit under. No
+ * Kamino page names either. Read from `@kamino-finance/limo-sdk` 4.0.0 and on chain on 5 November
+ * 2026: see docs/research/2026-11-05-limit-orders/solana.md. The config is pinned because another
+ * one has another admin and another set of fillers.
+ */
+export const LIMO_PROGRAM = 'LiMoM9rMhrdYrfzUCxQppvxCSG1FcrUK9G8uLq4A1GF'
+export const LIMO_GLOBAL_CONFIG = 'prod7Mog8r56sn5dPwze8nVdf94veEawdxNNqd5v7A1'
+export const LIMO_CREATE_ORDER = '8d3625cfedd2fad7'
+export const LIMO_CLOSE_ORDER = 'f41b0ce22df7e62b'
+export const LIMO_ORDER_BYTES = 424
+/** The rent-exempt minimum for 424 bytes, which the SDK pins too. */
+export const LIMO_ORDER_RENT = 3_841_920
+
/** Anchor's discriminator for `deposit`, which Jupiter Lend Earn and Kamino kVaults share. */
export const DEPOSIT_DISCRIMINATOR = 'f223c68952e1f2b6'
@@ -160,3 +174,15 @@ export const LULO_EXIT_POLICY: SolanaExitPolicy = {
},
],
}
+
+/** `update_order` and `initialize_vault` are left out on purpose. See limo-rules.ts for the rest. */
+export const LIMO_POLICY: SolanaVenuePolicy = {
+ venueName: 'Kamino Limit Orders',
+ programs: { [LIMO_PROGRAM]: { discriminators: [LIMO_CREATE_ORDER, LIMO_CLOSE_ORDER] } },
+ mayWrapSol: true,
+ createsSigner: {
+ owner: LIMO_PROGRAM,
+ space: BigInt(LIMO_ORDER_BYTES),
+ lamports: BigInt(LIMO_ORDER_RENT),
+ },
+}
diff --git a/apps/dapp/lib/chains/solana/venues.ts b/apps/dapp/lib/chains/solana/venues.ts
index 5e476aa8..2189b855 100644
--- a/apps/dapp/lib/chains/solana/venues.ts
+++ b/apps/dapp/lib/chains/solana/venues.ts
@@ -58,6 +58,18 @@ export const solanaVenues: Venue[] = [
capability:
'Deposits USDC into Kamino’s largest USDC vault by deposits, and names it before you sign. Withdraws through Kamino’s API, which this exit depends on.',
},
+ {
+ id: 'kamino-limit',
+ family: 'solana',
+ name: 'Kamino Limit Orders',
+ category: 'orderbook',
+ chains: ['Solana'],
+ bestFor: 'Selling or buying SOL at a price you name, with no expiry',
+ url: 'https://kamino.com',
+ integration: 'executes',
+ capability:
+ 'Places, reads, cancels and closes SOL orders against USDC or USDT, and checks every transaction before you sign. Locks about 0.00684 SOL until you close the order, then it comes back, and it may never fill.',
+ },
{
id: 'onre',
family: 'solana',
diff --git a/apps/dapp/lib/chains/sui/capabilities.ts b/apps/dapp/lib/chains/sui/capabilities.ts
index 3dfcac79..949c2129 100644
--- a/apps/dapp/lib/chains/sui/capabilities.ts
+++ b/apps/dapp/lib/chains/sui/capabilities.ts
@@ -10,4 +10,5 @@ export const suiCapabilities: ChainCapability[] = [
{ kind: 'stake', venue: 'SpringSui', symbol: 'SUI' },
{ kind: 'unstake', venue: 'SpringSui', symbol: 'sSUI' },
{ kind: 'claim', venue: 'Walrus', symbol: 'WAL' },
+ { kind: 'limit', venue: 'DeepBook', symbol: 'SUI' },
]
diff --git a/apps/dapp/lib/chains/sui/deepbook-limit.ts b/apps/dapp/lib/chains/sui/deepbook-limit.ts
new file mode 100644
index 00000000..a7a49268
--- /dev/null
+++ b/apps/dapp/lib/chains/sui/deepbook-limit.ts
@@ -0,0 +1,533 @@
+import { bcs } from '@mysten/sui/bcs'
+import { Transaction } from '@mysten/sui/transactions'
+import { isValidSuiAddress, normalizeStructTag, normalizeSuiAddress } from '@mysten/sui/utils'
+
+import type { LimitBook, RestingOrder } from '../limit'
+import {
+ ChainActionRefused,
+ type ChainActionRequest,
+ type ChainVenue,
+ type PreparedAction,
+} from '../types'
+import { buildUnsigned, suiClient, withTimeout } from './client'
+import {
+ addExit,
+ addPlace,
+ decodeIds,
+ decodeManager,
+ decodeOrderId,
+ decodeOrders,
+ decodeSettled,
+ deepbookAssertSafe,
+ deepbookPair,
+ DEEPBOOK_READS,
+ orderFigures,
+ quoteOf,
+ type BookOrder,
+ type ManagerRef,
+ type PoolBook,
+} from './deepbook-rules'
+import {
+ DEEPBOOK_MANAGER_TYPE,
+ DEEPBOOK_POOL,
+ DEEPBOOK_POOL_VERSION,
+ DEEPBOOK_REGISTRY,
+ DEEPBOOK_REGISTRY_VERSION,
+ DEEPBOOK_SUI,
+ DEEPBOOK_USDC,
+} from './policies'
+import { displayAmount } from './tokens'
+
+/**
+ * What simulating on mainnet settled on 5 November 2026, for a public funded wallet and with nothing
+ * signed (`sui-deepbook.live.test.ts`):
+ *
+ * 1. `new`, `deposit`, `generate_proof_as_owner`, `place_limit_order` and `public_share_object` run in
+ * one transaction, so first use is one signature and `thenPrepareAgain` is never set here.
+ * 2. `pay_with_deep = false` is accepted on POST_ONLY, for both sides, with exactly the order's amount
+ * deposited and no DEEP anywhere. The Move comment saying it must be true is out of date. The
+ * pool's maker fee is 0 today, so nothing is owed in any coin; `prepare` refuses if that changes.
+ * 3. `register_balance_manager` is allowed. Its Move source checks the sender is the manager's owner,
+ * then only adds the manager's id to that owner's set in the registry. In simulation it created one
+ * registry entry and moved nothing. That is how a new device finds the manager, and it is why a
+ * stranger can't plant one: nobody else can register a manager for this account.
+ */
+
+const VENUE_ID = 'deepbook-limit'
+const VENUE_NAME = 'DeepBook'
+const SUI_DECIMALS = 9
+const USDC_DECIMALS = 6
+const READ_TIMEOUT_MS = 10_000
+const EXPIRY_MS = 7 * 24 * 60 * 60 * 1000
+const MAX_U128 = (BigInt(1) << BigInt(128)) - BigInt(1)
+const ZERO = BigInt(0)
+const WAITING_NOTE = 'This is in your DeepBook account, not your wallet, until you press Collect.'
+
+function refused(sentence: string): never {
+ throw new ChainActionRefused(`${sentence} Nothing was signed or sent.`)
+}
+
+function assertAccount(account: string): void {
+ if (!isValidSuiAddress(account)) refused('That is not a Sui address.')
+}
+
+const sui = (raw: bigint): string => displayAmount(raw.toString(), SUI_DECIMALS, SUI_DECIMALS)
+const usdc = (raw: bigint): string => displayAmount(raw.toString(), USDC_DECIMALS, USDC_DECIMALS)
+
+const poolInput = (tx: Transaction) =>
+ tx.sharedObjectRef({
+ objectId: DEEPBOOK_POOL,
+ initialSharedVersion: DEEPBOOK_POOL_VERSION,
+ mutable: false,
+ })
+
+const managerInput = (tx: Transaction, manager: ManagerRef) =>
+ tx.sharedObjectRef({
+ objectId: manager.id,
+ initialSharedVersion: manager.version,
+ mutable: false,
+ })
+
+const POOL_TYPES = { typeArguments: [DEEPBOOK_SUI, DEEPBOOK_USDC] }
+
+/** Each command's return values, from a simulation that signs nothing and changes nothing. */
+async function readCalls(
+ account: string,
+ build: (tx: Transaction) => void,
+ signal?: AbortSignal
+): Promise {
+ const tx = new Transaction()
+ tx.setSender(account)
+ build(tx)
+ const result = await withTimeout(
+ suiClient().core.simulateTransaction({
+ transaction: tx,
+ include: { commandResults: true },
+ ...(signal === undefined ? {} : { signal }),
+ }),
+ READ_TIMEOUT_MS,
+ 'Reading DeepBook'
+ )
+ if (result.$kind !== 'Transaction') throw new Error('A DeepBook read failed.')
+ return (result.commandResults ?? []).map((c) => c.returnValues.map((v) => v.bcs))
+}
+
+function returned(results: Uint8Array[][], command: number, value = 0): Uint8Array {
+ const bytes = results[command]?.[value]
+ if (bytes === undefined) throw new Error('A DeepBook read came back short.')
+ return bytes
+}
+
+const u64Of = (bytes: Uint8Array): bigint => BigInt(bcs.u64().parse(bytes))
+
+/** A read needed before anything can be built. Failing means building nothing, said in words. */
+async function mustRead(work: () => Promise, what: string): Promise {
+ try {
+ return await work()
+ } catch (e) {
+ if (e instanceof ChainActionRefused) throw e
+ return refused(`I couldn’t read ${what} just now, so try again in a moment.`)
+ }
+}
+
+interface PoolState {
+ book: PoolBook
+ makerFee: bigint
+}
+
+async function readPool(account: string): Promise {
+ const results = await readCalls(account, (tx) => {
+ tx.moveCall({
+ target: DEEPBOOK_READS.bookParams,
+ ...POOL_TYPES,
+ arguments: [poolInput(tx)],
+ })
+ tx.moveCall({
+ target: DEEPBOOK_READS.tradeParams,
+ ...POOL_TYPES,
+ arguments: [poolInput(tx)],
+ })
+ })
+ return {
+ book: {
+ tick: u64Of(returned(results, 0, 0)),
+ lot: u64Of(returned(results, 0, 1)),
+ min: u64Of(returned(results, 0, 2)),
+ },
+ makerFee: u64Of(returned(results, 1, 1)),
+ }
+}
+
+interface Manager extends ManagerRef {
+ owner: string
+ caps: string[]
+}
+
+/**
+ * The manager at this id, or nothing if the object there isn't one, isn't shared, or can't be read on
+ * its own, as a manager held inside a margin account can't. A failed read still throws.
+ */
+async function readManager(id: string): Promise {
+ const object = await withTimeout(
+ suiClient().core.getObject({ objectId: id, include: { content: true } }),
+ READ_TIMEOUT_MS,
+ 'Reading DeepBook'
+ ).then(
+ (reply) => reply.object,
+ (e: unknown) => {
+ if (e instanceof Error && /not found/i.test(e.message)) return undefined
+ throw e
+ }
+ )
+ if (object === undefined) return undefined
+ const owner = object.owner
+ if (
+ owner.$kind !== 'Shared' ||
+ normalizeStructTag(object.type) !== normalizeStructTag(DEEPBOOK_MANAGER_TYPE)
+ ) {
+ return undefined
+ }
+ return {
+ id: normalizeSuiAddress(id),
+ version: owner.Shared.initialSharedVersion,
+ ...decodeManager(object.content),
+ }
+}
+
+async function registeredManagers(
+ account: string,
+ signal?: AbortSignal
+): Promise<(Manager | undefined)[]> {
+ const results = await readCalls(
+ account,
+ (tx) => {
+ tx.moveCall({
+ target: DEEPBOOK_READS.managerIds,
+ arguments: [
+ tx.sharedObjectRef({
+ objectId: DEEPBOOK_REGISTRY,
+ initialSharedVersion: DEEPBOOK_REGISTRY_VERSION,
+ mutable: false,
+ }),
+ tx.pure.address(account),
+ ],
+ })
+ },
+ signal
+ )
+ return Promise.all(decodeIds(returned(results, 0)).map(readManager))
+}
+
+/**
+ * The manager to deposit into. Its owner is read off the object, whatever the registry says. One with
+ * a cap minted on it is passed over, since whoever holds that cap could trade or withdraw what goes
+ * in, and a clean one is opened instead.
+ */
+async function managerFor(account: string): Promise {
+ const me = normalizeSuiAddress(account)
+ const managers = await mustRead(() => registeredManagers(account), 'your DeepBook account')
+ const held = managers.filter((m): m is Manager => m !== undefined)
+ if (held.some((m) => m.owner !== me)) {
+ refused('A DeepBook account registered here belongs to another wallet.')
+ }
+ return held.find((m) => m.caps.length === 0) ?? 'new'
+}
+
+/** A failed build has already been put in words, which here also say that nothing happened. */
+async function built(tx: Transaction, symbol: string): Promise {
+ try {
+ return await buildUnsigned(tx, suiClient(), symbol)
+ } catch (e) {
+ if (e instanceof ChainActionRefused) refused(e.message)
+ throw e
+ }
+}
+
+async function prepare(request: ChainActionRequest, account: string): Promise {
+ assertAccount(account)
+ if (request.kind !== 'limit')
+ refused(`${VENUE_NAME} only rests orders. It can’t ${request.kind}.`)
+ const { input, output, selling } = deepbookPair(request.tokenIn, request.tokenOut ?? '')
+
+ const { book, makerFee } = await mustRead(() => readPool(account), 'DeepBook')
+ if (makerFee !== ZERO) {
+ refused('DeepBook now charges a fee to rest an order, which I haven’t checked this against.')
+ }
+ const figures = orderFigures({
+ amount: request.amount,
+ price: request.limitPrice ?? '',
+ selling,
+ book,
+ })
+ const manager = await managerFor(account)
+ const expiry = Date.now() + EXPIRY_MS
+
+ const tx = new Transaction()
+ tx.setSender(account)
+ addPlace(tx, { manager, selling, figures, expiry })
+ const transaction = await built(tx, input.symbol)
+
+ const pay = selling ? sui(figures.deposit) : usdc(figures.deposit)
+ const receive = selling ? usdc(figures.receive) : sui(figures.receive)
+ return {
+ venueId: VENUE_ID,
+ venueName: VENUE_NAME,
+ kind: 'limit',
+ summary: `Offer ${pay} ${input.symbol} on DeepBook for ${receive} ${output.symbol}, at ${usdc(figures.price)} USDC per SUI.`,
+ pay: { amount: pay, symbol: input.symbol },
+ receive: { amount: receive, symbol: output.symbol, approximate: false },
+ notes: [
+ ...(manager === 'new'
+ ? ['This opens a DeepBook account that only you control, to hold the order’s tokens.']
+ : []),
+ 'Filled tokens stay in your DeepBook account until you press Collect, which needs a second signature. Cancelling brings back what is left in one signature.',
+ 'Expires in 7 days. After that, cancel it to bring back what is left.',
+ 'It never fills at once. If the price has already passed yours, placing it fails and nothing moves.',
+ // Read on chain on 5 November 2026: the `UpgradeCap` is policy 0, compatible, at 0x37f187e1...64d2,
+ // and version 9 was signed as a multisig. The Move source sets no policy, and whose keys those
+ // are isn't on chain, so this doesn't name anyone.
+ "DeepBook's contracts can be upgraded by the multisig that holds their upgrade key, so your deposit carries that risk.",
+ ],
+ signing: { family: 'sui', transaction },
+ context: {
+ manager: manager === 'new' ? 'new' : manager.id,
+ side: selling ? 'sell' : 'buy',
+ price: figures.price.toString(),
+ quantity: figures.quantity.toString(),
+ deposit: figures.deposit.toString(),
+ expiry: String(expiry),
+ },
+ }
+}
+
+/**
+ * `limitCost` is what a first order costs in storage, with room to spare: simulated on 5 November 2026
+ * at 0.0099 SUI net for a new manager and its registry entry, and a budget of 0.009 SUI. Storage is
+ * charged net of a rebate on the pool's objects it rewrites, so most of the gross 0.074 SUI comes back
+ * in the same transaction.
+ */
+export const deepbookLimitVenue: ChainVenue = {
+ id: VENUE_ID,
+ name: VENUE_NAME,
+ kinds: ['limit'],
+ prepare,
+ assertSafe: deepbookAssertSafe,
+ limitCost: { amount: '0.02', symbol: 'SUI' },
+}
+
+interface ManagerHoldings {
+ orders: BookOrder[]
+ /** In the manager, or settled in the pool's record for it. Either way, not in the wallet. */
+ base: bigint
+ quote: bigint
+}
+
+async function readHoldings(
+ account: string,
+ manager: ManagerRef,
+ signal?: AbortSignal
+): Promise {
+ const first = await readCalls(
+ account,
+ (tx) => {
+ const held = managerInput(tx, manager)
+ tx.moveCall({
+ target: DEEPBOOK_READS.accountExists,
+ ...POOL_TYPES,
+ arguments: [poolInput(tx), held],
+ })
+ for (const coin of [DEEPBOOK_SUI, DEEPBOOK_USDC]) {
+ tx.moveCall({
+ target: DEEPBOOK_READS.balance,
+ typeArguments: [coin],
+ arguments: [held],
+ })
+ }
+ },
+ signal
+ )
+ const base = u64Of(returned(first, 1))
+ const quote = u64Of(returned(first, 2))
+ if (!bcs.bool().parse(returned(first, 0))) return { orders: [], base, quote }
+
+ const second = await readCalls(
+ account,
+ (tx) => {
+ const held = managerInput(tx, manager)
+ tx.moveCall({
+ target: DEEPBOOK_READS.account,
+ ...POOL_TYPES,
+ arguments: [poolInput(tx), held],
+ })
+ tx.moveCall({
+ target: DEEPBOOK_READS.orders,
+ ...POOL_TYPES,
+ arguments: [poolInput(tx), held],
+ })
+ },
+ signal
+ )
+ const settled = decodeSettled(returned(second, 0))
+ return {
+ orders: decodeOrders(returned(second, 1)),
+ base: base + settled.base,
+ quote: quote + settled.quote,
+ }
+}
+
+function restingOrder(manager: string, order: BookOrder): RestingOrder {
+ const { isBid, price } = decodeOrderId(order.id)
+ const remaining = order.quantity - order.filled
+ const filled = order.filled > ZERO
+ const note =
+ order.expires <= Date.now()
+ ? 'It has expired and can no longer fill. Cancel brings back what is left.'
+ : filled
+ ? 'What has filled so far is in your DeepBook account, not your wallet, until you collect it.'
+ : undefined
+ return {
+ id: `${manager}:${order.id}`,
+ venueId: VENUE_ID,
+ tokenIn: isBid ? 'USDC' : 'SUI',
+ tokenOut: isBid ? 'SUI' : 'USDC',
+ amountIn: isBid ? usdc(quoteOf(order.quantity, price)) : sui(order.quantity),
+ remainingIn: isBid ? usdc(quoteOf(remaining, price)) : sui(remaining),
+ receivedOut: isBid ? sui(order.filled) : usdc(quoteOf(order.filled, price)),
+ limitPrice: usdc(price),
+ status: filled ? 'partly' : 'open',
+ expiresAt: order.expires,
+ ...(note === undefined ? {} : { note }),
+ }
+}
+
+function waitingRow(manager: string, base: bigint, quote: bigint): RestingOrder | undefined {
+ const collectable = [
+ ...(quote > ZERO ? [{ amount: usdc(quote), symbol: 'USDC' }] : []),
+ ...(base > ZERO ? [{ amount: sui(base), symbol: 'SUI' }] : []),
+ ]
+ const [first] = collectable
+ if (first === undefined) return undefined
+ return {
+ id: manager,
+ venueId: VENUE_ID,
+ tokenIn: first.symbol === 'USDC' ? 'SUI' : 'USDC',
+ tokenOut: first.symbol,
+ amountIn: '0',
+ remainingIn: '0',
+ receivedOut: first.amount,
+ collectable,
+ status: 'filled',
+ note: WAITING_NOTE,
+ }
+}
+
+async function openOrders(account: string, signal?: AbortSignal): Promise {
+ assertAccount(account)
+ const me = normalizeSuiAddress(account)
+ const managers = (await registeredManagers(account, signal)).filter(
+ (m): m is Manager => m !== undefined && m.owner === me
+ )
+ const rows = await Promise.all(
+ managers.map(async (manager) => {
+ const { orders, base, quote } = await readHoldings(account, manager, signal)
+ const waiting = waitingRow(manager.id, base, quote)
+ return [
+ ...orders.map((order) => restingOrder(manager.id, order)),
+ ...(waiting === undefined ? [] : [waiting]),
+ ]
+ })
+ )
+ return rows.flat()
+}
+
+/** An order's id here: its manager, then the order. The manager alone is the row of waiting money. */
+function parseOrderKey(orderId: string): { manager: string; order?: bigint } {
+ const match = /^(0x[0-9a-fA-F]{64})(?::(\d{1,39}))?$/.exec(orderId)
+ const manager = match?.[1]
+ if (manager === undefined) refused('That is not a DeepBook order.')
+ const order = match?.[2] === undefined ? undefined : BigInt(match[2])
+ if (order !== undefined && order > MAX_U128) refused('That is not a DeepBook order.')
+ return { manager: normalizeSuiAddress(manager), ...(order === undefined ? {} : { order }) }
+}
+
+function amounts(base: bigint, quote: bigint): string {
+ return [
+ ...(base > ZERO ? [`${sui(base)} SUI`] : []),
+ ...(quote > ZERO ? [`${usdc(quote)} USDC`] : []),
+ ].join(' and ')
+}
+
+async function prepareExit(
+ account: string,
+ orderId: string,
+ action: 'cancel' | 'collect'
+): Promise {
+ assertAccount(account)
+ const key = parseOrderKey(orderId)
+ const manager = await mustRead(() => readManager(key.manager), 'that order')
+ if (manager === undefined) refused('That is not a DeepBook order.')
+ if (manager.owner !== normalizeSuiAddress(account)) {
+ refused('That order belongs to another wallet.')
+ }
+ const { orders, base, quote } = await mustRead(() => readHoldings(account, manager), 'that order')
+
+ const tx = new Transaction()
+ tx.setSender(account)
+ const signing = (transaction: string) => ({ family: 'sui' as const, transaction })
+
+ if (action === 'collect') {
+ if (base === ZERO && quote === ZERO) {
+ refused('There is nothing waiting in that DeepBook account to collect.')
+ }
+ addExit(tx, { account, manager })
+ const inSui = base > ZERO
+ return {
+ venueId: VENUE_ID,
+ venueName: VENUE_NAME,
+ kind: 'limit',
+ summary: `Move ${amounts(base, quote)} from your DeepBook account to your wallet.`,
+ pay: { amount: '0', symbol: inSui ? 'SUI' : 'USDC' },
+ receive: {
+ amount: inSui ? sui(base) : usdc(quote),
+ symbol: inSui ? 'SUI' : 'USDC',
+ approximate: false,
+ },
+ notes: ['Any order still open on this account stays open.'],
+ signing: signing(await built(tx, 'SUI')),
+ }
+ }
+
+ if (key.order === undefined) {
+ refused('That is money waiting in your DeepBook account, so there is nothing to cancel.')
+ }
+ const order = orders.find((o) => o.id === key.order)
+ if (order === undefined) {
+ refused('That order is no longer open. If it filled, Collect brings the money back.')
+ }
+ const { isBid, price } = decodeOrderId(order.id)
+ const left = order.quantity - order.filled
+ const backBase = isBid ? base : base + left
+ const backQuote = isBid ? quote + quoteOf(left, price) : quote
+ addExit(tx, { account, manager, order: order.id })
+ return {
+ venueId: VENUE_ID,
+ venueName: VENUE_NAME,
+ kind: 'limit',
+ summary: `Cancel your DeepBook order and bring ${amounts(backBase, backQuote)} back to your wallet.`,
+ pay: { amount: '0', symbol: isBid ? 'USDC' : 'SUI' },
+ receive: {
+ amount: isBid ? usdc(quoteOf(left, price)) : sui(left),
+ symbol: isBid ? 'USDC' : 'SUI',
+ approximate: false,
+ },
+ notes: [
+ 'This also brings back anything else waiting in this DeepBook account.',
+ 'It could fill before this lands. Whatever filled comes back to your wallet too.',
+ ],
+ signing: signing(await built(tx, 'SUI')),
+ }
+}
+
+export function deepbookBook(): LimitBook {
+ return { openOrders, prepareExit }
+}
diff --git a/apps/dapp/lib/chains/sui/deepbook-listing.ts b/apps/dapp/lib/chains/sui/deepbook-listing.ts
index e5a47564..58902897 100644
--- a/apps/dapp/lib/chains/sui/deepbook-listing.ts
+++ b/apps/dapp/lib/chains/sui/deepbook-listing.ts
@@ -10,5 +10,5 @@ export const deepbookListing: Venue = {
bestFor: "Swaps straight against Sui's on-chain order book",
integration: 'executes',
capability:
- 'Swaps SUI, USDC and USDsui at market, and USDT when its thin book can fill. Limit orders are not connected yet',
+ 'Swaps SUI, USDC and USDsui at market, and USDT when its thin book can fill. Limit orders are listed apart, as DeepBook Limit Orders',
}
diff --git a/apps/dapp/lib/chains/sui/deepbook-rules.ts b/apps/dapp/lib/chains/sui/deepbook-rules.ts
new file mode 100644
index 00000000..83ca16f6
--- /dev/null
+++ b/apps/dapp/lib/chains/sui/deepbook-rules.ts
@@ -0,0 +1,597 @@
+import { bcs } from '@mysten/sui/bcs'
+import { coinWithBalance, type Transaction } from '@mysten/sui/transactions'
+import { normalizeStructTag, normalizeSuiAddress } from '@mysten/sui/utils'
+
+import { limitPair } from '../limit'
+import {
+ ChainActionRefused,
+ tokenBySymbol,
+ toBaseUnits,
+ type ChainToken,
+ type SigningPayload,
+} from '../types'
+import {
+ DEEPBOOK_CALLS,
+ DEEPBOOK_MANAGER_TYPE,
+ DEEPBOOK_PACKAGE,
+ DEEPBOOK_POLICY,
+ DEEPBOOK_POOL,
+ DEEPBOOK_POOL_VERSION,
+ DEEPBOOK_POST_ONLY,
+ DEEPBOOK_REGISTRY,
+ DEEPBOOK_REGISTRY_VERSION,
+ DEEPBOOK_SELF_MATCHING,
+ DEEPBOOK_SUI,
+ DEEPBOOK_USDC,
+} from './policies'
+import {
+ assertPtbSafe,
+ moveTarget,
+ objectReaderFor,
+ parsePtb,
+ readerFor,
+ type ReadObject,
+ type ReadPure,
+} from './safety'
+import { displayAmount, SUI_TOKENS } from './tokens'
+
+type TxData = ReturnType
+type TxCommand = TxData['commands'][number]
+type TxArgument = Extract['MoveCall']['arguments'][number]
+
+/** Move's fixed-point scale. A price is quote units per base unit, times this. */
+const FLOAT_SCALE = BigInt(1_000_000_000)
+const SUI_DECIMALS = 9
+const USDC_DECIMALS = 6
+const ZERO = BigInt(0)
+
+function refused(sentence: string): never {
+ throw new ChainActionRefused(`${sentence} Nothing was signed or sent.`)
+}
+
+const ten = (n: number): bigint => BigInt(10) ** BigInt(n)
+
+/** The pool's sizes, in base units. Always read from the chain: the indexer has disagreed with it. */
+export interface PoolBook {
+ tick: bigint
+ lot: bigint
+ min: bigint
+}
+
+export interface OrderFigures {
+ /** On the tick. */
+ price: bigint
+ /** SUI in base units, on the lot. */
+ quantity: bigint
+ /** What goes into the manager: SUI for a sell, USDC for a buy. */
+ deposit: bigint
+ /** What it brings once it has filled: USDC for a sell, SUI for a buy. */
+ receive: bigint
+}
+
+function baseUnits(amount: string, decimals: number): bigint {
+ try {
+ return BigInt(toBaseUnits(amount, decimals))
+ } catch (e) {
+ if (e instanceof ChainActionRefused) refused(e.message)
+ throw e
+ }
+}
+
+/**
+ * The order to place, from what the person said. A sell's price rounds up to the tick and a buy's
+ * rounds down, so neither ever trades worse than the price stated, and a quantity off the lot rounds
+ * down. A buy deposits only what its rounded quantity holds, so nothing is left idle in the manager.
+ */
+export function orderFigures(input: {
+ amount: string
+ price: string
+ selling: boolean
+ book: PoolBook
+}): OrderFigures {
+ const { amount, selling, book } = input
+ const text = input.price.trim()
+ if (!/^\d+(\.\d+)?$/.test(text)) refused(`"${input.price}" is not a price.`)
+ const [whole = '0', fraction = ''] = text.split('.')
+ const top = BigInt(`${whole}${fraction}`) * ten(USDC_DECIMALS)
+ const bottom = ten(fraction.length) * book.tick
+ const ticks = selling ? (top + bottom - BigInt(1)) / bottom : top / bottom
+ const price = ticks * book.tick
+ if (price === ZERO) refused('That price is too small for DeepBook.')
+
+ const given = baseUnits(amount, selling ? SUI_DECIMALS : USDC_DECIMALS)
+ const wanted = selling ? given : (given * FLOAT_SCALE) / price
+ const quantity = (wanted / book.lot) * book.lot
+ if (quantity < book.min) {
+ refused(
+ `DeepBook's smallest SUI order is ${displayAmount(book.min.toString(), SUI_DECIMALS, SUI_DECIMALS)} SUI.`
+ )
+ }
+ const quote = (quantity * price) / FLOAT_SCALE
+ return selling
+ ? { price, quantity, deposit: quantity, receive: quote }
+ : { price, quantity, deposit: quote, receive: quantity }
+}
+
+/** What each side of a new order is, or a refusal for any pair but SUI against USDC. */
+export function deepbookPair(
+ tokenIn: string,
+ tokenOut: string
+): { input: ChainToken; output: ChainToken; selling: boolean } {
+ const input = tokenBySymbol(SUI_TOKENS, tokenIn)
+ const output = tokenBySymbol(SUI_TOKENS, tokenOut)
+ const pair = limitPair(tokenIn, tokenOut)
+ const symbols = [input?.symbol, output?.symbol].sort().join('/')
+ if (input === undefined || output === undefined || pair === undefined || symbols !== 'SUI/USDC') {
+ refused('DeepBook orders here take SUI against USDC only.')
+ }
+ return { input, output, selling: pair.selling }
+}
+
+/** Where a shared manager was first shared, which a transaction names along with its id. */
+export interface ManagerRef {
+ id: string
+ version: string
+}
+
+export interface PlacePlan {
+ /** `new` when this order creates the manager, and shares it last. */
+ manager: ManagerRef | 'new'
+ selling: boolean
+ figures: OrderFigures
+ expiry: number
+}
+
+const pool = (tx: Transaction) =>
+ tx.sharedObjectRef({
+ objectId: DEEPBOOK_POOL,
+ initialSharedVersion: DEEPBOOK_POOL_VERSION,
+ mutable: true,
+ })
+
+const managerInput = (tx: Transaction, manager: ManagerRef) =>
+ tx.sharedObjectRef({
+ objectId: manager.id,
+ initialSharedVersion: manager.version,
+ mutable: true,
+ })
+
+/**
+ * The whole order in one transaction. A manager created here is an ordinary value until the last step
+ * shares it, so the steps before can use it by its result. See `deepbook.ts` for the simulation.
+ */
+export function addPlace(tx: Transaction, plan: PlacePlan): void {
+ const { selling, figures, expiry } = plan
+ const coin = tx.add(
+ selling
+ ? coinWithBalance({ balance: figures.deposit })
+ : coinWithBalance({ type: DEEPBOOK_USDC, balance: figures.deposit, useGasCoin: false })
+ )
+ const manager =
+ plan.manager === 'new'
+ ? tx.moveCall({ target: DEEPBOOK_CALLS.newManager })
+ : managerInput(tx, plan.manager)
+ tx.moveCall({
+ target: DEEPBOOK_CALLS.deposit,
+ typeArguments: [selling ? DEEPBOOK_SUI : DEEPBOOK_USDC],
+ arguments: [manager, coin],
+ })
+ const proof = tx.moveCall({ target: DEEPBOOK_CALLS.proof, arguments: [manager] })
+ tx.moveCall({
+ target: DEEPBOOK_CALLS.place,
+ typeArguments: [DEEPBOOK_SUI, DEEPBOOK_USDC],
+ arguments: [
+ pool(tx),
+ manager,
+ proof,
+ tx.pure.u64(0),
+ tx.pure.u8(DEEPBOOK_POST_ONLY),
+ tx.pure.u8(DEEPBOOK_SELF_MATCHING),
+ tx.pure.u64(figures.price),
+ tx.pure.u64(figures.quantity),
+ tx.pure.bool(!selling),
+ tx.pure.bool(false),
+ tx.pure.u64(expiry),
+ tx.object.clock(),
+ ],
+ })
+ if (plan.manager !== 'new') return
+ tx.moveCall({
+ target: DEEPBOOK_CALLS.register,
+ arguments: [
+ manager,
+ tx.sharedObjectRef({
+ objectId: DEEPBOOK_REGISTRY,
+ initialSharedVersion: DEEPBOOK_REGISTRY_VERSION,
+ mutable: true,
+ }),
+ ],
+ })
+ tx.moveCall({
+ target: DEEPBOOK_CALLS.share,
+ typeArguments: [DEEPBOOK_MANAGER_TYPE],
+ arguments: [manager],
+ })
+}
+
+/**
+ * Brings what a manager holds back to the account. A fill stays in the pool's record for the manager
+ * until it is settled, and `withdraw_all` alone would leave it there: cancelling settles it, and
+ * collecting has to ask. `order` is an order to cancel, or nothing to collect.
+ */
+export function addExit(
+ tx: Transaction,
+ input: { account: string; manager: ManagerRef; order?: bigint | undefined }
+): void {
+ const manager = managerInput(tx, input.manager)
+ const proof = tx.moveCall({ target: DEEPBOOK_CALLS.proof, arguments: [manager] })
+ const types = { typeArguments: [DEEPBOOK_SUI, DEEPBOOK_USDC] }
+ if (input.order === undefined) {
+ tx.moveCall({
+ target: DEEPBOOK_CALLS.settle,
+ ...types,
+ arguments: [pool(tx), manager, proof],
+ })
+ } else {
+ tx.moveCall({
+ target: DEEPBOOK_CALLS.cancel,
+ ...types,
+ arguments: [pool(tx), manager, proof, tx.pure.u128(input.order), tx.object.clock()],
+ })
+ }
+ const coins = [DEEPBOOK_SUI, DEEPBOOK_USDC].map((coin) =>
+ tx.moveCall({
+ target: DEEPBOOK_CALLS.withdrawAll,
+ typeArguments: [coin],
+ arguments: [manager],
+ })
+ )
+ tx.transferObjects(coins, input.account)
+}
+
+const VecSetOfIds = bcs.struct('VecSet', { contents: bcs.vector(bcs.Address) })
+
+const ManagerLayout = bcs.struct('BalanceManager', {
+ id: bcs.Address,
+ owner: bcs.Address,
+ balances: bcs.struct('Bag', { id: bcs.Address, size: bcs.u64() }),
+ allow_listed: VecSetOfIds,
+})
+
+const BalancesLayout = bcs.struct('Balances', {
+ base: bcs.u64(),
+ quote: bcs.u64(),
+ deep: bcs.u64(),
+})
+
+const AccountLayout = bcs.struct('Account', {
+ epoch: bcs.u64(),
+ open_orders: bcs.struct('VecSet', { contents: bcs.vector(bcs.u128()) }),
+ taker_volume: bcs.u128(),
+ maker_volume: bcs.u128(),
+ active_stake: bcs.u64(),
+ inactive_stake: bcs.u64(),
+ created_proposal: bcs.bool(),
+ voted_proposal: bcs.option(bcs.Address),
+ unclaimed_rebates: BalancesLayout,
+ settled_balances: BalancesLayout,
+ owed_balances: BalancesLayout,
+})
+
+const OrderLayout = bcs.struct('Order', {
+ balance_manager_id: bcs.Address,
+ order_id: bcs.u128(),
+ client_order_id: bcs.u64(),
+ quantity: bcs.u64(),
+ filled_quantity: bcs.u64(),
+ fee_is_deep: bcs.bool(),
+ order_deep_price: bcs.struct('OrderDeepPrice', {
+ asset_is_base: bcs.bool(),
+ deep_per_asset: bcs.u64(),
+ }),
+ epoch: bcs.u64(),
+ status: bcs.u8(),
+ expire_timestamp: bcs.u64(),
+})
+
+/** The owner, and the ids of any caps that can act on the manager without being its owner. */
+export function decodeManager(bytes: Uint8Array): { owner: string; caps: string[] } {
+ const manager = ManagerLayout.parse(bytes)
+ return {
+ owner: normalizeSuiAddress(manager.owner),
+ caps: manager.allow_listed.contents,
+ }
+}
+
+export function decodeIds(bytes: Uint8Array): string[] {
+ return VecSetOfIds.parse(bytes).contents.map((id) => normalizeSuiAddress(id))
+}
+
+/** What fills have left in the pool's record for a manager, which is not in the manager yet. */
+export function decodeSettled(bytes: Uint8Array): { base: bigint; quote: bigint } {
+ const { base, quote } = AccountLayout.parse(bytes).settled_balances
+ return { base: BigInt(base), quote: BigInt(quote) }
+}
+
+export interface BookOrder {
+ id: bigint
+ quantity: bigint
+ filled: bigint
+ expires: number
+}
+
+export function decodeOrders(bytes: Uint8Array): BookOrder[] {
+ return bcs
+ .vector(OrderLayout)
+ .parse(bytes)
+ .map((order) => ({
+ id: BigInt(order.order_id),
+ quantity: BigInt(order.quantity),
+ filled: BigInt(order.filled_quantity),
+ expires: Number(order.expire_timestamp),
+ }))
+}
+
+/** An order's id holds its side and price: see `encode_order_id` in DeepBook's `utils.move`. */
+export function decodeOrderId(id: bigint): { isBid: boolean; price: bigint } {
+ return {
+ isBid: id >> BigInt(127) === ZERO,
+ price: (id >> BigInt(64)) & ((BigInt(1) << BigInt(63)) - BigInt(1)),
+ }
+}
+
+/** Quote units for a base quantity at a price, rounded down as DeepBook's `math::mul` is. */
+export const quoteOf = (quantity: bigint, price: bigint): bigint => (quantity * price) / FLOAT_SCALE
+
+const FUNDING_CALLS: ReadonlySet = new Set([
+ moveTarget('0x2', 'coin', 'redeem_funds'),
+ moveTarget('0x2', 'coin', 'destroy_zero'),
+ moveTarget('0x2', 'coin', 'send_funds'),
+])
+
+const targetOf = (command: TxCommand): string =>
+ command.$kind === 'MoveCall'
+ ? moveTarget(command.MoveCall.package, command.MoveCall.module, command.MoveCall.function)
+ : command.$kind
+
+const isFunding = (command: TxCommand): boolean =>
+ command.$kind === 'SplitCoins' ||
+ command.$kind === 'MergeCoins' ||
+ FUNDING_CALLS.has(targetOf(command))
+
+function isResultOf(argument: TxArgument | undefined, index: number): boolean {
+ if (argument?.$kind === 'Result') return argument.Result === index
+ return (
+ argument?.$kind === 'NestedResult' &&
+ argument.NestedResult[0] === index &&
+ argument.NestedResult[1] === 0
+ )
+}
+
+interface Step {
+ index: number
+ args: readonly TxArgument[]
+ types: readonly string[]
+}
+
+/** The steps other than funding, held to exactly the targets expected, in order. */
+function stepsOf(data: TxData, expected: readonly string[], what: string): Step[] {
+ const steps = data.commands.flatMap((command, index) =>
+ isFunding(command) ? [] : [{ command, index }]
+ )
+ const same =
+ steps.length === expected.length &&
+ steps.every(({ command }, i) => targetOf(command) === expected[i])
+ if (!same) refused(`This is not ${what} you asked for: its steps are not the ones expected.`)
+ return steps.map(({ command, index }) => ({
+ index,
+ args: command.$kind === 'MoveCall' ? command.MoveCall.arguments : [],
+ types: command.$kind === 'MoveCall' ? command.MoveCall.typeArguments : [],
+ }))
+}
+
+interface PlaceContext {
+ manager: string
+ side: string
+ price: string
+ quantity: string
+ deposit: string
+ expiry: string
+}
+
+function placeContext(context: Record | undefined): PlaceContext {
+ const figure = (key: string): string => {
+ const value = context?.[key]
+ if (value === undefined || !/^\d{1,20}$/.test(value)) {
+ refused('This order lost track of what you asked for, so I stopped here.')
+ }
+ return value
+ }
+ const manager = context?.['manager']
+ const side = context?.['side']
+ if (manager === undefined || (side !== 'sell' && side !== 'buy')) {
+ refused('This order lost track of what you asked for, so I stopped here.')
+ }
+ return {
+ manager,
+ side,
+ price: figure('price'),
+ quantity: figure('quantity'),
+ deposit: figure('deposit'),
+ expiry: figure('expiry'),
+ }
+}
+
+function pureU64(read: ReadPure, argument: TxArgument | undefined): string | undefined {
+ const bytes = read(argument)
+ if (bytes === undefined) return undefined
+ try {
+ return bcs.u64().parse(bytes)
+ } catch {
+ return undefined
+ }
+}
+
+/**
+ * The one coin split for the deposit, which must be exactly the amount the order holds. A split coin
+ * only grows by being merged into, and the coin's own merges target the wallet's coins, so one that
+ * touches the split is what would put more in than the card says.
+ */
+function assertDeposit(
+ data: TxData,
+ read: ReadPure,
+ coin: TxArgument | undefined,
+ amount: string
+): void {
+ const splits = data.commands.flatMap((command, index) =>
+ command.$kind === 'SplitCoins' ? [{ split: command.SplitCoins, index }] : []
+ )
+ const [only] = splits
+ if (
+ splits.length !== 1 ||
+ only === undefined ||
+ only.split.amounts.length !== 1 ||
+ !isResultOf(coin, only.index) ||
+ pureU64(read, only.split.amounts[0]) !== amount ||
+ data.commands.some(
+ (command) =>
+ command.$kind === 'MergeCoins' &&
+ [command.MergeCoins.destination, ...command.MergeCoins.sources].some((merged) =>
+ isResultOf(merged, only.index)
+ )
+ )
+ ) {
+ refused('This order deposits a different amount than it needs.')
+ }
+}
+
+function assertPlace(
+ data: TxData,
+ read: ReadPure,
+ readObject: ReadObject,
+ context: Record | undefined
+): void {
+ const want = placeContext(context)
+ const isNew = want.manager === 'new'
+ const steps = stepsOf(
+ data,
+ [
+ ...(isNew ? [DEEPBOOK_CALLS.newManager] : []),
+ DEEPBOOK_CALLS.deposit,
+ DEEPBOOK_CALLS.proof,
+ DEEPBOOK_CALLS.place,
+ ...(isNew ? [DEEPBOOK_CALLS.register, DEEPBOOK_CALLS.share] : []),
+ ],
+ 'the limit order'
+ )
+ const [created] = steps
+ const afterNew = isNew ? steps.slice(1) : steps
+ const [deposit, proof, place, register, share] = afterNew as [Step, Step, Step, Step?, Step?]
+
+ const isManager = (argument: TxArgument | undefined): boolean =>
+ isNew
+ ? created !== undefined && isResultOf(argument, created.index)
+ : readObject(argument) === normalizeSuiAddress(want.manager)
+ const used = [deposit.args[0], proof.args[0], place.args[1], register?.args[0], share?.args[0]]
+ const expected = isNew ? used : used.slice(0, 3)
+ if (!expected.every(isManager)) {
+ refused('This uses a different DeepBook account than the one it should.')
+ }
+ if (!isResultOf(place.args[2], proof.index)) {
+ refused('This order was not proved by the account it trades from.')
+ }
+
+ const selling = want.side === 'sell'
+ if (normalizeStructTag(deposit.types[0] ?? '') !== (selling ? DEEPBOOK_SUI : DEEPBOOK_USDC)) {
+ refused('This order deposits the wrong token.')
+ }
+ assertDeposit(data, read, deposit.args[1], want.deposit)
+
+ if (pureU64(read, place.args[6]) !== want.price) {
+ refused('This order has a different price than the one you asked for.')
+ }
+ if (pureU64(read, place.args[7]) !== want.quantity) {
+ refused('This order is for a different amount than the one you asked for.')
+ }
+ const isBid = read(place.args[8])
+ if (isBid === undefined || isBid.length !== 1 || isBid[0] !== (selling ? 0 : 1)) {
+ refused('This order is on the other side from the one you asked for.')
+ }
+ if (pureU64(read, place.args[10]) !== want.expiry) {
+ refused('This order has a different expiry than the one it was built with.')
+ }
+}
+
+function assertExit(data: TxData, readObject: ReadObject): void {
+ const middle = data.commands.some((c) => targetOf(c) === DEEPBOOK_CALLS.cancel)
+ ? DEEPBOOK_CALLS.cancel
+ : DEEPBOOK_CALLS.settle
+ if (data.commands.some(isFunding)) {
+ refused('This is not the exit you asked for: its steps are not the ones expected.')
+ }
+ const [proof, pooled, first, second, transfer] = stepsOf(
+ data,
+ [
+ DEEPBOOK_CALLS.proof,
+ middle,
+ DEEPBOOK_CALLS.withdrawAll,
+ DEEPBOOK_CALLS.withdrawAll,
+ 'TransferObjects',
+ ],
+ 'the exit'
+ ) as [Step, Step, Step, Step, Step]
+
+ const manager = readObject(proof.args[0])
+ const sameManager =
+ manager !== undefined &&
+ readObject(pooled.args[1]) === manager &&
+ readObject(first.args[0]) === manager &&
+ readObject(second.args[0]) === manager
+ if (!sameManager) refused('This uses a different DeepBook account than the one it should.')
+ if (!isResultOf(pooled.args[2], proof.index)) {
+ refused('This exit was not proved by the account it takes from.')
+ }
+
+ const coins = [first, second].map((step) => normalizeStructTag(step.types[0] ?? '')).sort()
+ const objects = data.commands[transfer.index]?.TransferObjects?.objects ?? []
+ const bothReturned =
+ coins[0] !== coins[1] &&
+ objects.length === 2 &&
+ objects.some((o) => isResultOf(o, first.index)) &&
+ objects.some((o) => isResultOf(o, second.index))
+ if (!bothReturned) refused('This does not bring both tokens back to your wallet.')
+}
+
+/**
+ * Passes only what this file would have built for this account: `assertPtbSafe` holds each call to
+ * what it may do, and this holds the whole to its steps and figures. A placed order's figures are
+ * compared with `context`, which shows only that the two agree, for the reason `limoAssertSafe` gives:
+ * anyone holding the wallet's signature can broadcast these bytes directly. So only the tests of
+ * `orderFigures` bind them to the person's words. An exit needs no context: a manager's money can only
+ * go to the sender, and only its owner can sign for it.
+ */
+export function deepbookAssertSafe(
+ payload: SigningPayload,
+ account: string,
+ context?: Record | undefined
+): void {
+ assertPtbSafe(payload, account, DEEPBOOK_POLICY)
+ if (payload.family !== 'sui') return
+ const data = parsePtb(payload.transaction)
+ const readObject = objectReaderFor(data)
+ if (data.commands.some((c) => targetOf(c) === DEEPBOOK_CALLS.place)) {
+ assertPlace(data, readerFor(data), readObject, context)
+ } else {
+ assertExit(data, readObject)
+ }
+}
+
+export const DEEPBOOK_READS = {
+ bookParams: moveTarget(DEEPBOOK_PACKAGE, 'pool', 'pool_book_params'),
+ tradeParams: moveTarget(DEEPBOOK_PACKAGE, 'pool', 'pool_trade_params'),
+ accountExists: moveTarget(DEEPBOOK_PACKAGE, 'pool', 'account_exists'),
+ account: moveTarget(DEEPBOOK_PACKAGE, 'pool', 'account'),
+ orders: moveTarget(DEEPBOOK_PACKAGE, 'pool', 'get_account_order_details'),
+ balance: moveTarget(DEEPBOOK_PACKAGE, 'balance_manager', 'balance'),
+ managerIds: moveTarget(DEEPBOOK_PACKAGE, 'registry', 'get_balance_manager_ids'),
+} as const
diff --git a/apps/dapp/lib/chains/sui/module.ts b/apps/dapp/lib/chains/sui/module.ts
index 57c09e25..15a7d042 100644
--- a/apps/dapp/lib/chains/sui/module.ts
+++ b/apps/dapp/lib/chains/sui/module.ts
@@ -5,6 +5,7 @@ import { aftermathVenue } from './aftermath'
import { readSuiBalances } from './balances'
import { cetusVenue } from './cetus'
import { deepbookVenue } from './deepbook'
+import { deepbookBook, deepbookLimitVenue } from './deepbook-limit'
import { momentumVenue } from './momentum'
import { naviVenue } from './navi'
import { priceFromLlama } from './price'
@@ -29,6 +30,7 @@ export function buildSuiVenues(
springsuiVenue,
aftermathVenue,
walrusVenue,
+ deepbookLimitVenue,
...(canIssueOneClick(env) ? [createSendVenue('sui')] : []),
]
}
@@ -46,6 +48,7 @@ export const suiModule: ChainModule = {
stake: 'springsui',
unstake: 'springsui',
send: SEND_VENUE_ID,
+ limit: 'deepbook-limit',
},
venueForToken: {
stake: { WAL: 'walrus' },
@@ -54,4 +57,5 @@ export const suiModule: ChainModule = {
priceUsd: (token) => priceFromLlama(token),
submit: (input) => submitSui(input),
balances: readSuiBalances,
+ limitBook: deepbookBook(),
}
diff --git a/apps/dapp/lib/chains/sui/policies.ts b/apps/dapp/lib/chains/sui/policies.ts
index b49aa204..afe5a23e 100644
--- a/apps/dapp/lib/chains/sui/policies.ts
+++ b/apps/dapp/lib/chains/sui/policies.ts
@@ -3,7 +3,13 @@ import { normalizeStructTag, normalizeSuiAddress } from '@mysten/sui/utils'
import { ChainActionRefused } from '../types'
import { cetusOverlay, MAX_SLIPPAGE_BPS } from './cetus-rules'
-import { moveTarget, type ParsedCall, type PtbPolicy, type ReadPure } from './safety'
+import {
+ moveTarget,
+ type ParsedCall,
+ type PtbPolicy,
+ type ReadObject,
+ type ReadPure,
+} from './safety'
import { SUI_TOKENS } from './tokens'
/**
@@ -168,3 +174,149 @@ export const NAVI_POLICY: PtbPolicy = {
venueName: 'NAVI',
allowedCalls: new Set([moveTarget(NAVI_PACKAGE, 'incentive_v3', 'entry_deposit')]),
}
+
+/**
+ * DeepBook core, version 9: the latest package, which is what calls target. `@mysten/deepbook-v3`
+ * 2.10.1's `mainnetPackageIds`, and calling it succeeded in simulation on 5 November 2026. An upgrade
+ * blocks this venue until the new id is checked and added here.
+ */
+export const DEEPBOOK_PACKAGE = '0xbca0cefe720330e7bb4f2283438b68de6a0e656688db4ac90d61e71fe6151fe8'
+/** The original package, which is where the types are defined, so where objects say they are from. */
+const DEEPBOOK_TYPES = '0x2c8d603bc51326b8c13cef9dd07031a408a48dddb541963357661df5d3204809'
+export const DEEPBOOK_MANAGER_TYPE = `${DEEPBOOK_TYPES}::balance_manager::BalanceManager`
+export const DEEPBOOK_REGISTRY =
+ '0xaf16199a2dff736e9f07a845f23c5da6df6f756eddb631aed9d24a93efc4549d'
+export const DEEPBOOK_REGISTRY_VERSION = '336155480'
+/** SUI/USDC, the one pool used. Read off the SDK's `mainnetPools` and checked on chain. */
+export const DEEPBOOK_POOL = '0xe05dafb5133bcffb8d59f4e12465dc0e9faeaa05e3e342a08fe135800e3e4407'
+export const DEEPBOOK_POOL_VERSION = '389750322'
+export const DEEPBOOK_SUI = normalizeStructTag('0x2::sui::SUI')
+export const DEEPBOOK_USDC = normalizeStructTag(
+ '0xdba34672e30cb065b1f93e3ab55318768fd6fef66c15942c9f7cb846e2f900e7::usdc::USDC'
+)
+/** The Move constant `POST_ONLY`: the order aborts rather than fill any part at once. */
+export const DEEPBOOK_POST_ONLY = 3
+/** The Move constant `SELF_MATCHING_ALLOWED`. It only governs a fill, and this order never takes one. */
+export const DEEPBOOK_SELF_MATCHING = 0
+/** Seven days, as built, plus room for the time between building it and it landing. */
+const DEEPBOOK_MAX_EXPIRY_MS = 8 * 24 * 60 * 60 * 1000
+
+export const DEEPBOOK_CALLS = {
+ newManager: moveTarget(DEEPBOOK_PACKAGE, 'balance_manager', 'new'),
+ deposit: moveTarget(DEEPBOOK_PACKAGE, 'balance_manager', 'deposit'),
+ proof: moveTarget(DEEPBOOK_PACKAGE, 'balance_manager', 'generate_proof_as_owner'),
+ register: moveTarget(DEEPBOOK_PACKAGE, 'balance_manager', 'register_balance_manager'),
+ withdrawAll: moveTarget(DEEPBOOK_PACKAGE, 'balance_manager', 'withdraw_all'),
+ place: moveTarget(DEEPBOOK_PACKAGE, 'pool', 'place_limit_order'),
+ cancel: moveTarget(DEEPBOOK_PACKAGE, 'pool', 'cancel_order'),
+ settle: moveTarget(DEEPBOOK_PACKAGE, 'pool', 'withdraw_settled_amounts'),
+ share: moveTarget('0x2', 'transfer', 'public_share_object'),
+} as const
+
+function normalizedTypes(types: readonly string[]): string[] | undefined {
+ try {
+ return types.map((t) => normalizeStructTag(t))
+ } catch {
+ return undefined
+ }
+}
+
+function assertDeepbookPool(call: ParsedCall, readObject: ReadObject): void {
+ if (readObject(call.arguments[0]) !== DEEPBOOK_POOL) {
+ fail('This order is on a different market than the one you asked for.')
+ }
+ const [base, quote, ...rest] = normalizedTypes(call.typeArguments) ?? []
+ if (base !== DEEPBOOK_SUI || quote !== DEEPBOOK_USDC || rest.length !== 0) {
+ fail('This order trades other tokens than SUI and USDC.')
+ }
+}
+
+function deepbookNumber(read: ReadPure, call: ParsedCall, index: number, what: string): bigint {
+ const bytes = read(call.arguments[index])
+ if (bytes === undefined) return fail(`I couldn't read the ${what} on this order.`)
+ try {
+ return BigInt(bcs.u64().parse(bytes))
+ } catch {
+ return fail(`I couldn't read the ${what} on this order.`)
+ }
+}
+
+/**
+ * What holds for every DeepBook call whatever was asked. Who owns the manager is not in the bytes, and
+ * a deposit, proof or withdrawal aborts for anyone but its owner. `deepbookAssertSafe` then holds the
+ * transaction to the figures it was built with.
+ */
+function checkDeepbookCall(
+ call: ParsedCall,
+ read: ReadPure,
+ _account: string,
+ readObject: ReadObject
+): void {
+ switch (call.target) {
+ case DEEPBOOK_CALLS.place: {
+ assertDeepbookPool(call, readObject)
+ const orderType = read(call.arguments[4])
+ if (
+ orderType === undefined ||
+ orderType.length !== 1 ||
+ orderType[0] !== DEEPBOOK_POST_ONLY
+ ) {
+ fail('This order could fill at once, and it must only rest.')
+ }
+ const matching = read(call.arguments[5])
+ if (
+ matching === undefined ||
+ matching.length !== 1 ||
+ matching[0] !== DEEPBOOK_SELF_MATCHING
+ ) {
+ fail('This order matches against your own in a way I did not check.')
+ }
+ const payWithDeep = read(call.arguments[9])
+ if (payWithDeep === undefined || payWithDeep.length !== 1 || payWithDeep[0] !== 0) {
+ fail('This order pays its fee in a way I did not check.')
+ }
+ const expiry = deepbookNumber(read, call, 10, 'expiry')
+ if (expiry > BigInt(Date.now() + DEEPBOOK_MAX_EXPIRY_MS)) {
+ fail('This order lasts past the 8 days I allow.')
+ }
+ return
+ }
+ case DEEPBOOK_CALLS.cancel:
+ case DEEPBOOK_CALLS.settle:
+ assertDeepbookPool(call, readObject)
+ return
+ case DEEPBOOK_CALLS.deposit:
+ case DEEPBOOK_CALLS.withdrawAll: {
+ const [coin, ...rest] = normalizedTypes(call.typeArguments) ?? []
+ if ((coin !== DEEPBOOK_SUI && coin !== DEEPBOOK_USDC) || rest.length !== 0) {
+ fail('This order trades other tokens than SUI and USDC.')
+ }
+ return
+ }
+ case DEEPBOOK_CALLS.register:
+ if (readObject(call.arguments[1]) !== DEEPBOOK_REGISTRY) {
+ fail('This transaction writes to a different DeepBook registry.')
+ }
+ return
+ case DEEPBOOK_CALLS.share: {
+ const [shared, ...rest] = normalizedTypes(call.typeArguments) ?? []
+ if (shared !== normalizeStructTag(DEEPBOOK_MANAGER_TYPE) || rest.length !== 0) {
+ fail('This transaction shares something other than your DeepBook account.')
+ }
+ return
+ }
+ default:
+ return
+ }
+}
+
+/**
+ * Exactly what placing, cancelling and collecting use. Left out, because each hands control of a
+ * manager to another address: `mint_trade_cap`, `mint_deposit_cap`, `mint_withdraw_cap` and
+ * `new_with_custom_owner`. The framework's `public_share_object` is allowed only for a manager.
+ */
+export const DEEPBOOK_POLICY: PtbPolicy = {
+ venueName: 'DeepBook',
+ allowedCalls: new Set(Object.values(DEEPBOOK_CALLS)),
+ checkCall: checkDeepbookCall,
+}
diff --git a/apps/dapp/lib/chains/sui/safety.ts b/apps/dapp/lib/chains/sui/safety.ts
index 763b33c7..0037b4d5 100644
--- a/apps/dapp/lib/chains/sui/safety.ts
+++ b/apps/dapp/lib/chains/sui/safety.ts
@@ -18,12 +18,15 @@ export interface ParsedCall {
/** The bytes of a literal argument, or nothing when the value comes from elsewhere in the transaction. */
export type ReadPure = (argument: TxArgument | undefined) => Uint8Array | undefined
+/** The id of an object argument, or nothing when the value comes from elsewhere in the transaction. */
+export type ReadObject = (argument: TxArgument | undefined) => string | undefined
+
export interface PtbPolicy {
venueName: string
/** Normalized `package::module::function`. A package alone is too coarse: see below. */
allowedCalls: ReadonlySet
/** Checks the arguments of an allowed call, where they can name a recipient or a limit. */
- checkCall?: (call: ParsedCall, read: ReadPure, account: string) => void
+ checkCall?: (call: ParsedCall, read: ReadPure, account: string, readObject: ReadObject) => void
}
export function moveTarget(pkg: string, module: string, fn: string): string {
@@ -50,7 +53,7 @@ function refuse(reason: string): never {
throw new ChainActionRefused(`${reason} ${NOTHING_SENT}`)
}
-function parse(transaction: string): TxData {
+export function parsePtb(transaction: string): TxData {
// Only BCS bytes. The JSON form can hold unresolved values that are not what gets signed.
if (transaction === '' || !/^[A-Za-z0-9+/]+={0,2}$/.test(transaction)) {
refuse('I could not read this as a Sui transaction.')
@@ -62,7 +65,7 @@ function parse(transaction: string): TxData {
}
}
-function readerFor(data: TxData): ReadPure {
+export function readerFor(data: TxData): ReadPure {
return (argument) => {
if (argument === undefined || argument.$kind !== 'Input') return undefined
const input = data.inputs[argument.Input]
@@ -71,6 +74,22 @@ function readerFor(data: TxData): ReadPure {
}
}
+export function objectReaderFor(data: TxData): ReadObject {
+ return (argument) => {
+ if (argument === undefined || argument.$kind !== 'Input') return undefined
+ const input = data.inputs[argument.Input]
+ if (input?.$kind !== 'Object') return undefined
+ const { Object: held } = input
+ const id =
+ held.$kind === 'SharedObject'
+ ? held.SharedObject.objectId
+ : held.$kind === 'ImmOrOwnedObject'
+ ? held.ImmOrOwnedObject.objectId
+ : held.Receiving.objectId
+ return normalizeSuiAddress(id)
+ }
+}
+
function assertPaysAccount(read: ReadPure, destination: TxArgument | undefined, me: string): void {
const bytes = read(destination)
let recipient: string | undefined
@@ -95,8 +114,9 @@ export function assertPtbSafe(payload: SigningPayload, account: string, policy:
if (payload.family !== 'sui') refuse('That is not a Sui transaction.')
if (!isValidSuiAddress(account)) refuse('That is not a Sui address.')
const me = normalizeSuiAddress(account)
- const data = parse(payload.transaction)
+ const data = parsePtb(payload.transaction)
const read = readerFor(data)
+ const readObject = objectReaderFor(data)
if (typeof data.sender === 'string' && normalizeSuiAddress(data.sender) !== me) {
refuse('This transaction would be sent from a different account than your wallet.')
@@ -133,7 +153,7 @@ export function assertPtbSafe(payload: SigningPayload, account: string, policy:
}
const args = command.MoveCall.arguments
if (target === SEND_FUNDS) assertPaysAccount(read, args[1], me)
- policy.checkCall?.({ target, typeArguments, arguments: args }, read, me)
+ policy.checkCall?.({ target, typeArguments, arguments: args }, read, me, readObject)
break
}
default:
diff --git a/apps/dapp/lib/chains/sui/venues.ts b/apps/dapp/lib/chains/sui/venues.ts
index 23901720..5599a80e 100644
--- a/apps/dapp/lib/chains/sui/venues.ts
+++ b/apps/dapp/lib/chains/sui/venues.ts
@@ -95,6 +95,18 @@ export const suiVenues: Venue[] = [
deepbookListing,
momentumListing,
walrusListing,
+ {
+ id: 'deepbook-limit',
+ family: 'sui',
+ name: 'DeepBook Limit Orders',
+ category: 'orderbook',
+ chains: ['Sui'],
+ url: 'https://deepbook.xyz',
+ bestFor: 'Selling or buying SUI at a price you name, for up to 7 days',
+ integration: 'executes',
+ capability:
+ 'Places, reads, cancels and collects SUI orders against USDC, and checks every transaction before you sign. Filled tokens wait in your DeepBook account until you collect them.',
+ },
{
id: 'springsui',
family: 'sui',
diff --git a/apps/dapp/lib/chains/types.ts b/apps/dapp/lib/chains/types.ts
index e81e6ea0..fc532733 100644
--- a/apps/dapp/lib/chains/types.ts
+++ b/apps/dapp/lib/chains/types.ts
@@ -1,4 +1,6 @@
import type { ChainSlug } from '@intent/types'
+import type { CowOrder } from './evm/cow-rules'
+import type { LimitBook } from './limit'
/**
* Doing something on a chain that is not Stellar, in three moves: prepare on the
@@ -13,6 +15,8 @@ export type ChainActionKind =
| 'supply'
| 'stake'
| 'send'
+ /** Resting an order at a price the person names, until it fills or expires. */
+ | 'limit'
| 'withdraw'
| 'unstake'
| 'borrow'
@@ -75,6 +79,8 @@ export interface ChainActionRequest {
* money goes, so it is checked and never changed.
*/
recipient?: string | undefined
+ /** Limit orders only: the price of the non-stable token in the stable one, as typed. */
+ limitPrice?: string | undefined
/** Streams only: whole days the amount is paid over. */
durationDays?: string | undefined
/** Invoices only: when it is due, as YYYY-MM-DD. */
@@ -105,17 +111,26 @@ export type SigningPayload =
/** An ERC-20 `approve` the wallet sends and sees confirmed first, when the call pulls a token. */
approval?: { to: string; data: string } | undefined
}
+ /** A message, not a transaction: signing it moves nothing until the venue's server posts it. */
+ | {
+ family: 'evm-typed'
+ chainId: number
+ account: string
+ typedData: { domain: object; types: object; primaryType: 'Order'; message: CowOrder }
+ }
| { family: 'stellar'; xdr: string; account: string }
/**
* What a wallet gives back. Solana, Sui and Stellar wallets return signed bytes the
- * server checks and submits. NEAR and EVM wallets send the transactions themselves.
+ * server checks and submits. NEAR and EVM wallets send the transactions themselves. A typed EVM
+ * message comes back as a signature the server checks and posts.
*/
export type SignedPayload =
| { family: 'solana'; signedTransaction: string }
| { family: 'sui'; transaction: string; signature: string }
| { family: 'near'; hashes: string[] }
| { family: 'evm'; hash: string }
+ | { family: 'evm-typed'; signature: string }
| { family: 'stellar'; signedXdr: string }
export type SignedOutcome =
@@ -138,6 +153,8 @@ export interface PreparedAction {
signing: SigningPayload
/** Returned unchanged at submit, for venues whose submit step needs it. */
context?: Record | undefined
+ /** A setup step: the same request must be prepared again once this one settles. */
+ thenPrepareAgain?: true
}
export interface SubmitInput {
@@ -203,7 +220,10 @@ export interface ChainVenue {
* venue's API and the browser are both outside what this server controls.
*
* `context` is what `prepare` issued, handed back by the browser. A venue that reads it
- * must prove the server wrote it before believing any of it.
+ * must prove the server wrote it before believing any of it. The exception is a venue that only
+ * compares it with the signed bytes, and whose submit does nothing else with it, as Kamino's limit
+ * orders do: anyone holding the wallet's signature can broadcast those bytes directly, so a proof
+ * would stop nothing.
*/
assertSafe: (
payload: SigningPayload,
@@ -217,6 +237,18 @@ export interface ChainVenue {
* only: nothing is built. A venue without this can't follow a swap.
*/
assertCanFollow?: ((step: FollowOnStep, account: string) => Promise) | undefined
+ /** A limit order's one-off setup cost in the native coin, kept back on top of the fee reserve. */
+ limitCost?: { amount: string; symbol: string } | undefined
+ /**
+ * For a venue whose setup is paid once per account: what this one still owes, or nothing. Takes the
+ * place of `limitCost` in the reserve, which there is only the most it can come to.
+ */
+ limitCostNow?:
+ | ((
+ request: ChainActionRequest,
+ account: string
+ ) => Promise<{ amount: string; symbol: string } | undefined>)
+ | undefined
}
/** A step that would follow a swap. The amount isn't known until the swap has delivered it. */
@@ -287,6 +319,7 @@ export interface ChainModule {
balances: (account: string, signal?: AbortSignal) => Promise
/** A sentence for a symbol this chain doesn't list but a person may well type, by upper-case symbol. */
tokenHints?: Record | undefined
+ limitBook?: LimitBook
}
/** "an Avalanche", "a Monad". */
diff --git a/apps/dapp/lib/orchestrate/orchestrate.ts b/apps/dapp/lib/orchestrate/orchestrate.ts
index f157e3fd..81b36858 100644
--- a/apps/dapp/lib/orchestrate/orchestrate.ts
+++ b/apps/dapp/lib/orchestrate/orchestrate.ts
@@ -234,6 +234,14 @@ function basePrompt(chain: string): string {
].join(' ')
}
+/** Limit orders off Stellar: written out in the person's own figures, or said to be unavailable. */
+function limitPrompt(chain: string): string {
+ if (chain === 'monad') {
+ return "There are no limit orders on this chain. If the person names a price to sell or buy at, set orderStyle to 'limit' and say so in reply; do not turn it into a swap."
+ }
+ return 'A price the person names, or \'when it hits\' or \'once it reaches\' a price, is a limit order. Set orderStyle to \'limit\' and write instruction as "sell at $" or "buy with at $", copying the amount, token and price character for character from what they typed, and never choosing a price yourself. A purchase sized in the token received, such as "get me 1 SOL at 110", cannot run here: do not write it as a sale. Ask what they will spend instead.'
+}
+
/**
* What differs off Stellar, where the opening line and the follow-ons above are written for
* Stellar. The facts below list what this chain can run, and the app checks everything signed
@@ -244,6 +252,7 @@ function offStellarPrompt(chain: string): string {
return [
'The person is on the chain named in the facts below, not on Stellar.',
"followOn is 'lend' only for a deposit that the facts list under Swap then deposit, and 'offramp' only for a cash-out that the facts list under Swap then cash out. Anything else that follows a swap, such as a send or a stake, is not a followOn: write the whole request in instruction.",
+ limitPrompt(chain),
`When a swap is followed by something, write the instruction as one sentence like "Swap 5 ${own} to USDC, then send to ADDRESS", with every amount, token and address copied character for character from what the person typed. Never invent, shorten or correct an address, and never add an amount they did not give.`,
].join(' ')
}
diff --git a/apps/dapp/lib/weave/cashout-view.ts b/apps/dapp/lib/weave/cashout-view.ts
index 93043c73..8fff818e 100644
--- a/apps/dapp/lib/weave/cashout-view.ts
+++ b/apps/dapp/lib/weave/cashout-view.ts
@@ -199,7 +199,10 @@ export function cleanIdentifier(text: string): string {
type SigningFamily = SigningPayload['family']
-/** These wallets broadcast for themselves, so an error back may follow a transfer that went out. */
+/**
+ * These wallets broadcast for themselves, so an error back may follow a transfer that went out. A
+ * typed EVM message is only signed, and goes nowhere until the server posts it.
+ */
export function walletSends(family: SigningFamily): boolean {
return family === 'near' || family === 'evm'
}
diff --git a/apps/dapp/public/images/venues/cow-limit.webp b/apps/dapp/public/images/venues/cow-limit.webp
new file mode 100644
index 00000000..0c74df26
Binary files /dev/null and b/apps/dapp/public/images/venues/cow-limit.webp differ
diff --git a/apps/dapp/public/images/venues/deepbook-limit.webp b/apps/dapp/public/images/venues/deepbook-limit.webp
new file mode 100644
index 00000000..9c07c021
Binary files /dev/null and b/apps/dapp/public/images/venues/deepbook-limit.webp differ
diff --git a/apps/dapp/public/images/venues/kamino-limit.webp b/apps/dapp/public/images/venues/kamino-limit.webp
new file mode 100644
index 00000000..afd8ba0d
Binary files /dev/null and b/apps/dapp/public/images/venues/kamino-limit.webp differ
diff --git a/apps/dapp/public/images/venues/rhea-limit.webp b/apps/dapp/public/images/venues/rhea-limit.webp
new file mode 100644
index 00000000..d13a6089
Binary files /dev/null and b/apps/dapp/public/images/venues/rhea-limit.webp differ
diff --git a/docs/research/2026-11-05-limit-orders/evm.md b/docs/research/2026-11-05-limit-orders/evm.md
new file mode 100644
index 00000000..8603ae40
--- /dev/null
+++ b/docs/research/2026-11-05-limit-orders/evm.md
@@ -0,0 +1,174 @@
+# Limit orders on Avalanche (CoW) and Monad: research report
+
+Date: 2026-11-05. Read-only: SDK tarballs, public RPC reads, CoW API GET and one POST /quote (a quote creates no order). No wallet, no .env.local, no repo edits.
+Tags: [V] verified live or from SDK source, [D] from docs only, [U] unverified.
+
+## A. Avalanche with CoW Protocol
+
+### Verdict: BUILDABLE. Main work is adding typed-data signing; the protocol side is live and proven.
+
+### A1. Live status [V]
+
+- API base https://api.cow.fi/avalanche/api/v1 answers. GET /version returned v2.386.0@9302627.
+- Chain id 43114 (AVALANCHE in @cowprotocol/sdk-config 2.11.2, SupportedChainId). cow-sdk 9.3.3 is current on npm.
+- Settlement GPv2Settlement 0x9008D19f58AAbD9eD0D60971565AA8510560ab41. Code present on Avalanche RPC. Same address on all CoW chains (https://docs.cow.fi/cow-protocol/reference/contracts/core, which lists Avalanche).
+- Vault relayer GPv2VaultRelayer 0xC92E8bdf79f0507f65a392b0ab4667716BFE0110. Confirmed by calling vaultRelayer() on the Avalanche settlement contract.
+- WAVAX 0xB31f66AA3C1e785363F0875A1B74E27b85FD66c7 (SDK wrapped-native table). USDC 0xB97EF9Ef8734C71904D8002F8b6Bc66Dd9c48a6E is accepted by the API (native_price returns).
+- Eth-flow contract 0xba3cb449bd2b4adddbc894d8697f5170800eadec (SDK ETH_FLOW_ADDRESSES applied to all chains). Code present on Avalanche. The SDK maps it to every chain, so treat Avalanche use as [V] for code, [U] for behaviour (no native order seen).
+- Live quote: POST /quote, sell 1 WAVAX to USDC, returned buyAmount 8728855 (8.73 USDC), feeAmount 1387297418112970 wei, protocolFeeBps "2", verified true.
+
+### A2. Live evidence of fills [V]
+
+Trade events on the settlement contract in the last ~30k blocks: 6 trades. For three, GET /orders/{uid} returned status "fulfilled", for example:
+
+- 0xec15a509...a7e5fd2dff540b19e542a92cb824aac9ebef05c9586c46aec6bf4 (owner 0xd2dff5..., created 2026-11-05 11:14Z, executedSellAmount 200034, executedFee 17884, class "limit", AAVE v4 swap with hooks).
+- 0x9e31cc36...6646 and 0x80f42c75...6677 (owner 0x44afcc..., Safe Wallet Swaps, partner fee 70 bps, fulfilled).
+ Caveat: these are market-intent swaps; the API tags every order class "limit" because fees are surplus-based. I did not find a user-set limit order far from market. Fill mechanics are the same. A human limit order resting for days on Avalanche is [U].
+ Liquidity is thin: 6 trades in about 30k blocks, so a limit order may wait. Solver 0x8cd4beaa... seen on quotes.
+
+### A3. Flow
+
+1. Approval (EVM tx). ERC-20 approve(vaultRelayer 0xC92E...0110, amount) on the sell token. Exact amount, not unlimited. A new tx type is not needed; the current `{family:'evm', to, data, value:'0'}` path covers it.
+2. Sell token must be an ERC-20. Native AVAX is not directly sellable by a signed order.
+ - Option 1: wrap to WAVAX first (WAVAX.deposit() payable, one tx), then approve, then sign.
+ - Option 2: eth-flow contract. SDK getEthFlowTransaction calls createOrder on the eth-flow contract with the AVAX as value. This is an on-chain order, one tx, no signature. Cancel is on-chain invalidateOrder; the order is invalidated by its creator or by anyone after expiry, and auto-refund applies only at >=2% slippage (https://docs.cow.fi/cow-protocol/reference/contracts/periphery/eth-flow). Eth-flow orders are sell-only, with buy token an ERC-20.
+ - Receiving AVAX (buying native): buyToken 0xEeeeeEeeeEeEeeEeEeEeeEEEeeeeEeeeeeeeEEeE (BUY_ETH_ADDRESS in contracts-ts) [D/V in SDK].
+3. Quote (optional but wise): POST /quote to get fee and a reference price. For a limit order the signed feeAmount must be "0"; the solver takes a surplus fee in the sell token (executedFee seen on filled orders) [V from orders, rule from SDK trading code].
+4. EIP-712 sign. Domain `{name:"Gnosis Protocol", version:"v2", chainId:43114, verifyingContract:0x9008...ab41}` (contracts-ts static domain()). Primary type `Order`, fields in order [V, ORDER_TYPE_FIELDS]:
+ sellToken address, buyToken address, receiver address, sellAmount uint256, buyAmount uint256, validTo uint32, appData bytes32, feeAmount uint256, kind string ("sell"/"buy"), partiallyFillable bool, sellTokenBalance string ("erc20"), buyTokenBalance string ("erc20").
+ ORDER_TYPE_HASH 0xd5a25ba2e97094ad7d83dc28a6572da797d6b3e7fc6663bd93efb789fc17e489.
+ Note: in the API JSON, appData is the full JSON string and appDataHash the bytes32; the signed field is the hash. Create/stash the app-data JSON (PUT /app_data/{hash} or send fullAppData with the order). GET /app_data/0x00..00 returns "{}", so the empty hash works.
+5. POST /api/v1/orders with: sellToken, buyToken, receiver, sellAmount, buyAmount, validTo, appData (+appDataHash), feeAmount "0", kind, partiallyFillable, sellTokenBalance, buyTokenBalance, signingScheme "eip712", signature (65-byte hex), from. Response is the order UID (56 bytes: digest 32 + owner 20 + validTo 4). UID = hashOrder(domain, order) + owner + validTo; compute it locally and compare to the response.
+6. Limit semantics: sell order = sellAmount fixed, buyAmount is the minimum out; buy order = buyAmount fixed, sellAmount is the maximum in. The price a person types sets buyAmount. Any fill at or above that is surplus to them.
+7. Smart-contract wallets: use EIP-1271 or presign (setPreSignature on settlement, on-chain tx). EOAs use eip712 (or ethsign).
+
+### A4. Cancellation
+
+- Off-chain (free): DELETE /api/v1/orders, body `{orderUids:[...], signature, signingScheme:"eip712"}`; typed data primary `OrderCancellations{bytes[] orderUids}` with the same domain (CANCELLATIONS_TYPE_FIELDS, hashOrderCancellations). Best-effort: a solver already settling can still fill it. [V in SDK; exact DELETE body is [U] — read from order-book SDK before building]. Cancel pages at docs.cow.fi returned 404 for the URL I guessed.
+- On-chain: GPv2Settlement.invalidateOrder(bytes orderUid) from the owner. Definitive, costs gas. Eth-flow orders: invalidateOrder on the eth-flow contract.
+- Expiry cancels everything on its own (validTo).
+
+### A5. Reading state
+
+- GET /orders/{uid}: status (open, fulfilled, cancelled, expired, presignaturePending), executedSellAmount, executedBuyAmount, executedFee, invalidated, creationDate. [V]
+- GET /trades?orderUid=… or ?owner=… (exactly one) for fills. [V: error text confirmed]
+- GET /account/{owner}/orders (also 200 empty for unseen owner via /orders?owner returned empty) for the open list. [U for exact path]
+- GET /users/{owner}/total_surplus works. [V]
+- Fills can be partial when partiallyFillable true; executedBuyAmount rises per fill.
+
+### A6. Fees, minimums, partial fills, expiry
+
+- Fee: no fee up front for the person; surplus fee in sell token on fill (covers gas); protocol fee 2 bps on the quote (protocolFeeBps "2"); partner fee optional in appData (e.g. 25 or 70 bps by integrators). If we want revenue, it is an appData partnerFee with our recipient, which must be shown to the person. [V]
+- Min size: no hard minimum, but API rejects below fee. Tests on WAVAX to USDC: 1e14 wei and 1e12 wei both returned SellAmountDoesNotCoverFee, fee about 0x3c6739e8db42d (about 0.0010 WAVAX, roughly $0.009) at 1e14. Practically a few dollars minimum so fee is not a large share. [V]
+- Partial fills: partiallyFillable:true supported by protocol; default SDK false. A partly-fillable limit order can fill in pieces over days. [D/V SDK]
+- Expiry: validTo uint32 unix seconds. SDK default 30 min for market (DEFAULT_QUOTE_VALIDITY); limit default in SDK is far-dated; MAX_VALID_TO_EPOCH in sdk-config. API max validity for limit orders: [U].
+- Price oracle for "good price" guidance: GET /token/{addr}/native_price [V].
+
+### A7. What the wallet flow must add (repo)
+
+Today: `SigningPayload` (apps/dapp/lib/chains/types.ts:40) has an `evm` variant that is a transaction only (`to,data,value`); `signOn` in apps/dapp/lib/adapters/evm-adapter.ts:96 calls wagmi sendTransaction and returns `{family:'evm', hash}`. `SignedPayload` has no signature variant. Submit routes only expect hashes (`EVM_HASH` in send.ts etc.).
+Needed:
+
+1. New SigningPayload variant, e.g. `{family:'evm-typed', chainId, typedData:{domain,types,primaryType,message}, account}` and a SignedPayload `{family:'evm-typed', signature}`.
+2. adapter: wagmi `signTypedData(wagmiConfig, {...})` after chain check; map user-rejection the same way.
+3. Prepare step returns: approval tx (existing evm payload) then typed-data payload; the sequence engine (lib/chains/sequence-flow.ts) must allow a step whose result is a signature plus a server POST.
+4. Submit route: server posts the order to CoW, computes UID and checks it matches; returns uid and an explorer link (https://explorer.cow.fi/avax/orders/{uid} [U]) instead of tx hash. SubmitResult's `hash` becomes an order id; a 'pending' outcome is natural.
+5. Status route for open orders; a cancel flow (second typed-data sign for off-chain, or evm tx for invalidateOrder).
+6. Native AVAX: wrap tx first (existing evm path), or eth-flow tx (existing path, value set).
+7. SC wallets: not supported without EIP-1271/presign; detect via code at account and refuse, or use presign tx.
+
+### A8. What `assertSafe` must verify before signing (server before showing, client again before sign)
+
+Because the typed data comes from our server and the wallet shows the user raw hex, the check has to be done in our code, on the exact `message` to be signed:
+
+- domain.name "Gnosis Protocol", version "v2", chainId 43114, verifyingContract exactly 0x9008D19f...ab41 (compared lowercase). A wrong verifyingContract makes the signature useless but a wrong chain could replay elsewhere.
+- sellToken and buyToken equal the token addresses the user asked for (listed, never by symbol); sell is not equal to buy.
+- receiver equals the signer account (or the explicit recipient the user typed). The zero address means "owner" in the protocol; refuse it anyway so what is shown matches.
+- sellAmount and buyAmount: recompute from the user's stated amount and limit price in base units; buyAmount >= typed price x sellAmount for sell orders; for buy orders sellAmount <= the cap. Reject if it is better than the live native_price by an absurd margin (typo guard) and warn if the order would fill at once (market) or be 10x away.
+- kind and partiallyFillable match the request.
+- validTo: in the future, not more than the user's chosen expiry, not in the past, not uint32 max unless "no expiry" was chosen. Show it as a date.
+- feeAmount "0".
+- sellTokenBalance and buyTokenBalance both "erc20" (not "external"/"internal", which draw on Balancer vault balances).
+- appData: hash matches the JSON we gave; JSON has no hooks (pre/post hooks run arbitrary calls from the settlement-time executor, a real risk, as the AAVE order above uses them) and only the partner fee we disclose.
+- Approval tx: `to` is the sell token, selector approve(0x095ea7b3), spender exactly vault relayer 0xC92E...0110, amount equals sellAmount (not max), value 0. Wrap tx: to WAVAX with deposit() only. Eth-flow: to the eth-flow address, value equals sellAmount, receiver equals the owner.
+- At submit: re-derive the order UID from the signed message and compare with what the API returned; check the returned owner equals the account.
+- Wrong-chain protection: wallet chain 43114 before signing (the adapter already switches).
+
+### A9. Risks and what a person can lose
+
+- Order does not move funds until a solver fills at or above the limit; funds stay in the wallet (non-custodial). Loss exposure is the approved amount (use exact approvals; an unlimited approval stays open to the relayer for good).
+- Stale limit: a resting order signs a price that holds until validTo. A big price move makes it fill at a price the person no longer wants (picking-off risk). Mitigate with short default expiry and a cancel button.
+- Off-chain cancel is not instant: a fill in flight can still land. Use on-chain invalidate for certainty.
+- AVAX gas dropping to zero leaves a person unable to approve/cancel; fills are paid by the solver out of the surplus fee.
+- Thin Avalanche liquidity: 6 trades in about 30k blocks; limits may not fill and quote depth is limited.
+- Phishing via malicious typed data (wrong spender or domain) is the main client-side risk; covered by A8.
+- CoW is a hosted order book run by CoW DAO; if the API is down, the order stops being discoverable but it remains valid until validTo, and could still be settled if a solver has it; only on-chain invalidate reliably stops it.
+- Native AVAX path via eth-flow: refund depends on invalidation (user or after expiry).
+
+## B. Monad (chain 143)
+
+### Verdict: NOT SAFELY BUILDABLE TODAY for a general limit order. Kuru is the only real candidate; a build is possible but I would not ship it without an audit and clearer admin facts.
+
+### B1. CoW on Monad [V]
+
+- https://api.cow.fi/monad/api/v1/version returns 404 (nginx). sdk-config 2.11.2 has no Monad chain (grep for 143 gave a block number only). docs list no Monad. GPv2Settlement code at 0x9008... exists on Monad RPC but CoW runs no API there; do not treat that as support. CoW: NOT SUPPORTED.
+- 1inch, 0x, UniswapX, Bebop on Monad: web search found no evidence, [U]. Monday Trade (Uniswap v3-like AMM plus order book) exists [U], https://learn.backpack.exchange/articles/what-is-monday-trade ; not examined.
+
+### B2. Kuru (best candidate) [D unless marked]
+
+Docs reflect a new "Spot V2" system; the docs were last modified 2026-10-19 (sitemap) — about 17 days old. Old docs (llms.txt, /contracts/Audits, /contracts/Contract-addresses) now return 404 or list old architecture (MarginAccount/Router, "Audits: Spearbit, Cantina" in the stale llms.txt). Do not trust the old audit line for V2.
+
+- Network: chain id 143 [V, eth_chainId 0x8f from rpc.monad.xyz].
+- Contracts (https://docs.kuru.io/deployments/mainnet.md):
+ - AccountCore proxy 0x264123a9B96Cc1193944cC25C188f1bD3dFa670e (custody, deposits)
+ - SpotRouter proxy 0xBdDF3556701B7ECca5d69416CFfd7F86D37E52b3
+ - WithdrawalLimiter proxy 0xF059F4AAb49Bd3AFB62134145EeAF38Fe103F0eA
+ - ProtocolAuthority 0x15db88bAc73D856B3ca53683F82058826A3b6E8f; KeeperAuthority 0xbc36eC07800150489f9ED0f86a2d3E27Ca36980e
+ - Nine OrderBook markets, e.g. MON/USDC 0xc9dE998e272d59a19CEA3783C8dd44f87A86405D, WETH/USDC 0x8121EE960066e4344A213F9aC7502395502aEe7a, cbBTC/USDC 0xAE8E0cf4A9ff696b5f6EC0B1339200EB4c09E422 (others in doc: XAUt0, NVDAx, SPYx, QQQx, TSLAx, EWYx).
+ - USDC on Monad 0x754704Bc059F8C67012fEd69BC8A327a5aafb603 [D, https://docs.monad.xyz/guides/kuru-flow].
+- On-chain checks [V]: AccountCore, SpotRouter and markets are all 141-byte minimal proxies (ERC-1967 implementation slot style), so upgradeable. owner() of AccountCore, SpotRouter, ProtocolAuthority = 0x64b86d1d212357f7127d1e86740c897167bbf024, a 12 KB contract (not a Safe with getThreshold; type unknown, [U] whether timelock or multisig). KeeperAuthority owner 0x8b736dce... is a small contract (172 bytes). Market owner = SpotRouter.
+- Flow:
+ 1. If ERC-20 sell: approve(AccountCore, amount) on the token. Native MON: deposit with value, token address(0).
+ 2. AccountCore.deposit(address rootOwner, address token, uint256 amount) payable (first deposit creates the root account, uint40 id; rootAccountIdOf(owner)).
+ 3. Market.batch(uint40 userId, (uint8 side, uint96 quantity, uint32 price, uint8 tif, uint8 executionInstruction, uint32 minSizeAfterBlock)[] orders, uint8[] cancelSlotIdxs) — owner sends the tx directly; our wallet path already supports this. Prices and sizes are in market precision; read getMarketParams() (pricePrecision, sizePrecision, tickSize) and make price % tick == 0.
+ 4. tif: GTC rests; IOC/FOK immediate. executionInstruction postOnly keeps it from crossing. Partial fills allowed; GTC remainder rests.
+ 5. Cancel: batch with cancelSlotIdxs, or cancelAllOrders(uint40 userId), by owner tx. Immediate and definitive when mined (on-chain book).
+ 6. Withdraw: AccountCore.withdraw(uint40 rootAccountId, address token, uint256 amount, address recipient). Free (non-reserved) funds only.
+ 7. Read: getMarketParams, order state events (BookUpdatesPacked, TradesPacked), REST https://api.mainnet.kuru.io (data source), gateway https://gateway.mainnet.kuru.io (needs wallet signature + JWT, [U] details). Docs say direct reads of balances/orders are via those APIs; exact endpoints [U].
+- Fees [D]: maker 1 bp, taker 2 bps at base tier, tiers lower with 14-day volume; GTC post-only keeps a person as maker; resting bids reserve quote plus maker fee. Builder fee optional (needs account approval, approveBuilder).
+- Minimums [D]: market-specific minQuoteNotional for GTC (value not published; read on-chain, [U]); maxQuoteNotional applies; tiny resting orders are cancelled as dust.
+- Self-custody caveat: funds sit inside AccountCore (Kuru's contract) while an order is open; withdrawal passes a WithdrawalLimiter: bucket $200,000 shared across all users, refill about $8,333/hour, so a rush of withdrawals can be throttled; governance can change limits; docs mention freezes and a protocol pause with no stated controller (https://docs.kuru.io/accounts/withdrawal-availability.md).
+- Audit status [U/negative]: no audit report found for V2 (AccountCore, SpotRouter). Cantina bounty (https://cantina.xyz/bounties/f93c6818-141e-43a0-941f-5f56d606ad6f) started 2025-11-24, max $50k, currently paused, scope not visible. DefiLlama TVL for Kuru CLOB was about $544k when searched (https://defillama.com/protocol/kuru-clob). Thin TVL and fresh upgradeable contracts with an unknown owner.
+- SDK: @toxicflow-labs/ts-sdk (docs example) — an unusual package name, I did not inspect it, [U] whether Kuru-official. Do not take a dependency on it; we can ABI-encode batch ourselves.
+- One-click trading / 7702 / relay features exist; we would not use them (they add signers and 7702 delegation).
+
+### B3. What a build would need (if later chosen)
+
+Tx-only, no typed data: approve to AccountCore, deposit, batch, later cancelAllOrders and withdraw: five tx types, all already expressible as `{family:'evm'}`. assertSafe: every tx to a fixed allowlist (AccountCore, one market, token), selector allowlist (approve, deposit, batch, cancelAllOrders, withdraw), approve spender = AccountCore exact amount, withdraw recipient = signer, the batch userId must equal the account's own rootAccountIdOf read on chain, side/price/quantity re-derived from the request and tick-checked, tif GTC with postOnly where the person asked for a resting order. State reading needs the Kuru API or an event indexer.
+
+### B4. Risks / what a person can lose
+
+- Custody in an upgradeable, unaudited-as-far-as-found contract: the whole deposit (not only the order) is exposed to a bug, a malicious upgrade, or a pause/freeze while funds are inside.
+- Withdrawal limiter can delay exits in a stress event.
+- Thin liquidity on most markets; rebase/price manipulation on small books; a resting order can be picked off (like any CLOB).
+- Tokenised-stock markets (NVDAx, TSLAx, SPYx...) add issuer and off-hours risk; we would only offer MON/USDC, WETH/USDC, cbBTC/USDC at most.
+- Prices and size use raw precision: wrong scale means an order at a price off by powers of ten (a fat-finger class loss); must be derived and checked from getMarketParams.
+
+## Summary table
+
+| Chain | Verdict | Biggest risk |
+| --------------- | ------------------- | ---------------------------------------------------------------------------------------------------- |
+| Avalanche (CoW) | Buildable | needs typed-data signing in wallet flow; thin fills; stale-price exposure (funds never leave wallet) |
+| Monad (Kuru) | Not recommended now | deposit goes into an upgradeable contract with no found audit, fresh V2, withdrawal throttle |
+| Monad (CoW) | Not supported | API 404 |
+
+## Sources
+
+- https://docs.cow.fi/cow-protocol/reference/contracts/core
+- https://docs.cow.fi/cow-protocol/reference/contracts/periphery/eth-flow
+- https://api.cow.fi/avalanche/api/v1 (live calls: version, quote, orders, native_price, app_data, total_surplus, trades error)
+- npm @cowprotocol/sdk-config 2.11.2, @cowprotocol/sdk-contracts-ts 4.0.1, @cowprotocol/sdk-trading 2.8.1, @cowprotocol/cow-sdk 9.3.3
+- https://docs.kuru.io/deployments/mainnet.md, /contracts/order-book.md, /contracts/account-core.md, /matching-engine/order-types.md, /matching-engine/fees.md, /sdk/orders-and-cancellation.md, /accounts/withdrawal-availability.md, /accounts/one-click-trading.md, /api/introduction.md, https://docs.kuru.io/sitemap.xml
+- https://cantina.xyz/bounties/f93c6818-141e-43a0-941f-5f56d606ad6f
+- https://docs.monad.xyz/guides/kuru-flow
+- Repo: apps/dapp/lib/chains/types.ts, apps/dapp/lib/adapters/evm-adapter.ts
diff --git a/docs/research/2026-11-05-limit-orders/near.md b/docs/research/2026-11-05-limit-orders/near.md
new file mode 100644
index 00000000..1572dc38
--- /dev/null
+++ b/docs/research/2026-11-05-limit-orders/near.md
@@ -0,0 +1,110 @@
+# NEAR limit orders on Rhea/Ref DCL (dclv2.ref-labs.near) - research, 2026-11-05
+
+## Corrections from the build, 2026-11-05
+
+Read off mainnet with nothing signed (`near-rhea-dcl.live.test.ts`, plus real transactions on this pool read through `EXPERIMENTAL_tx_status`). Where this differs from the report below, this section is right.
+
+- **No sign flip on the pool's axis.** The UI's `-point` only turns its helper's "B per A" price into the pool's y-per-x, so what goes in `msg` is one axis either way: USDC per NEAR = 1e18 / 1.0001^point, whichever token is sold. Selling NEAR rests below `current_point`, buying NEAR above it. Real resting orders agree: #8665 sells USDC at 415439 (0.9091 USDC per NEAR) against a market at about 401300, and the book near the market has only sell-NEAR orders below the point and sell-USDC above it. A fill matched the point to 8 digits: order #16180, 130.344448 NEAR at 401041 bought 500.004597 USDC, or 3.8360253 per NEAR against 3.8360253 at the point, so no maker fee shows.
+- **A purchase pays out in native NEAR, not wNEAR.** `cancel_order {order_id, amount}` makes the contract call `wrap.near.near_withdraw` and transfer native NEAR. Seen on this pool with `amount: "0"` in `3b2gaLoDMLtDhR6YvjWeCGo2jN7r2XNXnXbo3PkxD5nL` (500 NEAR bought) and `9ToL4GV74Z3oggetFvHb6LdogxSRd2QFDR1yZf1S1tK4`. A cancel returning sold wNEAR does the same (`23U4s6vFXWTf47HzR6QwqgddmyMDpKrHL7adGkYBuzUJ`, on another pool). There is an undocumented `skip_unwrap_near: true` to keep wNEAR, which market makers pass. This app doesn't, and refuses it. So the section 3 note, "proceeds come back as wNEAR", is wrong for this call.
+- **Collect and cancel pay the wallet.** USDC bought arrives by `ft_transfer` in the same transaction (`4MAGH8AXzcMgPjU5nC8ZDXRo6AjAVdF5SUady6LZD9oh`). The SDK's comment about an "inner account" doesn't describe what happens here.
+- **An `amount` above what is left is cut.** In that same transaction, a request to cancel 72.4 NEAR of an order already filled logs `actual_cancel_amount: "0"`, succeeds, and pays out the fill.
+- **`bought_amount` includes `unclaimed_amount`**, and a filled order stays in `list_active_orders` with `remain_amount: "0"` until collected (#15820: 999 USDC at 400927, both 257.47 NEAR). `get_order` answers `null` once an order has closed. `unclaimed_amount` is `null` in history rows.
+- **`find_order` answers `null` when nothing rests at that point.** A second order at the same point fails rather than merges: `E301: active order already exist` is in the deployed bytes (`view_code`, hash 3G34Nh...), and `internal_add_order` in the public fork below requires it. The app refuses it first.
+- **Gas.** Storage 30 + `near_deposit` 30 + `ft_transfer_call` 250 is 310 TGas, over the 300 limit the report said it fit in. Storage goes in its own transaction anyway.
+- **Several transactions don't carry a first use safely.** NEAR Connect hands the array to the wallet, which promises neither order nor stopping after a failure, and throws with no hashes if it fails partway. Rhea's own interface does send one array. An order landing without its storage should panic (`E100: account not registered`, `E102: insufficient storage`, `E107: not enough storage for slots` are in the bytes) and NEP-141 would refund it, as wNEAR if it was wrapped. The app pays for storage alone, with `thenPrepareAgain`, then builds the order once the chain shows the storage.
+- **An order's own status doesn't say it rested.** If `ft_on_transfer` refuses, NEP-141 refunds and the transaction still succeeds. Submit looks for the order with `find_order`, and reads a miss as pending.
+- **`LimitOrder` exists beside `LimitOrderWithSwap`, and this app keeps `LimitOrderWithSwap`.** The deployed bytes list `TokenReceiverMessage::LimitOrder` with 4 fields and `LimitOrderWithSwap` with 6. No real mainnet use of the plain one was found, on 5 November 2026, read through nearblocks: 0 among 358 recent `ft_transfer_call`s naming dclv2 on USDC and wrap.near (8 pages of 25 on each), and 0 against 138 `LimitOrderWithSwap` in 5 recent pages each of six accounts that place orders on dclv2 (mmt-arb, rhealover, 9dbdd4...4bb6, i1156173191.tg, bim, doublerose). This is a bounded sample, not the whole history. So it fails the bar of live use on this pool, and its behaviour on mainnet stays unread.
+ - What is known of it, none of it from a mainnet transaction. A public fork (`shawn10359/burrowland`, `contracts/mock-dcl`, not the deployed code) has its 4 fields as `client_id, pool_id, buy_token, point`, and `internal_add_order` panics with `E202: illegal point` on a crossed point: selling x needs `point >= current_point`, selling y `point <= current_point`. A third party's notes say the same from testnet (`sleetplayground/near-kit-tool-box-skills`). If the deployed contract does this, the plain message would close the crossing race, so it is worth one real test when someone signs a first order.
+ - The same fork bounds `LimitOrderWithSwap`'s swap at the order's own point, and its 6 fields would be these 4 plus `skip_unwrap_near` and `referral_id`, the last from INTEAR's indexer and those notes. That would make the "no minimum out" in section 3 a price bound, less any pool fee. One real crossed order agrees: #15815 buys NEAR at 400894 (3.89283), and swapped 11.6% of its deposit at 3.89283. One order is not proof, so the app still refuses a crossed point.
+- **Still unverified:** whether the 0.5 NEAR storage comes back, and how the deployed contract behaves in the cases above, as its source remains unpublished. No order has been signed through this app.
+
+Verdict: BUILDABLE for USDC/wNEAR (and a few other pairs), with a larger safety.ts change than any current NEAR venue (it refuses ft_transfer_call today). Not run end to end; no tx was sent.
+
+## Sources
+
+- SDK (source): https://github.com/ref-finance/ref-sdk/blob/main/src/dcl-swap/limit-order.ts , dcl-pool.ts, swap.ts (last push 2026-02-01); also rhea-sdk (same files).
+- UI (source of the place-order call): https://github.com/ref-finance/ref-ui/blob/main/src/services/swapV3.ts (v3Swap, LimitOrderWithSwap, cancel_order, get_user_storage_detail, priceToPoint, pointToPrice).
+- Guide: https://github.com/ref-finance/guide/blob/main/near-chain-guides/swap/limit-orders.md (guide.ref.finance did not resolve from here; read via GitHub).
+- Contract Rust source: NOT found. ref-finance org has no ref-dcl repo (listed all org repos). Everything on the contract below comes from SDK/UI code plus live view calls. Unverified where noted.
+- Live RPC: rpc.mainnet.near.org view calls on dclv2.ref-labs.near.
+
+## 1. Methods and args
+
+Contract: `dclv2.ref-labs.near` (get_metadata: version 2.4.2, state Running, owner ref-finance.sputnik-dao.near, wnear_id wrap.near, protocol_fee_rate 2000 (=20% of pool fee, unverified meaning), 6432 pools, 8010 users).
+
+Place (UI swapV3.ts, LimitOrderWithSwap branch), on the SELL token contract:
+
+```
+ft_transfer_call {
+ receiver_id: "dclv2.ref-labs.near",
+ amount: ,
+ msg: JSON.stringify({ LimitOrderWithSwap: { client_id: "", pool_id, buy_token, point } })
+} gas 250 TGas, deposit 1 yoctoNEAR
+```
+
+- pool_id = "tokenX|tokenY|fee", tokens sorted lexicographically (getDCLPoolId). For USDC native: `17208628f84f5d6ad33f0da3bbbeb27ffcb398eac501a31bd6ad2011e36133a1|wrap.near|`; USDC is token_x, wNEAR token_y.
+- point sign: UI computes point for price A-by-B, then `new_point = (poolTokenX == sellToken) ? point : -point`. Point must be a multiple of the pool's point_delta (UI floors: regularizedPoint). The point is the ORDER price in the pool's x->y axis; the "LimitOrderWithSwap" name means it first swaps whatever part is already marketable, then rests the rest (original_deposit_amount vs swap_earn_amount fields confirm this).
+- Before it, UI prepends: (a) `storage_deposit` on the buy token for the user if unregistered (registration_only); (b) `storage_deposit` on dclv2 if get_user_storage_detail says more slots are needed. If the user has no record: attaches 0.5 NEAR with registration_only true. Otherwise slot_price \* max(needed,10) (+ locked_near if not sponsor). Check `get_user_storage_detail {user_id}` (returned null for a random unregistered account, so first-time = 0.5 NEAR). Unverified: whether 0.5 NEAR is refundable (the contract tracks locked_near/slots; likely refundable, not confirmed).
+- If selling NEAR: UI prepends near_deposit on wrap.near (and storage_deposit on wrap.near if unregistered).
+
+Cancel: `dclv2.ref-labs.near.cancel_order {order_id, amount}` amount = remain_amount (SDK) or omitted (UI = cancel all). 180 TGas. SDK/UI attach NO deposit (no yocto). Returns unsold tokens plus any bought tokens in the same call (guide).
+Claim: SDK `claim_order` = `cancel_order {order_id, amount:"0"}`, 180 TGas, no deposit. Pays out the bought tokens to the wallet, leaves the unfilled portion open (guide). So proceeds are NOT automatic: a fill sits as unclaimed_amount until the owner calls claim (guide: "no time limit"). Unverified: whether amount omitted vs "0" differ.
+
+Reads (all verified live):
+
+- `list_active_orders {account_id}`, `list_history_orders {account_id}`, `get_order {order_id}`, `find_order {account_id,pool_id,point}`, `get_pointorder_range {pool_id,left_point,right_point}`, `get_pool {pool_id}`, `list_pools {from_index,limit}`, `get_user_storage_detail {user_id}`.
+- Live order sample (doublerose.near): order_id `"#8665"`, point 415439, sell_token USDC, original_amount 1000000, remain_amount 1000000, bought_amount 0, unclaimed_amount 0. Fields: original_deposit_amount, swap_earn_amount, original_amount, remain_amount, cancel_amount, bought_amount (accumulated), unclaimed_amount (filled, not yet claimed). Fill state = remain_amount vs original_amount, claimable = unclaimed_amount. History order = remain_amount "0".
+- order_id format: `#`. The order id is not known at place time; find it afterwards via find_order(account,pool,point) or list_active_orders (the UI parses the tx result log).
+
+## 2. Pools and price math (live, final block ~223163188)
+
+Native USDC / wNEAR pools (current_point, NEAR price at the pool, liquidity):
+| fee | point_delta | current_point | USDC per NEAR | TVL (x USDC / y NEAR) | resting order totals |
+|---|---|---|---|---|---|
+| 100 (0.01%) | 1 | 400630 | 3.997 | 288,390 USDC / 65,101 wNEAR | 4,684 USDC / 13,582 wNEAR |
+| 2000 (0.2%) | 40 | 400609 | 4.005 | 3,913 / 84,665 | 107.6 USDC / 3,096 wNEAR |
+| 400 (0.04%) | 8 | 393696 | 8.0 (stale) | ~11 / 10 | dust |
+| 10000 (1%) | 200 | 397600 | 5.4 (stale) | 0 | none |
+Also USDT.tether-token.near / wrap.near fee 100 (point 400559, ~$4.03, ~6.4k USDT) and fee 2000. USDC/wNEAR fee 100 is the one with real depth. Fee tier list: 100, 400, 2000, 10000 (DCL_POOL_FEE_LIST). Fee -> point_delta: 100->1, 400->8, 2000->40, 10000->200 (matches live data).
+
+Conversion: raw price y/x at point p = 1.0001^p. USDC(6 dec)=x, wNEAR(24)=y: NEAR priced in USDC = 1 / (1.0001^p \* 1e6/1e24) = 1e18 / 1.0001^p ... checked: p=400630 -> 3.997 USDC/NEAR. Inverse: p = round(ln(1e18/priceUsdcPerNear)/ln(1.0001)) then floor to point_delta (fee 100: delta 1 so ~0.01% steps). Sign flips depending on which token is sold (see above); the SDK's priceToPoint/getPointByPrice does this and must be copied or called, and tested against a live order: doublerose's order sells USDC at point 415439 in pool x=USDC (sell==x so point positive): 1e18/1.0001^415439 = 0.9856e... compute in tests, i.e. a limit order selling USDC for NEAR sits at a LOWER NEAR-per-USDC rate than... (verify with a test, do not trust my sign reasoning; unverified).
+Resting depth at the fee-100 pool near the market (get_pointorder_range 399000-402000): sell-NEAR orders (amount_y) at 399922, 399052, 400608; sell-USDC orders (amount_x) at 400927 ... 401986. i.e. orders above/below the current point 400630 on the expected sides, so the book is used by real people.
+
+## 3. Fill mechanics
+
+- DCL is bin-based; an order sits at one point and fills when swaps push the pool price through that point (the pool's own swaps consume resting orders at that point; guide: partial fills happen, both Claim and Cancel enabled). Source not read; per guide + order fields. LimitOrderWithSwap also immediately swaps the marketable part at placement (swap_earn_amount).
+- Partial fills: yes (remain_amount shrinks, unclaimed_amount grows). Cancel returns unsold part plus bought part.
+- Min size: no min seen in SDK/UI code. UI only checks output >= 0.00000001 and NEAR min balance. Storage slots (see above) are the real cost. Unverified on-chain min.
+- Fees: no cancel fee (guide). Trading fee on the filled part: unverified/ presumably the pool fee tier; protocol_fee_rate 2000 in metadata.
+- Lost/stuck risks: (1) fill must be claimed by a second tx (a cancel_order amount 0) and the buy token must be registered for the user on its contract or the payout can fail; the UI registers it first. (2) Order never fills if price does not cross; ordinary. (3) ft_transfer_call refunds unused tokens if ft_on_transfer fails/errs (NEP-141), standard. (4) A tiny pool/fee tier with no liquidity (e.g. fee 10000) can have stale price and an order placed on the wrong side of price fills instantly at a bad rate through the swap part, since LimitOrderWithSwap swaps first at whatever price. Mitigation: only allow pool_ids from the app's allowlist and require point within X ticks of current_point, read from get_pool at build time.
+- wNEAR: place works on wrap.near ft_transfer_call; the UI wraps first with near_deposit. Proceeds come back as wNEAR (ERC-like ft payout), not native NEAR; the user would need a separate near_withdraw (unwrap) - check how the app's existing swap does (apps/dapp/lib/chains/near/swap.ts/tokens.ts: WRAP_NEAR is in SWAP_CONTRACTS).
+
+## 3b. Repo fit (apps/dapp/lib/chains/near/safety.ts)
+
+- safety.ts allows only calls on wrap.near/USDC/USDT whose LAST call is `ft_transfer` to a 64-hex deposit address, or a stake. Header says `ft_transfer_call` is explicitly refused. A limit order needs:
+ - New asserter e.g. assertDclLimitOrderSafe: receiverId in {wrap.near, USDC, USDT}; calls = [storage_deposit(you, registration_only) on buy token?, storage_deposit on dclv2 with a capped deposit (<= 0.5 NEAR + slots; cap e.g. 1 NEAR), near_deposit?, ft_transfer_call]; ft_transfer_call args exactly {receiver_id:"dclv2.ref-labs.near", amount, msg}; msg parsed and required to equal exact-key JSON LimitOrderWithSwap{client_id:"", pool_id from allowlist, buy_token = the other token, point multiple of delta}; deposit exactly 1; gas <= 250 TGas (total <= 300 TGas holds with near_deposit 30 + storage 30). The existing hasExactKeys / isDigits helpers fit. Multiple transactions in the UI sequence (separate storage deposit to dclv2 is a different receiverId, so it needs more than one tx; readSingleTransaction demands exactly one). Either allow a 2-step sequence or put storage_deposit(dclv2) as a separate prior transaction signed under its own asserter.
+ - Cancel and claim: receiver dclv2.ref-labs.near, method cancel_order, args {order_id, amount}, deposit 0, gas <= 180 TGas, order_id must be verified to belong to the account via get_order (owner_id == account) server-side.
+- Balances/order read: use list_active_orders via the existing rpc.ts view path.
+
+## 4. Is it live
+
+- Contract live: code_hash 3G34Nhjmir5JPYPtijaA9iCNnHDPR7h3wfC9H77woEGo, storage 27 MB, ~9.6k NEAR balance; nearblocks shows swaps going through ft_on_transfer at the time of my query (block 223163499, ~now) and 56.5M txns total. 6432 pools, 8010 users.
+- Limit orders in use: doublerose.near (a Ref operator in metadata) has active order #8665 created 2026-02 on the USDC/wNEAR fee 100 pool; real resting orders on the book (107 to 1,000 USDC per point). Order activity volume not measured (unverified how many placements this month).
+- Upgradeability: `view_access_key_list` on dclv2.ref-labs.near returns NO keys (locked account). Owner field = ref-finance.sputnik-dao.near (DAO), operators doublerose.near and bim.near. Upgrade only via the contract itself (self-upgrade by owner) / DAO proposals; a single key cannot redeploy it. Operators can presumably pause/ set state (unverified - metadata has `state: Running` and operators; I could not read the Rust).
+- Audits: BlockSec "blocksec_ref_finance_dcl_v1.0_signed.pdf" and "blocksec_ref_finance_dcl_v2.0_signed.pdf" are in https://github.com/ref-finance/guide/tree/main/.gitbook/assets (PDFs not read; unverified findings). Plus Ref Finance Security Audit 1 and 2 PDFs in the same folder.
+- Incidents: none found in this pass (did not search exhaustively). Ref's earlier Ref Exchange had an exploit history? unverified; not searched.
+
+## 5. Verdict
+
+Buildable. Mechanically simple: one ft_transfer_call to place (plus storage deposits), cancel_order to cancel or claim, list_active_orders to read. Needs: a new strict asserter, a multi-transaction sequence (storage), point math ported and tested (sign flip trap), a claim step for the user (not automatic), and wNEAR in/out handling.
+
+Main risks:
+
+1. Not automatic: a filled order pays nothing until the user claims, and the claim is a separate signature. UI must show unclaimed_amount and offer Claim (or Cancel).
+2. LimitOrderWithSwap swaps the marketable part immediately. A mis-signed point sells at market with no min_output guard. Pre-check against current_point with a tolerance, and compute with the tested converter.
+3. First use costs up to 0.5 NEAR storage deposit plus slot fees (refundability unverified).
+4. Thin liquidity outside USDC/wNEAR fee 100 and USDT/wNEAR fee 100; stale prices on fee 400/10000 pools. Allowlist pool ids.
+5. Contract source not public; behaviour known from SDK/UI and live views only. DCL v1 and v2 audited by BlockSec, owner is a DAO, no access keys.
+6. cancel/claim attach no yocto deposit in the SDK; a wallet-signed call with 0 deposit is lower risk for the asserter but should be dry-run in testing.
+
+What a person can lose: the sold amount is moved to the DEX contract; the worst real cases are a wrong point that fills at a bad rate immediately, an order never filled (cancel returns funds, no fee), unclaimed proceeds left in the contract (not lost, claimable any time), and the storage NEAR if not refundable. A contract bug or a DAO/operator pause would trap funds; the risk is bounded by audit and the large live use. Nothing here is verified by sending a transaction.
diff --git a/docs/research/2026-11-05-limit-orders/solana.md b/docs/research/2026-11-05-limit-orders/solana.md
new file mode 100644
index 00000000..a6590151
--- /dev/null
+++ b/docs/research/2026-11-05-limit-orders/solana.md
@@ -0,0 +1,98 @@
+# Kamino Limo limit orders on Solana: feasibility report (2026-11-05)
+
+Method: downloaded and read `@kamino-finance/limo-sdk@4.0.0` source from npm (tarball unpacked in scratchpad/pkg), read the program source at github.com/Kamino-Finance/limo (master), and queried mainnet read-only (api.mainnet-beta.solana.com). Nothing was signed or sent.
+
+## 1. SDK
+
+- Package: `@kamino-finance/limo-sdk`, latest 4.0.0 (published 2026-10-05; 3.1.0 on 2026-07-30). Apache-2.0. Repo: github.com/Kamino-Finance/limo-sdk (the package.json points at Kamino-Finance/limo/tree/master/limo-sdk; the standalone limo-sdk repo has src/, README, package.json).
+- Built on `@solana/kit` (Codama-generated client since 3.0.0). Dependencies: `@solana/kit` pinned EXACT `8.3.0` (our app has ^8.6.0, so expect a second nested kit copy and possible type clashes at the boundary), `@solana-program/{token,token-2022,system,compute-budget}`, `@coral-xyz/anchor ^0.28.0` and `@coral-xyz/borsh` (used only for `BN`; anchor 0.28 drags in `@solana/web3.js` v1 transitively), `decimal.js`, `bn.js`, `bs58`, plus `commander` and `dotenv` (CLI only, in `client.ts`, not exported from index). `index.ts` is just `export * from "./Limo"`.
+- Server-safe: yes in practice. No fs/net code in the exported path; only `process.env.LIMO_PROGRAM_ID` is read (utils/programId.ts), which overrides the program id if set. Do not leave that env var set in prod; the assert-safe program allow-list would catch a wrong program anyway. It is heavy (anchor + web3.js v1 in the bundle). Lighter alternative: skip the SDK and hand-build the three instructions with kit (Codama code is small, discriminators below).
+- Client: `new LimoClient(rpc: Rpc, subscription: RpcSubscriptions, globalConfig: Address | undefined, globalConfigState?, programId?)`. It needs an RpcSubscriptions object even for tx building (only used by listeners / `processTxn`). The README snippet is stale (two args).
+- Mainnet program: `LiMoM9rMhrdYrfzUCxQppvxCSG1FcrUK9G8uLq4A1GF`. Mainnet global config (found on chain, see section 4): `prod7Mog8r56sn5dPwze8nVdf94veEawdxNNqd5v7A1` (pda_authority `DJGVEH2F5DdwQVNgxtCEVSpC2Qu5sEjRmBHfa22tAnYY`, matches an Orb label "Kamino Swap Limit Orders Vault Authority"). The SDK does not hard-code it; the caller must pass it. No docs page states it (verified: kamino.com docs give no addresses).
+- Create (returns instructions, no sending):
+ - `createOrderGenericIx(user: TransactionSigner, inputMint: Address, outputMint: Address, inputAmountLamports: BN, outputAmountLamports: BN, inputMintProgramId: Address, outputMintProgramId: Address, globalConfigOverride?: Address, wrapUnwrapSol = true, withInitVault?: boolean): Promise<[Instruction[], TransactionSigner]>` (second value is a fresh order keypair that must co-sign).
+ - `createOrderGenericWithParamsIx(args: CreateOrderWithParamsArgs)` adds optional `permissionless` and `counterparty` update_order instructions.
+ - `placeBidIxs(user, quoteTokenMint, baseTokenMint, baseUiAmount: Decimal, price: Decimal, inputMintProgramId, outputMintProgramId, mintDecimals?, globalConfigOverride?)`; also `placeAskIxs`. Note: placeBid computes output as `baseUiAmount.div(price)`, a price convention we should not rely on; compute input and output raw amounts ourselves.
+ - Sending versions (`createOrderGeneric`, `placeBid`, ...) call `processTxn(user, ixs, mode, ...)`, which needs a keypair signer; not usable for a non-custodial flow. Use the `*Ix` variants and build the message ourselves.
+- Cancel: `closeOrderAndClaimTipIx(maker: TransactionSigner, order: OrderStateAndAddress, wrapUnwrapSol = true): Promise`. The same instruction cancels an open order and closes a filled one. There is no separate cancel.
+- Read:
+ - `getOrderState(orderAddress): Promise`
+ - `getAllOrdersStateAndAddressForMaker(maker: Address): Promise` (uses getProgramAccounts with memcmp on discriminator, global config and maker)
+ - `getAllOrdersDisplayForMaker(maker)`, `getOrderDisplay(address)`, `getAllOrdersDisplayForMakerInputAndOutputMints(...)`, `getLatestFilledOrders(...)`.
+ - Fill state is in the `Order` account: `initialInputAmount`, `remainingInputAmount`, `expectedOutputAmount`, `filledOutputAmount`, `tipAmount`, `numberOfFills`, `status` (0 Active, 1 Filled, 2 Cancelled), `permissionless`, `counterparty`, `lastUpdatedTimestamp`. Order account is 424 bytes, discriminator `134,173,223,185,77,86,28,51`. getProgramAccounts on a public RPC is heavy (22.7k order accounts for the prod config); in production filter by maker (memcmp offset 40) with a paid RPC, or fetch known order addresses with getMultipleAccounts.
+- Instruction discriminators (Anchor): create_order `141,54,37,207,237,210,250,215`; close_order_and_claim_tip `244,27,12,226,45,247,230,43`; update_order `54,8,208,207,34,134,239,168`. create_order data: disc(8) + input_amount u64 + output_amount u64 + order_type u8 (0 = bid).
+
+## 2. Transactions and what assert-safe must verify
+
+### Create (SDK order, wSOL input shown; `wrapUnwrapSol` true by default)
+
+1. (only if the vault for the input mint does not exist) Limo `initialize_vault` (payer = user, creates the escrow vault token account, PDA seeds `["escrow_vault", globalConfig, mint]`). User pays that rent; not refundable (the vault is shared). Common mints already have vaults.
+2. System `create_account`: payer user, new account = order keypair (co-signer), 424 bytes, 3,841,920 lamports (0.00384 SOL), owner = Limo program.
+3. If input is SOL: ATA program `create_idempotent` for the user's wSOL ATA, System `transfer` of exactly the input amount user -> wSOL ATA, Token `sync_native` (data `[17]`).
+4. Limo `create_order(input_amount, output_amount, order_type)`. Accounts: maker (signer, writable), global_config (writable), pda_authority, order, input_mint, output_mint, maker_ata (user's input ATA), input_vault (PDA), input token program, output token program, system program, event authority, program. On chain it transfers `input_amount` from maker_ata to the vault and transfers `ata_creation_cost + txn_fee_cost` lamports from the maker to the global config account (on prod: 3,000,000 + 0 = 0.003 SOL).
+5. If input is SOL: Token `close_account` of the wSOL ATA, destination = user (unwraps any leftover; note this also unwraps any pre-existing wSOL balance in that ATA).
+6. Optional: Limo `update_order` (mode 0 permissionless = true, mode 1 counterparty).
+
+- Rent / who pays back: order account rent (0.00384 SOL) returns to the maker when the order is closed (`close = maker`). The 0.003 SOL "txn fee cost" goes to the global config account. In the public program source (last commit 2025-11-26) it is NOT refunded at close; the SDK 4.0.0 `Order` struct has a newer `feePaid` field described as "refunded to the maker via close = maker", and the deployed program is newer than the public source (redeployed 2026-09-11), so whether it is refunded is UNVERIFIED. Budget for losing 0.003 SOL.
+- Correction, from simulation on 2026-11-05 (`solana-limo.live.test.ts`, read and simulate only): the 0.003 SOL is returned on close. A simulated create left the order account at 6,841,920 lamports (rent plus the fee) and the global config's balance unchanged, so on the deployed binary the fee sits on the order account, as the SDK's `feePaid` says, not in the config as step 4 above and sections 3 and 5 below say. A simulated close of a real filled order holding 6,841,920 lamports paid the maker 6,816,920, which is all of it less the 25,000 lamport network and priority fee. Not yet seen on a landed transaction.
+- Gap to handle: the SDK does not create the maker's OUTPUT token account. When the output is not wSOL the filler needs the maker's output ATA to exist (take_order's `maker_output_ata` is checked with `verify_ata`). Prod `ata_creation_cost` is 0, so the program does not subsidise it. Add an ATA-program `create_idempotent` for the output mint (owner = user) to our create tx, or fills for that order can fail. (Reasoned from source; not tested.) When the output is SOL, fills pay native SOL straight to the maker (no ATA needed).
+
+### Cancel / close
+
+1. If SOL input: ATA `create_idempotent` wSOL ATA (owner user). Otherwise ATA `create_idempotent` for the input mint ATA (owner user).
+2. Limo `close_order_and_claim_tip`. Accounts: maker (signer), order (writable, closed to maker), global_config, pda_authority, input_mint, output_mint, maker_input_ata, input_vault, input token program, system program, event authority, program. On chain: returns `remaining_input_amount` from the vault to maker_input_ata, pays `tip_amount` lamports from pda_authority to the maker, closes the order account and refunds its rent to the maker. Fails if `now < last_updated_timestamp + order_close_delay_seconds` (0 on prod now), if the order is in a flash operation, or if status is already Cancelled.
+3. If SOL: Token `close_account` of the wSOL ATA to user (unwraps).
+
+- For a filled order the maker's output has ALREADY been paid at fill time (to the maker's output ATA, or native SOL). Closing only returns leftover input, the tips, and rent. Filled orders stay as 424-byte accounts (22,375 of 22,739 prod order accounts are status Filled) until the maker closes them, so the app should offer "close" for filled orders to recover 0.00384 SOL plus tips.
+
+### What the pre-sign decoder should verify (create)
+
+Existing policy shape `programs: {id: {discriminators}}`, `mayWrapSol` fits. Additional checks needed because limo is not a token transfer the current code understands:
+
+- Program allow-list: Limo `LiMoM9...`, System, Token (or Token-2022), ATA, ComputeBudget. Nothing else.
+- Limo create_order: decode the data and require input_amount == requested raw amount, output_amount == requested raw output (price = output/input, check both), order_type == 0.
+- Account checks on that instruction: maker == the signer/owner; global_config == `prod7Mog8...` (pin it; a different config means a different admin and filler set, and a hostile config could hold or block funds); pda_authority == PDA(["authority", globalConfig]); input_vault == PDA(["escrow_vault", globalConfig, inputMint]); maker_ata == the owner's ATA for inputMint; input_mint and output_mint == the requested mints; token programs match the mints' owners; order account == the key of the extra order signer and is the account created by the create_account instruction (space 424, owner Limo, lamports 3,841,920 +/- config, payer = user).
+- Other instructions: System `transfer` only to the user's own wSOL ATA, amount <= input amount (the existing wrap check); no System transfer to any other address except the global-config fee transfer which happens inside the Limo CPI (so it will not appear as a top-level instruction; cap it by simulating, or by reading global_config `txn_fee_cost + ata_creation_cost` and bounding the user's SOL loss to input + rent + fee). Token `close_account` destination must be the user. ATA creates must have owner = user (or the user's own key). No Token `approve`, `set_authority`, or `transfer` to third parties.
+- Optional update_order: allow only mode 0 with value 1 (permissionless) if we ever enable it; refuse mode 1 (counterparty) unless we set it intentionally.
+- Order keypair: the tx has two signers (user + order key); the existing check that only the user must sign needs an exception for exactly one fresh ephemeral signer that is the new order account. The server must hold that key only for the signing step (or generate it client-side) and it holds no value.
+- Verify the price: the dapp should decode and show input mint/amount, output mint/min amount, and the implied price from the two u64s, using mint decimals from chain.
+
+### What to verify (cancel)
+
+close_order_and_claim_tip: maker == user; order account is one the app read from chain with `maker == user` (fetch and compare; the program enforces has_one maker, so a wrong order simply fails); maker_input_ata is the user's own ATA (token::authority = maker is enforced on chain, but check anyway); pda_authority and vault PDAs as above; global_config pinned; no extra transfers.
+
+## 3. Fill behaviour
+
+- Fillers: the order is filled by "searchers" in an auction; the winner pays a SOL tip to the trader (docs, kamino.com/docs/products/swap/limit-orders). Kamino charges no platform fee. Mainnet global config: `host_fee_bps = 0` (verified on chain), so no protocol cut either; tip lamports sit in pda_authority and are paid to the maker on close.
+- Third-party orders are filled only by the permissioned path unless the maker opts in: `take_order` requires `order.permissionless != 0` OR a valid Pyth Express Relay (PER) permission in the same tx (`check_permission_and_get_tip`). `create_order` sets `permissionless = 0` and `counterparty = default`. Verified via on-chain scan of the prod config: 22,726 of 22,739 orders have permissionless = 0 and 13 have it = 1. So normal orders created by anyone (any frontend, including ours) are eligible for PER searchers; there is no host/referrer allow-list in the program for makers, so third-party-created orders are treated the same as Kamino-UI ones. The program has no whitelist of maker or frontend. Whether PER searchers fill every pair or only the liquid ones is up to them (UNVERIFIED beyond the stats below).
+- Partial fills: yes. `take_order(input_amount, min_output_amount, tip)`; the taker may fill any `input_amount <= remaining`, and must pay at least `ceil(input * expected_output / initial_input)` (pro rata, rounding up in the maker's favour). Never worse than the stated price.
+- Expiry: none. The order account has no expiry field. It stays until filled or the maker cancels (docs say the same). Our UI must make that visible.
+- Minimum size: none in the program (only input_amount > 0, output_amount > 0, different mints). Practically, searchers need the fill to beat tip + fees, so dust may never fill (UNVERIFIED).
+- Fees paid by the maker: 0.003 SOL per order (txn_fee_cost) plus 0.00384 SOL refundable order rent plus signature/priority fee, plus a one-time vault rent if the input mint has no vault.
+- Tips / global config: no tip required from the maker; the global config is needed as an account input (pin `prod7Mog8...`); the maker cannot pay a tip. Searchers pay the tip.
+- Pairs observed on prod (open + filled-not-yet-closed order accounts, 22,739 total): USDC->SOL 7,814; SOL->USDC 6,780; USDC->USDT 1,394; USDT->USDC 1,172; USDT->SOL 693; USDC->cbBTC-like mint(cbbt...) 688; SOL->USDT 345; SOL->J1to(JitoSOL) 284. Status: 22,375 filled, 364 active. A 98% filled share of surviving accounts suggests these majors do fill; but filled orders linger while cancelled ones are deleted, so this is survivorship-biased and says nothing about price distance or time-to-fill. UNVERIFIED for long-tail pairs; we should restrict to SOL/USDC/USDT and a few majors.
+
+## 4. On-chain facts (read-only RPC)
+
+- Program `LiMoM9...` is deployed, executable, owner BPFLoaderUpgradeable. ProgramData `Eqay3PwNFLH7C2MSSsrZashRbzu2CBGCVMYr3zttM4sQ`, last deployed slot 446,205,635 = 2026-09-11 16:45 UTC (so the public GitHub source, last commit 2025-11-26, is behind the deployed binary).
+- Upgrade authority: `E4NYRj51oRgZpVNJFhX8fF7KThy3ukwd57Tc31iQ8rCs` (system-owned, 0 data; same key is the global config `admin_authority`). It is upgradeable, so Kamino can change program logic at any time. Whether this is a Squads vault/multisig is UNVERIFIED (a system-owned PDA is consistent with a Squads vault but not proof).
+- Global configs under the program: 6 (TeStcU..., prod7Mog8r56sn5dPwze8nVdf94veEawdxNNqd5v7A1, 3Lvo5g..., 98JtHV..., FVVFqi..., Fankws...). The `prod7Mog8...` one has emergency_mode 0, flash_take_blocked 0, new_orders_blocked 0, orders_taking_blocked 0, host_fee_bps 0, order_close_delay 0, txn_fee_cost 3,000,000, ata_creation_cost 0, total_tip_amount 95.2 SOL-equivalent lamports 95,206,476,055 and pda_authority balance 95.22 SOL. The admin can flip emergency mode or block new orders or order taking; funds stay in vaults (the admin has no instruction to sweep maker input from the vault: `withdraw_host_tip` only moves host tips).
+- Recent activity: the latest 1,000 signatures span 2026-11-04 13:41 to 2026-11-05 12:03 UTC (about 40 txs/hour at minimum; 32 of 1,000 failed). Actively used.
+- Audits (listed on kamino.com/docs/security/audits, web search snippet only; I did not open the report PDFs, so scope and findings UNVERIFIED): OtterSec 2024-11-07, Offside Labs 2024-11-29, Sec3 2025-01-29, Certora formal verification 2025-02-21 or 2025-05-13 (docs disagree). Bug bounty stated as $1.5M. These pre-date the current binary (Sept 2026 upgrade, newer fee_paid and RFQ venue code), so the deployed version is not fully covered by the dated audits as far as I can tell. Incidents: none found in a quick search; I did not do a deep incident search.
+
+## 5. Verdict
+
+Buildable, yes, non-custodial and kit-native. Estimated work: build the create/cancel/close instructions (SDK `*Ix` functions or a small hand-written kit builder), add an assert-safe policy for Limo, read orders by maker, UI for open/partially filled/filled-unclosed orders.
+
+Main risks:
+
+1. Funds sit in a Kamino PDA vault under an UPGRADEABLE program controlled by one non-program-verified key. A malicious or buggy upgrade could affect all escrowed input. Audits predate the latest binary.
+2. No expiry and no guaranteed filler. Order stays open until the price is hit and a searcher wants it; the person must cancel to get funds back, and dust or off-market orders may never fill. Permissionless is off by default, so only Pyth Express Relay searchers can fill (verified from source and chain).
+3. Cost: 0.003 SOL non-refundable (probably) per order, plus 0.00384 SOL rent refunded only after the user closes the order (including after fill), plus one-time vault rent for new input mints.
+4. Missing output ATA (SDK does not create it): add an idempotent ATA create or fills can fail.
+5. SDK hygiene: exact-pinned kit 8.3.0 vs our 8.6.0, anchor 0.28 and web3.js v1 pulled in, SDK README stale; the public program source is older than the deployed binary, so source reading is not a full guarantee of behaviour.
+6. Wrong global config is the main injection vector: the SDK takes it as input; pin `prod7Mog8...` in assert-safe.
+
+What a person can lose: the escrowed input amount in the worst case (program upgrade bug or exploit, or a pause that traps funds if admin disables closing; no close-blocking flag was seen in config, only new orders and order taking can be blocked); 0.003 SOL fee per order; an order filled at exactly their limit (no price improvement guaranteed, though the searcher tip adds a little SOL); and the rent of filled orders if they never close them. They cannot lose more than the escrowed input plus fee, because the tx is signed by the user and the output is paid pro rata at the stated price by program checks.
+
+Sources: npm @kamino-finance/limo-sdk 4.0.0; https://github.com/Kamino-Finance/limo-sdk; https://github.com/Kamino-Finance/limo (programs/limo/src/handlers/create_order.rs, close_order_and_claim_tip.rs, take_order.rs, operations.rs); https://kamino.com/docs/products/swap/limit-orders; https://kamino.com/docs/security/audits; https://kamino.com/docs/security; https://kamino.com/docs/security/formal-verification; api.mainnet-beta.solana.com read-only queries.
diff --git a/docs/research/2026-11-05-limit-orders/sui.md b/docs/research/2026-11-05-limit-orders/sui.md
new file mode 100644
index 00000000..d4e5ca24
--- /dev/null
+++ b/docs/research/2026-11-05-limit-orders/sui.md
@@ -0,0 +1,119 @@
+# Sui limit orders on DeepBook V3 - research (2026-11-05)
+
+Verdict: BUILDABLE on DeepBook V3, one signature to open and place, with a custom tx builder and a new safety policy. Cetus limit orders are a worse fit (see 6). Funds are never held by us; they sit in a BalanceManager the person owns, and unfilled or filled tokens need an explicit withdraw to return to the wallet.
+
+Evidence legend: [SRC] read from SDK/Move source, [LIVE] queried read-only today, [DOC] docs page, [UNVERIFIED].
+
+## 1. Package, calls, flow
+
+Package: `@mysten/deepbook-v3` 2.10.1 (modified 2026-11-03), repo MystenLabs/ts-sdks, peer dep `@mysten/sui ^2.39.0` [LIVE npm]. Repo pins `@mysten/sui` 2.31.2 (apps/dapp/package.json), so a bump is required. SDK is by Mysten Labs (the protocol author), deps are only axios, @mysten/bcs, axios-retry, @noble/hashes. No postinstall seen in package.json scripts reviewed (I did not audit dist for supply-chain; a Socket listing exists for an older experimental version - UNVERIFIED relevance). Server-safe: it only builds `Transaction` fragments and calls a public indexer; nothing signs. Recommended: do NOT use the SDK client class at runtime on the server for the safety path; use it (or its generated `contracts/deepbook/*` bindings) only to build, and verify bytes independently.
+
+Mainnet ids [SRC src/utils/constants.ts]:
+
+- DEEPBOOK_PACKAGE_ID (core v9, latest, what calls target): `0xbca0cefe720330e7bb4f2283438b68de6a0e656688db4ac90d61e71fe6151fe8`
+- Types live in original package `0x2c8d603bc51326b8c13cef9dd07031a408a48dddb541963357661df5d3204809` [LIVE pool object type]. Dynamic fields show `0x00c1a56e...::ewma` and `::pool::ReferralRewards`, i.e. a middle version also appears in types. Policy must allow the latest id only and be updated on upgrade (fits safety.ts "unknown id refused" design).
+- REGISTRY_ID `0xaf16199a2dff736e9f07a845f23c5da6df6f756eddb631aed9d24a93efc4549d`; DEEP coin `0xdeeb7a46...c270::deep::DEEP`; USDC `0xdba34672...00e7::usdc::USDC`.
+- Package ids are NOT the same as the pool id; the pool is a shared object.
+
+Calls (SDK method -> Move target) [SRC transactions/balanceManager.ts, deepbook.ts; Move pool.move main]:
+
+1. Create: `balance_manager::new()` returns BalanceManager (SDK `createAndShareBalanceManager` does `new` then `0x2::transfer::public_share_object<...::balance_manager::BalanceManager>`). Variant `createBalanceManagerWithOwner(owner)` -> `balance_manager::new_with_custom_owner`.
+2. Deposit: `balance_manager::deposit(manager, coin)`; SDK builds the coin with `coinWithBalance`, which emits SplitCoins/MergeCoins and, for address-balance funds, `0x2::coin::redeem_funds` etc. (the repo already allows these in safety.ts FRAMEWORK_CALLS).
+3. Proof: `balance_manager::generate_proof_as_owner(manager)` -> TradeProof (hot potato-like, needed by every trade call).
+4. Place: `pool::place_limit_order(pool, manager, proof, client_order_id u64, order_type u8, self_matching u8, price u64, quantity u64, is_bid bool, pay_with_deep bool, expire_timestamp u64, clock 0x6)`. SDK defaults: order_type NO_RESTRICTION, expiry MAX_TIMESTAMP, payWithDeep true. For a maker-only intent use POST_ONLY order_type; for no-surprise-fill use expiry.
+5. Cancel: `pool::cancel_order(pool, manager, proof, order_id u128, clock)`; also `cancel_orders`, `cancel_all_orders`, and `cancel_orders_permissionless`-style variants exist. Cancel settles the unfilled funds back to the manager balance (process_cancel -> settle_balance_manager) [SRC pool.move:596-617].
+6. Withdraw: `balance_manager::withdraw(manager, amount)` or `withdraw_all(manager)` returns Coin, then `TransferObjects` to the person. Note the SDK's `withdrawFromManager` takes a `recipient` argument - the safety policy must pin it to the account.
+7. Read open orders: dry-run `pool::account_open_orders(pool, manager)` -> vector; `pool::get_order`, `get_orders`, `account(pool, manager)`; or indexer REST `https://deepbook-indexer.mainnet.mystenlabs.com/order_updates/SUI_USDC`, `/trades/SUI_USDC` (fills: price, base_volume, maker_balance_manager_id, fees) [LIVE]. Balance: `balance_manager::balance`.
+
+One signature? Yes, with a caveat. Create + deposit + place can be one PTB, but a shared object cannot be shared and then referenced by ID in the same tx, and the SDK helpers all take a manager ID from config (`getBalanceManager(key).address`). So for first-time setup you must: `new` -> use the returned result argument directly in `deposit`, `generate_proof_as_owner`, `place_limit_order` -> `public_share_object` LAST. That means calling the generated move-call bindings (`balanceManagerMoveCalls`, `poolMoveCalls`) directly with the result handle, not the config-key helpers. This is plausible per Move rules (the object is owned by the PTB until shared) but I did NOT dry-run it - UNVERIFIED; test it with `simulateTransaction` (read-only) before building on it. Returning users (manager exists): deposit + proof + place is one PTB with the manager by ID.
+
+Correction, 5 November 2026: a maker's fill does not reach the BalanceManager by itself. It waits in the pool's record for the manager (`Account.settled_balances`) until the manager next acts on the pool or `withdraw_settled_amounts` runs, so `withdraw_all` alone returns nothing for it. Shown by simulation in `sui-deepbook.live.test.ts`. The next sentence holds for a taker.
+
+After a fill: a fill settles into the BalanceManager (process_create returns settled/owed, vault.settle_balance_manager) - the bought tokens are credited to the manager's balance, NOT the wallet. Person must call withdraw (second signature) to get tokens back; also true after cancel and expiry. There is a permissionless `withdraw_settled_amounts_permissionless` that credits maker proceeds to the manager (anyone can call it) but it still ends in the manager; only `withdraw` sends to the wallet and requires the owner [SRC pool.move]. Practical flow: place (sig 1) -> wait/monitor -> `withdraw_all` base+quote, in one PTB (sig 2). If cancelled: `cancel_order` + `withdraw_all` in one PTB (one sig). Optionally pre-add the withdraw in a later PTB; there is no auto-withdraw, no keeper.
+
+## 2. Pools, sizes, fees, prices
+
+Pools are shared objects, keyed by (Base,Quote). Mainnet ids [SRC constants.ts mainnetPools; matched against indexer get_pools LIVE]:
+
+- SUI_USDC `0xe05dafb5133bcffb8d59f4e12465dc0e9faeaa05e3e342a08fe135800e3e4407`
+- DEEP_SUI `0xb663828d6217467c8a1838a03793da896cbe745b150ebd57d82f814ca579fc22`
+- DEEP_USDC `0xf948981b806057580f91622417534f491da5f61aeaf33d0ed8e69fd5691c95ce`
+- WAL_USDC `0x56a1c985c1f1123181d6b881714793689321ba24301b3585eec427436eb1c76d`
+- XBTC_USDC `0x20b9a3ec7a02d4f344aa1ebc5774b7b0ccafa9a5d76230662fdc0300bb215307`
+- USDSUI_USDC `0xa374264d43e6baa5aa8b35ff18ff24fdba7443b4bcb884cb4c2f568d32cdac36`
+ Discover all via indexer `GET /get_pools` [LIVE]. Quote is USDC or SUI, so only pairs that exist are tradable; a SUI->USDC sell is the `isBid=false` order on SUI_USDC; a USDC->SUI buy is `isBid=true`.
+
+Validation (order*info.move validate_inputs) [SRC]: quantity >= min_size (EOrderBelowMinimumSize), quantity % lot_size == 0 (EOrderInvalidLotSize), price % tick_size == 0 and within range (EOrderInvalidPrice). Quantity is in base raw units. On-chain price = human price * 1e9 \_ quoteScalar / baseScalar (SDK convertPrice [SRC conversion.ts]); for SUI/USDC that is price \* 1e6.
+
+Live on-chain values for SUI_USDC via read-only simulate of `pool::pool_book_params` / `pool_trade_params` / `mid_price` / `whitelisted` [LIVE, my script scratchpad/di.mjs]:
+
+- tick_size 10 (raw) = 0.00001 USDC; lot_size 100000000 = 0.1 SUI; min_size 1000000000 = 1 SUI.
+- WARNING: indexer `/get_pools` reports tick_size 100 for SUI_USDC, which disagrees with the chain (10), and live book prices have 5 decimals (0.92059), consistent with 10. Do not trust the indexer for sizing; read chain.
+- trade params: taker fee 200000 (1e9 scale) = 2 bps, maker fee 0, stake_required 100000000000 = 100,000 DEEP. Not whitelisted (so DEEP fee or input-token fee applies). Per docs the whitelisted pools (DEEP_SUI, DEEP_USDC) are 0 fee [DOC docs.sui.io/standards/deepbookv3/design].
+- Other pools (indexer, units may be off as above, verify on chain): DEEP_USDC min 10 DEEP lot 1 DEEP tick 10000; XBTC_USDC min 1000 lot 1000 (8 decimals) tick 1e7; WAL_USDC min 1 WAL lot 0.1 WAL; DEEP_SUI min 10 lot 1.
+- Mid price: dry-run `pool::mid_price(pool, clock)`; returned 918740 = 0.91874 USDC [LIVE]. Alternatively indexer `/orderbook/SUI_USDC?level=1&depth=1` gave best bid 0.92106 / ask 0.92143 at fetch time; mid = (bid+ask)/2. Last trade ~0.92126.
+
+Fees and DEEP: place_limit_order has `pay_with_deep`. Move doc comment says "For current version pay_with_deep must be true", but the code path for false exists (empty deep price, input-token fee; `get_quantity_out_input_fee`, `can_place_limit_order` input-fee paths) [SRC pool.move:178, :1996-2010]. Whether false is actually accepted by deployed v9 for non-whitelisted pools is UNVERIFIED - dry-run to find out. If true, the BalanceManager must hold DEEP for taker fees on any part that crosses; a maker-only (POST_ONLY, resting) order that never takes pays maker fee 0, but the book still reserves DEEP for fees owed at place time per DEEP price. Needs DEEP deposit in practice -> a real onboarding burden. Use `get_order_deep_required` to compute. Live fills show `taker_fee_is_deep: true` and tiny fees (0.1057 DEEP for $16.76 trade).
+
+## 3. Safety check before signing (what assertPtbSafe needs for DeepBook)
+
+Repo state [SRC apps/dapp/lib/chains/sui/safety.ts, policies.ts]: `assertPtbSafe(payload, account, policy)` parses BCS, checks sender and gas owner = account, rejects non-Sender FundsWithdrawal, allows only SplitCoins/MergeCoins/TransferObjects (recipient pinned to me)/MoveCall in an allow-list of exact `pkg::module::function`, and routes each call to `policy.checkCall(call, readPure, account)`. Venues in policies.ts: springsui, cetus, navi. DeepBook appears only in venues.ts. A new `DEEPBOOK_POLICY` is needed. Note `public_share_object` and `new` are not in FRAMEWORK_CALLS: add exact `0x2::transfer::public_share_object` only with type arg pinned to `::balance_manager::BalanceManager` (do not allow the generic framework module; it can share anything).
+
+Allowed calls (latest pkg `0xbca0cefe...`): `balance_manager::{new, deposit, withdraw, withdraw_all, generate_proof_as_owner}`, `pool::{place_limit_order, cancel_order, cancel_orders, cancel_all_orders}`, `0x2::clock` by shared object 0x6. Do NOT allow `mint_trade_cap`, `mint_deposit_cap`, `mint_withdraw_cap`, `register_balance_manager`, `set_owner`/`new_with_custom_owner`, or any `*_cap`: those hand control of the manager to another address.
+
+checkCall must verify, from literal Pure inputs (reject anything not a Pure input, as `ReadPure` already does):
+
+- pool: the pool object input id equals the expected id from our own table for the asked pair AND type arguments equal [Base,Quote] for that pair (typeArguments exactly; a wrong type argument pair aborts, but a wrong pool with right types is a different-market risk e.g. a look-alike). Pool object must be the shared input with the expected id; since ids come through `Input` Object not Pure, readPure cannot read them - need an object-id check (data.inputs[n].UnresolvedObject/SharedObject.objectId); extend the policy API (current `ReadPure` only reads Pure).
+- side: is_bid (Pure bool) equals the asked side.
+- price: u64 equals expected raw price, computed by us from the human price with ticks (`price % tick == 0`, rounded in the person's disfavour never in ours), and within a sanity band of live mid (for example limit within a stated percent, or at least warn if a sell is far below mid / buy far above mid = would fill immediately at a worse price than asked; do cap it, since a limit order that crosses the book is a market order).
+- quantity: u64 equals asked base raw amount, multiple of lot_size, >= min_size.
+- order_type, self_matching, expiry (u64 ms): order_type pinned (POST_ONLY or NO_RESTRICTION as product decides), expiry within allowed window; `pay_with_deep` matches the fee mode we funded.
+- deposit: the Coin argument must come from our SplitCoins with amount equal to quantity (sell: base qty; buy: price\*qty in quote plus fee margin). Verify the SplitCoins amount Pure equals the expected deposit and coin type = expected. The balance manager argument for deposit/proof/place must be the SAME handle (result of `new`, or the person's known manager id). If an existing manager id is used, verify on chain that it exists, is a BalanceManager, and `owner == account` (read via GraphQL object), because depositing into a manager owned by someone else is a theft vector. For `new` in the same PTB the owner is ctx.sender, i.e. the account (new_with_custom_owner must be refused).
+- withdraw: `withdraw`/`withdraw_all` result must flow only into a TransferObjects whose recipient == account (already enforced) and amount ok. Cancel: order_id plausible and the manager is the person's.
+- No extra commands: default branch already refuses anything unknown (MakeMoveVec, Publish, Upgrade, etc.).
+- After build, re-run on the signed bytes (as the repo already does) and simulate (`simulateTransaction`) to assert balance changes: person's sell-token decreases by exactly deposit amount, nothing else leaves. The DeepBook move-call-level check alone cannot see value flow.
+
+Hard check on clientOrderId and DEEP: client_order_id is harmless. Gas: SDK sets a fixed gas budget constant; the repo's own flow should set it.
+
+## 4. Is it live
+
+- Mainnet package ids above; core v9 live [SRC + LIVE: calls to `pool_book_params` etc. at `0xbca0cefe...` succeeded in simulation today].
+- SUI/USDC 24h (indexer `/summary`, fetched 2026-11-05) quote volume about $4.55M, 4.89M SUI, last 0.92126, top-of-book spread 0.92106/0.92143 (about 4 bps), 1.5% 24h change [LIVE]. Orderbook depth at top about 2.2k SUI per side, level-2 about 1-4k SUI: thin; sizes of a few hundred SUI are fine, five-figure SUI will move the price [LIVE]. Other pools: DEEP_USDC ~$112k/24h, XBTC_USDC ~$1.0M, USDSUI_USDC ~$0.73M; WUSDT_USDC, TYPUS_SUI zero volume [LIVE]. Many pools are dead.
+- Audits: OtterSec is listed (third-party database) as auditing DeepBook V3, April 2025; report contents not seen; earlier OtterSec findings on the first DeepBook (Aug 2024) include a high "trade proof bypass" [UNVERIFIED, secondary source: movemaverick.github.io vulnerability database; OtterSec index https://ottersec.notion.site/Sampled-Public-Audit-Reports-a296e98838aa4fdb8f3b192663400772]. deepbookv3 repo has no `audits` folder (GitHub API 404) and a SECURITY.md [LIVE]. I found no MoveBit audit of V3.
+- Incidents: none found for DeepBook V3 in a web search (UNVERIFIED negative; search was shallow). The separate Cetus exploit (May 2025, about $220-260M) is unrelated to DeepBook.
+- Who can upgrade [LIVE, 5 November 2026]: `UpgradeCap` `0xdadf253c...4483`, policy 0 (compatible), held by `0x37f187e1...64d2`; the version 9 upgrade `3y8CSAfc...TLG7` carries a multisig signature (flag 3). The Move source sets no policy and the chain doesn't say whose keys those are. deepbook.xyz names the DeepBook Trading Foundation, and the code is in MystenLabs' repo [DOC].
+- Upgrade cadence is high (core v9, margin v8, SDK changelog shows many id bumps), so the allow-list needs a maintained pin and an alarm when the registry's allowed version changes. `EPackageVersionDisabled` (error 11) aborts old package calls after governance disables a version.
+
+## 5. Sources
+
+- https://docs.sui.io/standards/deepbook (overview only)
+- https://docs.sui.io/standards/deepbookv3/design (fee bounds, settlement)
+- npm `@mysten/deepbook-v3@2.10.1` tarball, src/transactions/{balanceManager,deepbook}.ts, src/utils/{constants,conversion}.ts
+- https://raw.githubusercontent.com/MystenLabs/deepbookv3/main/packages/deepbook/sources/pool.move and book/order_info.move
+- https://deepbook-indexer.mainnet.mystenlabs.com/{get_pools,summary,ticker,orderbook/SUI_USDC,trades/SUI_USDC,order_updates/SUI_USDC}
+- Sui GraphQL https://graphql.mainnet.sui.io/graphql and gRPC simulate on https://fullnode.mainnet.sui.io:443 (note: JSON-RPC is disabled on public fullnodes, matches repo client.ts)
+- Repo: apps/dapp/lib/chains/sui/safety.ts, policies.ts, venues.ts
+
+## 6. Cetus limit orders (brief)
+
+- Product: on-chain limit order, default 7-day expiry up to one month, unfilled returned at expiry, filled tokens auto-sent to wallet, partial fills held on chain until claimed [DOC https://cetus-1.gitbook.io/cetus-docs/guides/how-to-place-a-limit-order]. SDK `@cetusprotocol/limit-sdk` 1.2.6 exposes place_limit_order, cancel_order, claim_target_coin (grep of dist/index.js); I found no user-callable fill/execute function in the SDK.
+- Who executes: not documented. Docs say orders run "when the price is triggered"; with no public fill entry point in the SDK, execution is almost certainly by Cetus-operated off-chain keepers/bots (inference, UNVERIFIED). That adds a trust and liveness dependency on Cetus (orders may not fire; they cannot be filled by the open market the way a resting DeepBook order can), and prices may fill at Cetus AMM prices with slippage rather than book prices.
+- DeepBook is a true on-chain CLOB: resting orders fill whenever any taker crosses, no keeper. Better for "non-custodial, we only build a tx" because no operator can fail to execute. Cetus' upside: tokens auto-delivered to wallet (no second withdraw), no BalanceManager and no DEEP. Downside: opaque executor and weaker evidence base. Recommendation: DeepBook for fidelity to a limit price; Cetus only if the second-signature/DEEP friction proves too heavy, after reading its contract source and package ids (not done here).
+
+## 7. Verdict, risks, what a person can lose
+
+Buildable: yes. Not tested end-to-end (no wallet, no tx submitted).
+
+Main risks:
+
+1. BalanceManager custody model: funds sit in a shared object whose ownership is the person's address. Wrong-manager deposit (not owned by them) or a minted TradeCap/DepositCap/WithdrawCap held by another party loses funds. Mitigation: owner check on chain, ban cap calls.
+2. Shared-object-in-same-PTB behaviour for create+deposit+place is unproven; fallback is two signatures for first use.
+3. DEEP fee requirement: `pay_with_deep` documentation says must be true; people without DEEP could see aborts or have to buy DEEP. Dry-run `pay_with_deep=false` before promising an input-fee path.
+4. Order crosses the book: a buy limit above ask fills at once as taker at market (up to 2 bps taker fee plus slippage on thin book); POST_ONLY avoids it but aborts/rejects if it would cross.
+5. Indexer data can disagree with chain (tick_size 100 vs 10). Chain is the source of truth for sizing.
+6. Thin liquidity beyond about 2-4k SUI per level; fills may be partial and rest for a long time; stale price risk (a resting order can fill into a market move; expiry mitigates). Dust sizes: min 1 SUI.
+7. Package upgrades: pins go stale; a new package id blocks the venue until reviewed (safe failure per safety.ts).
+8. Smart-contract risk: audit evidence only secondary; earlier DeepBook had a trade-proof bypass finding (fixed per V3 audit? UNVERIFIED).
+9. Two-signature exit: tokens stay in the manager until the person withdraws. UX must show "ready to withdraw" and never leave it implied they are in the wallet.
+
+What a person can lose: worst case, the full deposited amount if the manager owner/caps are wrong or the contract has a bug; in normal use, taker fee on crossing size (2 bps plus DEEP purchase cost), slippage if the order crosses, plus gas for the extra withdraw signature; unfilled funds are not lost, they are recoverable by cancel+withdraw at any time without a counterparty (nobody else can cancel). Exchange-rate risk while waiting is inherent to a limit order.
diff --git a/docs/superpowers/plans/2026-11-05-limit-orders-on-chains.md b/docs/superpowers/plans/2026-11-05-limit-orders-on-chains.md
new file mode 100644
index 00000000..b4612813
--- /dev/null
+++ b/docs/superpowers/plans/2026-11-05-limit-orders-on-chains.md
@@ -0,0 +1,490 @@
+# Limit Orders Off Stellar Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** A person can place, watch, cancel and collect a limit order on Solana, Sui, NEAR and Avalanche, Monad refuses in words, and race agents can recommend resting an order instead of filling now.
+
+**Architecture:** A limit order is a new `ChainActionKind`, `limit`, that goes through the existing prepare, assert, sign and submit path, one venue per chain. Orders live on the chain or in CoW's book and are read back, so there is no database. A `LimitBook` on the chain module reads orders and builds their exits (cancel, collect). The race gets a resting option beside its fill routes, and a rest proposal becomes a `limit` request when pressed.
+
+**Tech Stack:** Next 15, `@solana/kit` 8, `@mysten/sui` 2.31.2, viem 2, wagmi 2, vitest.
+
+**Spec:** the user's request, "build limit orders" after "the AI should be able to do bundled swaps, limit swaps and so on on all chain", and the venue research in `docs/research/2026-11-05-limit-orders/` (`solana.md`, `sui.md`, `near.md`, `evm.md`). Those reports hold the addresses, discriminators, account orders and move targets. Read the one for your task. Facts marked unverified there are not facts.
+
+## Global Constraints
+
+- **No new dependencies.** `pnpm install` is not available in this worktree, `limo-sdk` pins kit 8.3.0 and pulls in web3.js v1, and `deepbook-v3` needs a newer `@mysten/sui`. Build instructions by hand with what is installed. A package may be read from a tarball in the scratchpad (`npm pack`), never added.
+- Work only in `.claude/worktrees/limit-orders`, branch `feat/chain-limit-orders`. Never run `pnpm install` here: `node_modules` are junctions into the main checkout. Never switch branches in the main checkout.
+- Nothing is ever signed or sent. Live checks read and simulate only, in files named `*.live.test.ts`. Run a live test only by its exact file name. Every other test run excludes them with `--exclude '**/*.live.test.ts'`.
+- Every focused run also includes `lib/__tests__/import-purity.test.ts` and `lib/__tests__/routes-load.test.ts`. Run from `apps/dapp` with `npx vitest run --reporter=basic --maxWorkers=3 --minWorkers=1`.
+- Stellar does not change. Its prompt stays pinned by hash, and `chainCapabilities('stellar')` stays undefined.
+- Only the person's own words decide the amount, the tokens and a stated price. A race may propose a resting price only inside the band in Task 9, and the person presses Review and signs.
+- A refusal says what happened and ends `Nothing was signed or sent.`
+- Limit orders never cross the market on purpose: an order that would fill at once at a worse price than the market is refused, by `assertRestingPrice`.
+- Every venue ships its real logo in the task that adds it. A test enforces this.
+- Vitest has no DOM and no BigInt literals (`BigInt('10')`). Push logic into pure helpers under `lib/`.
+- Commit subjects are lowercase and at most 100 characters, with body lines at most 100. No co-author lines or attribution. Stage files by name, never `.claude/` or `.next-verify/`.
+- Don't touch `naira-payout-card.tsx`, `bank-picker.tsx` or `use-naira-payout.ts`.
+
+## Review Focus
+
+1. A typo in the price: "sell 1 SOL at 1.10" must be refused in words, not rest forever or fill at a loss. Pinned in Task 1.
+2. The direction of a price: "swap 50 USDC to SOL at 90" spends 50 USDC for at least 0.5556 SOL, and rounding never gives less than the stated price. Pinned in Task 1.
+3. An order that filled while its card was open: pressing Cancel on it must collect or say so, never fail silently or sign something useless. Pinned in Task 7.
+4. Someone else's order: an exit for an order id the connected account doesn't own is refused on the server. Pinned in Tasks 2, 3, 4, 5, 6.
+5. Too little native coin: an order that leaves too little to pay its cost and fees is refused before signing, and the cost is shown first. Pinned in Task 2.
+
+---
+
+## File map
+
+| File | Responsibility |
+| -------------------------------------------------- | ---------------------------------------------------------------------- |
+| `lib/chains/limit.ts` | Pure price and amount maths, price sanity, `RestingOrder`, `LimitBook` |
+| `lib/chains/parse-limit.ts` | Reads a limit order from typed words, and the words gate |
+| `app/api/chain/[chain]/orders/route.ts` | Lists orders, builds an exit |
+| `lib/chains/solana/limo.ts`, `limo-rules.ts` | Kamino venue, book and safety |
+| `lib/chains/sui/deepbook.ts`, `deepbook-rules.ts` | DeepBook venue, book and policy |
+| `lib/chains/near/rhea-dcl.ts`, `rhea-dcl-rules.ts` | Rhea venue, book and safety |
+| `lib/chains/evm/cow.ts`, `cow-rules.ts` | CoW venue, book and safety |
+| `components/intents/chain-orders-card.tsx` | The list, with Cancel and Collect |
+| `lib/agents/rest-option.ts` | The resting option given to agents, and the band |
+
+---
+
+### Task 1: The maths and the words
+
+**Files:**
+
+- Create: `apps/dapp/lib/chains/limit.ts`, `apps/dapp/lib/chains/parse-limit.ts`
+- Modify: `apps/dapp/lib/chains/types.ts` (`ChainActionKind` adds `'limit'`; `ChainActionRequest` adds `limitPrice?: string | undefined`), `apps/dapp/lib/chains/parse-action.ts` (`racesFirst` false for `limit`)
+- Test: `apps/dapp/lib/__tests__/chain-limit.test.ts`
+
+**Interfaces:**
+
+- Produces, in `limit.ts`:
+ - `limitPair(tokenIn: string, tokenOut: string): { base: string; quote: string; selling: boolean } | undefined`. Quote is the stablecoin side (USDC or USDT), and nothing for two stablecoins or no stablecoin.
+ - `limitOutput(amountIn: string, price: string, selling: boolean, decimalsIn: number, decimalsOut: number): bigint`. Exact BigInt arithmetic. Selling: amount times price. Buying: amount divided by price. Always rounds up, so the order never accepts less than the stated price.
+ - `assertRestingPrice(price: string, marketUsd: number, selling: boolean, symbol: string): void`, which throws `ChainActionRefused`.
+ - `interface RestingOrder { id: string; venueId: string; tokenIn: string; tokenOut: string; amountIn: string; remainingIn: string; receivedOut: string; collectable?: { amount: string; symbol: string }[]; limitPrice?: string; status: 'open' | 'partly' | 'filled' | 'done'; expiresAt?: number; note?: string }`
+ - `interface LimitBook { openOrders(account: string, signal?: AbortSignal): Promise; prepareExit(account: string, orderId: string, action: 'cancel' | 'collect'): Promise }`
+- Produces, in `parse-limit.ts`:
+ - `parseChainLimit(text: string): ChainActionRequest | null`
+ - `limitSaid(request: ChainActionRequest, turns: readonly string[]): boolean`
+- Modifies: `ChainModule` adds `limitBook?: LimitBook`. `PreparedAction` adds `thenPrepareAgain?: true`, meaning this is a setup step and the same request must be prepared again once it settles.
+
+- [ ] **Step 1: Write the failing tests**
+
+```ts
+describe('limitOutput', () => {
+ it('sells at the stated price', () => {
+ expect(limitOutput('1', '110', true, 9, 6)).toBe(BigInt('110000000'))
+ })
+ it('buys at the stated price, rounding so the price is never worse', () => {
+ expect(limitOutput('50', '90', false, 6, 9)).toBe(BigInt('555555556'))
+ })
+ it('keeps precision a float would lose', () => {
+ expect(limitOutput('0.1', '0.3', true, 9, 6)).toBe(BigInt('30000'))
+ })
+})
+
+describe('limitPair', () => {
+ it('reads the side from the token given up', () => {
+ expect(limitPair('SOL', 'USDC')).toEqual({ base: 'SOL', quote: 'USDC', selling: true })
+ expect(limitPair('USDC', 'SOL')).toEqual({ base: 'SOL', quote: 'USDC', selling: false })
+ })
+ it('has no pair without exactly one stablecoin', () => {
+ expect(limitPair('SOL', 'JUP')).toBeUndefined()
+ expect(limitPair('USDC', 'USDT')).toBeUndefined()
+ })
+})
+
+describe('assertRestingPrice', () => {
+ it('refuses a sale below the market, which would fill at once at a loss', () => {
+ expect(() => assertRestingPrice('90', 100, true, 'SOL')).toThrow(/below/)
+ })
+ it('refuses a purchase above the market', () => {
+ expect(() => assertRestingPrice('110', 100, false, 'SOL')).toThrow(/above/)
+ })
+ it.each([
+ ['1.10', 100, true],
+ ['1000', 100, true],
+ ['1', 100, false],
+ ])('refuses %s against a market of %s as a likely typo', (price, market, selling) => {
+ expect(() => assertRestingPrice(price, market, selling, 'SOL')).toThrow(/Nothing was signed/)
+ })
+ it('takes a price on the right side, within half the market either way', () => {
+ expect(() => assertRestingPrice('110', 100, true, 'SOL')).not.toThrow()
+ expect(() => assertRestingPrice('149', 100, true, 'SOL')).not.toThrow()
+ expect(() => assertRestingPrice('90', 100, false, 'SOL')).not.toThrow()
+ expect(() => assertRestingPrice('51', 100, false, 'SOL')).not.toThrow()
+ })
+})
+
+describe('parseChainLimit', () => {
+ it.each([
+ ['sell 1 SOL at $110', { amount: '1', tokenIn: 'SOL', tokenOut: 'USDC', limitPrice: '110' }],
+ [
+ 'sell 1 sol for usdt at 110',
+ { amount: '1', tokenIn: 'sol', tokenOut: 'usdt', limitPrice: '110' },
+ ],
+ [
+ 'limit sell 1 SOL at 110',
+ { amount: '1', tokenIn: 'SOL', tokenOut: 'USDC', limitPrice: '110' },
+ ],
+ [
+ 'swap 1 SOL to USDC at $110',
+ { amount: '1', tokenIn: 'SOL', tokenOut: 'USDC', limitPrice: '110' },
+ ],
+ [
+ 'swap 1 SOL to USDC when it hits 110.50',
+ { amount: '1', tokenIn: 'SOL', tokenOut: 'USDC', limitPrice: '110.50' },
+ ],
+ [
+ 'swap 50 USDC to SOL at 90',
+ { amount: '50', tokenIn: 'USDC', tokenOut: 'SOL', limitPrice: '90' },
+ ],
+ [
+ 'buy SOL with 50 USDC at $90',
+ { amount: '50', tokenIn: 'USDC', tokenOut: 'SOL', limitPrice: '90' },
+ ],
+ [
+ 'buy SOL at $90 with 50 USDC',
+ { amount: '50', tokenIn: 'USDC', tokenOut: 'SOL', limitPrice: '90' },
+ ],
+ ['sell 1 solana at 110', { amount: '1', tokenIn: 'SOL', tokenOut: 'USDC', limitPrice: '110' }],
+ ])('reads %s', (typed, want) => {
+ expect(parseChainLimit(typed)).toEqual({ kind: 'limit', venue: undefined, ...want })
+ })
+
+ it.each([
+ 'sell 1 SOL',
+ 'swap 1 SOL to USDC',
+ 'sell 1 SOL at 1,10',
+ 'sell $5 of SOL at 110',
+ 'buy 2 SOL at 90',
+ 'sell 1 SOL at 110 then cash out',
+ ])('leaves %s alone', (typed) => {
+ expect(parseChainLimit(typed)).toBeNull()
+ })
+})
+
+describe('limitSaid', () => {
+ const asked = {
+ kind: 'limit' as const,
+ amount: '1',
+ tokenIn: 'SOL',
+ tokenOut: 'USDC',
+ limitPrice: '110',
+ }
+ it('wants the amount by its token and the price after it, in the persons own words', () => {
+ expect(limitSaid(asked, ['sell 1 SOL at $110'])).toBe(true)
+ expect(limitSaid(asked, ['sell 1 SOL', 'at 110'])).toBe(true)
+ })
+ it('refuses a figure they never said', () => {
+ expect(limitSaid({ ...asked, limitPrice: '120' }, ['sell 1 SOL at $110'])).toBe(false)
+ expect(limitSaid({ ...asked, amount: '2' }, ['sell 1 SOL at $110'])).toBe(false)
+ })
+ it('refuses price and amount swapped round', () => {
+ expect(limitSaid({ ...asked, amount: '110', limitPrice: '1' }, ['sell 1 SOL at $110'])).toBe(
+ false
+ )
+ })
+})
+```
+
+- [ ] **Step 2: Run them to see them fail**
+
+Run: `npx vitest run lib/__tests__/chain-limit.test.ts --reporter=basic`
+Expected: FAIL, cannot load `../chains/limit`.
+
+- [ ] **Step 3: Implement**
+
+`assertRestingPrice` copy: selling below the market, `That would sell below today's ${symbol} price of about $${m}, so it would fill at once. Name a price above it. Nothing was signed or sent.`, and the mirror for buying above. More than 50% from the market either way: `$${price} is far from today's ${symbol} price of about $${m}. Check the figure and send it again. Nothing was signed or sent.` Decimal commas are refused, as in `AMOUNT`. `buy 2 SOL at 90` names the amount received, which no venue here can hold exactly, so it stays unread and goes to the model, which refuses it in Task 8.
+
+- [ ] **Step 4: Run them to see them pass, with import-purity and routes-load**
+
+- [ ] **Step 5: Commit**: `feat(dapp): read a limit order and do its price sums`
+
+---
+
+### Task 2: The routes and the checks every venue shares
+
+**Files:**
+
+- Create: `apps/dapp/app/api/chain/[chain]/orders/route.ts`
+- Modify: `apps/dapp/lib/chains/prepare.ts`, `apps/dapp/lib/chains/capabilities.ts` (and each chain's `capabilities.ts`), `apps/dapp/app/api/chain/[chain]/prepare/route.ts`
+- Test: `apps/dapp/lib/__tests__/chain-limit-routes.test.ts`
+
+**Interfaces:**
+
+- Consumes: Task 1.
+- Produces: `GET /api/chain/[chain]/orders?account=` returns `{ orders: RestingOrder[] }`, 404 `unknown_chain`, 400 `invalid_account`, 503 `orders_unavailable`, and rate family `read`. `POST` with `{ account, orderId, action }` returns a prepared action as the prepare route does, with rate family `build` and the same wallet gate as the prepare route. 422 with `error` for a refusal.
+- Produces: `assertLimitCanRun(chainApi: ChainModule, request: ChainActionRequest, account: string): Promise` in `prepare.ts`, which `prepareChainAction` calls for `limit`.
+- Produces: `LIMIT_NOT_HERE: Partial>`, with `monad: "Monad has no limit order venue I would trust with your money yet. The only real option, Kuru, keeps deposits in an upgradeable contract with no audit I could find. Nothing was signed or sent."`
+
+- [ ] **Step 1: Write the failing tests**
+
+Cover, with a mocked module (as `chain-price-route.test.ts` does):
+
+- `prepareChainAction` for `limit` on `monad` throws the `LIMIT_NOT_HERE` copy.
+- A `limit` request with a price that fails `assertRestingPrice` is refused before the venue's `prepare` runs, and the venue was not called.
+- No live price: refused with `I have no live price for SOL, so I can't check that price. Nothing was signed or sent.`, venue not called.
+- A pair with no stablecoin: `Limit orders here need USDC or USDT on one side. Nothing was signed or sent.`
+- `assertNativeReserve` counts a venue's stated setup cost: a limit selling SOL with 0.0148 SOL held and a cost of 0.00684 SOL is refused, and the refusal names the cost.
+- GET and POST status codes above. POST with an `action` other than `cancel` or `collect` is 400. A module with no `limitBook` answers `{ orders: [] }` on GET and 422 on POST.
+
+- [ ] **Step 2: Run them to see them fail**
+- [ ] **Step 3: Implement.** `ChainVenue` gains `limitCost?: { amount: string; symbol: string }`, added to the native reserve for that venue. `assertAmountWithinCap` runs on a limit order as on any other action. Off Stellar help text (`chainHelp`) names limit orders where a venue exists.
+- [ ] **Step 4: Run them to see them pass, with import-purity and routes-load**
+- [ ] **Step 5: Commit**: `feat(dapp): route limit orders and refuse them where no venue is safe`
+
+---
+
+### Task 3: Solana, Kamino
+
+**Files:**
+
+- Create: `apps/dapp/lib/chains/solana/limo.ts`, `apps/dapp/lib/chains/solana/limo-rules.ts`, `apps/dapp/lib/__tests__/solana-limo.test.ts`, `apps/dapp/lib/__tests__/solana-limo.live.test.ts`
+- Modify: `apps/dapp/lib/chains/solana/venues.ts`, `module.ts`, `programs.ts`, `capabilities.ts`, `sign.ts` if needed
+
+**Interfaces:**
+
+- Consumes: Task 1 and 2. `decodeSolanaTransaction`, `associatedTokenAccount`, `ownWrappedSolAccount` from `assert-safe.ts`.
+- Produces: venue id `kamino-limit`, name `Kamino Limit Orders`, kinds `['limit']`, `limitCost` `0.00684 SOL`. `limoBook(): LimitBook`.
+- Pinned values: program `LiMoM9rMhrdYrfzUCxQppvxCSG1FcrUK9G8uLq4A1GF`, global config `prod7Mog8r56sn5dPwze8nVdf94veEawdxNNqd5v7A1`, instruction bytes and account order from `docs/research/2026-11-05-limit-orders/solana.md`. Pairs: SOL, USDC and USDT only.
+
+- [ ] **Step 1: Write the failing tests**
+
+- `buildCreate` for 1 SOL to USDC at 110 decodes to: `create_order` with input `1000000000`, output `110000000`, order type 0. Maker, ATAs and PDAs as the report says. An idempotent create of the output ATA is present, because the SDK omits it and fills can fail without it. Wrap, sync and close of wSOL are present.
+- USDC to SOL has no output ATA create, and no wrap.
+- `limoAssertSafe` passes that payload, and refuses each of: another global config, another maker, another vault, input or output amount changed by one, an extra system transfer to anyone, a close-account destination that isn't the maker, `update_order`, a second extra signer, and an order account the transaction doesn't create at exactly 424 bytes owned by the program.
+- `prepare` reads the live config (mocked) and refuses when `txn_fee_cost + ata_creation_cost` is above `10_000_000` lamports, or `new_orders_blocked` is set. The cost is inside the program's call, so the signed bytes can't show it.
+- `prepare` refuses a mint whose vault doesn't exist.
+- `decodeOrder` on a real order account, captured to a fixture by the live test, reads remaining, filled output, status and maker.
+- `prepareExit` refuses an order whose maker isn't the account, and a filled order offers `collect` (closes it for rent and tips) with the summary saying the SOL comes back.
+
+- [ ] **Step 2: Run them to see them fail**
+- [ ] **Step 3: Implement.** The order keypair is generated on the server with kit, signs the message, and is thrown away. Check that the wallet path keeps that signature: the transaction reaches the wallet partly signed, and `solanaAssertSafe` must accept exactly this one extra signer, which must be the account the transaction creates. `notes`: `This stays open until it fills or you cancel it. There is no expiry.`, `Costs about 0.003 SOL, probably not refunded, and locks 0.00384 SOL until you close the order.`, `Only Pyth Express Relay searchers fill it. It may never fill, and small orders are less likely to.`, `Kamino can upgrade this program, so your escrowed amount carries that risk.`
+- [ ] **Step 4: Run `solana-limo.live.test.ts` by its exact name.** It reads the config and one real order for the fixture, simulates a create for a public funded wallet, and asserts `getProgramAccounts` filtered by maker answers on `api.mainnet-beta.solana.com`. If that read is refused, `openOrders` answers from a list of order addresses the browser stored at signing, and the note in chat names `SOLANA_RPC_URL`.
+- [ ] **Step 5: Run all the task's tests, with import-purity and routes-load**
+- [ ] **Step 6: Commit**: `feat(dapp): place, read and close kamino limit orders on solana`
+
+---
+
+### Task 4: Sui, DeepBook
+
+**Files:**
+
+- Create: `apps/dapp/lib/chains/sui/deepbook.ts`, `apps/dapp/lib/chains/sui/deepbook-rules.ts`, `apps/dapp/lib/__tests__/sui-deepbook.test.ts`, `apps/dapp/lib/__tests__/sui-deepbook.live.test.ts`
+- Modify: `apps/dapp/lib/chains/sui/safety.ts`, `policies.ts`, `venues.ts`, `module.ts`, `capabilities.ts`, `apps/dapp/lib/chains/venue-logos` (or wherever the logo table lives), `apps/dapp/public/`
+
+**Interfaces:**
+
+- Produces: `ReadObject = (argument) => string | undefined`, passed to `PtbPolicy.checkCall` as a fourth argument. Existing policies ignore it. Venue id `deepbook-limit`, name `DeepBook`, kinds `['limit']`. `deepbookBook(): LimitBook`.
+- Pinned values: package, pools and registry from `docs/research/2026-11-05-limit-orders/sui.md`. Pair: SUI/USDC only. Order type POST_ONLY, so it can never fill at once. Expiry now plus 7 days. Tick, lot and minimum read from the chain, never the indexer, which disagrees.
+
+- [ ] **Step 1: Settle the three unknowns by simulation.** In `sui-deepbook.live.test.ts`, simulate, without signing, for a public funded wallet:
+ 1. `new`, `deposit`, `generate_proof_as_owner`, `place_limit_order`, then `public_share_object`, all in one transaction.
+ 2. `pay_with_deep = false` on POST_ONLY.
+ 3. `balance_manager::register_balance_manager`, reading its Move source first. Allow it only if it does nothing but record the owner's id in the registry.
+
+ Record the outcomes at the top of `deepbook.ts`. If (1) fails, first use is two signatures: creating the manager returns `thenPrepareAgain`. If (2) fails, the order needs DEEP: `prepare` reads `get_order_deep_required`, and if the manager and wallet can't cover it, refuses with `DeepBook needs about ${n} DEEP in your wallet to place this. Nothing was signed or sent.` If (3) isn't safe, the browser stores the manager id per wallet, and a new device shows a new manager.
+
+- [ ] **Step 2: Write the failing tests**
+
+- Selling 2 SUI at 1.10 builds: deposit 2 SUI, place with `is_bid` false, price and quantity on tick and lot, POST_ONLY, expiry 7 days on, then share only if the manager is new.
+- Buying with 11 USDC at 1.00 deposits 11 USDC and places a bid for 11 SUI.
+- Below 1 SUI: `DeepBook's smallest SUI order is 1 SUI. Nothing was signed or sent.` A quantity off the lot rounds down, and the card shows the rounded figure.
+- `DEEPBOOK_POLICY` passes those, and refuses each of: `mint_trade_cap`, `mint_deposit_cap`, `mint_withdraw_cap`, `new_with_custom_owner`, `set_owner`, another pool id with the right types, wrong type arguments, price or quantity changed, order type other than POST_ONLY, expiry past 8 days, `public_share_object` of anything but a `BalanceManager`, and a deposit coin that differs from the expected amount.
+- An existing manager whose on-chain owner isn't the account is refused before building.
+- `prepareExit` cancel builds `cancel_order` then `withdraw_all` for both coins in one transaction, with `TransferObjects` to the account only. `collect` builds `withdraw_all` for both. An order or manager that isn't the account's is refused.
+- `openOrders` marks money waiting in the manager as `collectable`, and its summary never implies it is in the wallet.
+
+- [ ] **Step 3: Run them to see them fail**
+- [ ] **Step 4: Implement.** `notes`: `Filled or cancelled tokens stay in your DeepBook account until you press Collect, which needs a second signature.`, `Expires in 7 days. Anything unfilled then waits to be collected.`, `It never fills at once. If the price has already passed yours, placing it fails and nothing moves.`
+- [ ] **Step 5: Run all the task's tests, with import-purity and routes-load**
+- [ ] **Step 6: Commit**: `feat(dapp): place, read and collect deepbook limit orders on sui`
+
+---
+
+### Task 5: NEAR, Rhea
+
+**Files:**
+
+- Create: `apps/dapp/lib/chains/near/rhea-dcl.ts`, `apps/dapp/lib/chains/near/rhea-dcl-rules.ts`, `apps/dapp/lib/__tests__/near-rhea-dcl.test.ts`, `apps/dapp/lib/__tests__/near-rhea-dcl.live.test.ts`
+- Modify: `apps/dapp/lib/chains/near/safety.ts` (header comment and a new export), `venues.ts`, `module.ts`, `capabilities.ts`, logo table and `apps/dapp/public/`
+
+**Interfaces:**
+
+- Produces: `assertDclOrderSafe(payload, account, expected)`, `assertDclExitSafe(payload, account, orderId)`, venue id `rhea-limit`, name `Rhea`, kinds `['limit']`, `rheaBook(): LimitBook`, and `pointForPrice(priceUsdcPerNear: number, selling: boolean, delta: number): number`.
+- Pinned values: contract `dclv2.ref-labs.near`, pool `17208628f84f5d6ad33f0da3bbbeb27ffcb398eac501a31bd6ad2011e36133a1|wrap.near|100`, delta 1, USDC/wNEAR only. Storage capped at 0.5 NEAR.
+
+- [ ] **Step 1: Write the failing tests**
+
+```ts
+describe('pointForPrice', () => {
+ // USDC per NEAR = 1e18 / 1.0001^point, read from orders resting on this pool on 2026-11-05.
+ it('sells NEAR at a point below the market, and no cheaper than asked', () => {
+ const point = pointForPrice(4.29, true, 1)
+ expect(Math.abs(point - 399922)).toBeLessThan(10)
+ expect(1e18 / 1.0001 ** point).toBeGreaterThanOrEqual(4.29)
+ })
+ it('buys NEAR at a point above the market, and no dearer than asked', () => {
+ const point = pointForPrice(0.909, false, 1)
+ expect(Math.abs(point - 415439)).toBeLessThan(10)
+ expect(1e18 / 1.0001 ** point).toBeLessThanOrEqual(0.909)
+ })
+ it('keeps to the pool step', () => {
+ expect(pointForPrice(4.29, true, 40) % 40).toBe(0)
+ })
+})
+```
+
+Also:
+
+- A sale at a point at or above the pool's `current_point`, or a purchase at or below it, is refused. `LimitOrderWithSwap` swaps a marketable part at once with no minimum out, so an order must never be marketable.
+- First use builds, in order: storage on `dclv2` as its own transaction, buy-token storage if unregistered, then the token transaction. `near_deposit` first when selling native NEAR.
+- `assertDclOrderSafe` passes that, and refuses: another contract, another pool, another `buy_token`, a changed amount or point, extra keys in `msg`, `client_id` other than `""`, deposit other than 1 yocto, gas above 250 TGas, storage above 0.5 NEAR. `ft_transfer_call` stays refused everywhere else: the existing `assertNearPayloadSafe` tests still pass unchanged.
+- `prepareExit` cancel is `cancel_order` with the remaining amount. `collect` is `cancel_order` with `amount: "0"`. Both no deposit, 180 TGas at most. An `order_id` whose `get_order` owner isn't the account is refused.
+- `openOrders` reads `list_active_orders` and marks `unclaimed_amount` as `collectable`.
+
+- [ ] **Step 2: Run them to see them fail**
+- [ ] **Step 3: Implement.** Proceeds of a NEAR purchase arrive as wNEAR, and a sale's proceeds as USDC. `notes`: `When it fills, press Collect to receive it. That needs a second signature.`, `First use sets aside up to 0.5 NEAR for storage on Rhea. I could not confirm it comes back.`, `A purchase pays out in wNEAR.`, `This stays open until it fills or you cancel it.`
+- [ ] **Step 4: Run `near-rhea-dcl.live.test.ts` by its exact name.** It reads the pool and a real order, checks the point formula against them, and reads `get_user_storage_detail`.
+- [ ] **Step 5: Run all the task's tests, with import-purity and routes-load**
+- [ ] **Step 6: Commit**: `feat(dapp): place, read and collect rhea limit orders on near`
+
+---
+
+### Task 6: Avalanche, CoW, and signing a message
+
+**Files:**
+
+- Create: `apps/dapp/lib/chains/evm/cow.ts`, `apps/dapp/lib/chains/evm/cow-rules.ts`, `apps/dapp/lib/__tests__/evm-cow.test.ts`, `apps/dapp/lib/__tests__/evm-cow.live.test.ts`
+- Modify: `apps/dapp/lib/chains/types.ts` (`SigningPayload` and `SignedPayload` add `evm-typed`), `apps/dapp/lib/adapters/evm-adapter.ts` (`signOn`), `apps/dapp/lib/chains/evm/venues.ts`, `module.ts`, `capabilities.ts`, logo table and `apps/dapp/public/`
+
+**Interfaces:**
+
+- Produces: `SigningPayload` variant `{ family: 'evm-typed'; chainId: number; account: string; typedData: { domain: object; types: object; primaryType: 'Order'; message: CowOrder } }`, `SignedPayload` variant `{ family: 'evm-typed'; signature: string }`. Venue id `cow-limit`, name `CoW Protocol`, kinds `['limit']`, on Avalanche only. `cowBook(): LimitBook`.
+- Pinned values: settlement, relayer, WAVAX, USDC and the type fields from `docs/research/2026-11-05-limit-orders/evm.md`. `validTo` now plus 7 days, `feeAmount` `"0"`, both balances `erc20`, `partiallyFillable` true, kind `sell`, empty app data with no hooks, no partner fee.
+
+- [ ] **Step 1: Write the failing tests**
+
+- `prepare` walks by what the chain says: AVAX to sell and not enough WAVAX returns a `deposit()` on WAVAX with `thenPrepareAgain`. Too little allowance returns an exact `approve` to the relayer with `thenPrepareAgain`, never a max approval. Otherwise it returns the typed data.
+- `cowAssertSafe` passes those, and refuses each of: another `verifyingContract`, chain id or domain name, another receiver or the zero address, a changed token or amount, `validTo` past 8 days or in the past, `feeAmount` not `"0"`, a balance other than `erc20`, non-empty app data, and an `approve` with another spender or a higher amount.
+- `submit` recovers the signer with viem and refuses when it isn't the account, which also refuses smart-contract wallets in words. It posts the order, computes the UID locally, and refuses when the answer differs. It returns `{ ok: true, hash: uid, explorerUrl: 'https://explorer.cow.fi/avax/orders/' + uid }`.
+- A CoW answer of `SellAmountDoesNotCoverFee` reads `That is too small for CoW's solvers to fill. Try a few dollars more. Nothing was signed or sent.`
+- `signOn` refuses a typed payload for another chain or another account, and passes one to wagmi's `signTypedData`. The signing logic is a pure helper with tests, as the adapter has no DOM.
+- `prepareExit` cancel returns `invalidateOrder` on settlement, an ordinary transaction with a gas cost, which is definitive where the off-chain cancel is best effort. An order owned by someone else is refused. There is no `collect`, as fills arrive in the wallet.
+- `openOrders` maps CoW statuses to `RestingOrder`, and `executedBuyAmount` to `receivedOut`.
+
+- [ ] **Step 2: Settle two unknowns live.** In `evm-cow.live.test.ts`, by exact name, quote only and create no order: whether the owner orders path exists as written in the report, and whether buying native AVAX through `0xEeee…` quotes. If it doesn't, a purchase pays out in WAVAX and a note says so.
+- [ ] **Step 3: Run the tests to see them fail**
+- [ ] **Step 4: Implement.** `notes`: `Your funds stay in your wallet until a solver fills this at your price or better.`, `Expires in 7 days.`, `Avalanche has few CoW solvers trading right now, so it may not fill.`, `Cancelling costs a small network fee.`, and for the approval, `This lets CoW move exactly this amount and no more. Cancelling doesn't remove it.`
+- [ ] **Step 5: Run all the task's tests, with import-purity and routes-load**
+- [ ] **Step 6: Commit**: `feat(dapp): sign and place cow limit orders on avalanche`
+
+---
+
+### Task 7: The chat
+
+**Files:**
+
+- Create: `apps/dapp/components/intents/chain-orders-card.tsx`, `apps/dapp/lib/chains/orders-view.ts`, `apps/dapp/lib/__tests__/chain-orders-view.test.ts`
+- Modify: `apps/dapp/components/intents/intent-chat.tsx`, `apps/dapp/lib/chains/parse-sequence.ts` (`readChainMessage`), `apps/dapp/hooks/use-chain-action.ts`, `apps/dapp/lib/chains/signing-flow.ts`
+
+**Interfaces:**
+
+- Consumes: Tasks 1, 2, and `thenPrepareAgain`.
+- Produces: `ChainMessageRead` adds `{ kind: 'limit'; action: ChainActionRequest }` and `{ kind: 'orders' }`. `ordersAsked(text: string): boolean`. `orderLine(order: RestingOrder): string`. `exitsFor(order: RestingOrder): ('cancel' | 'collect')[]`. `freshExit(listed: RestingOrder, now: RestingOrder | undefined, action): 'cancel' | 'collect' | 'gone'`. The hook adds `prepareExit(orderId, action, text)`.
+
+- [ ] **Step 1: Write the failing tests**
+
+- `readChainMessage('sell 1 SOL at $110', 'solana', 'Solana')` is `limit`. `parseChainSequence` still wins for `swap 1 SOL to USDC then cash out`, and a plain swap stays a plain swap.
+- `readChainMessage` on Monad refuses with `LIMIT_NOT_HERE`.
+- `ordersAsked` is true for `my orders`, `show my open orders`, `cancel my order`, `limit orders`, and false for `order 5 SOL` and `in order to swap`.
+- `exitsFor`: open offers cancel; partly offers cancel and collect where something is collectable; filled offers collect only; done offers nothing.
+- `freshExit`: Cancel pressed on an order that has since filled becomes `collect`, and one no longer listed is `gone`, which says `That order is no longer open. Nothing was signed or sent.`
+- `orderLine` reads like `Sell 1 SOL at $110, 0.4 filled, expires Nov 12`.
+- A settled action with `thenPrepareAgain` keeps the request and offers one `Continue` press, and a press while building does nothing. A settled action without it behaves as today.
+
+- [ ] **Step 2: Run them to see them fail**
+- [ ] **Step 3: Implement.** A limit goes straight to the card, with `racesFirst` false. The card shows pay, the price, the least it will receive, the notes, and the cost. After settling, the chat says the order is resting, how to see it (`my orders`), and for Sui and NEAR that collecting takes another signature. The list re-reads on open and after any exit, and each press re-reads the order first (`freshExit`). Chat persists through the existing transcript. Nothing about an order is written to the database.
+- [ ] **Step 4: Run all the task's tests, with import-purity and routes-load**
+- [ ] **Step 5: Commit**: `feat(dapp): place limit orders from the chat, and list, cancel and collect them`
+
+---
+
+### Task 8: The model reads limit orders
+
+**Files:**
+
+- Modify: `apps/dapp/lib/chains/parse-sequence.ts` (`ReadSwap`, `chainRouteFromReading`), `apps/dapp/lib/orchestrate/orchestrate.ts` and its per-chain prompt, `apps/dapp/app/api/intent/orchestrate/route.ts`
+- Test: `apps/dapp/lib/__tests__/chain-limit-reading.test.ts`
+
+**Interfaces:**
+
+- Consumes: `parseChainLimit`, `limitSaid`, `LIMIT_NOT_HERE`.
+- Produces: `chainRouteFromReading` returns `{ action }` with `kind: 'limit'` when the instruction parses as one and `limitSaid` holds, and otherwise a refusal.
+
+- [ ] **Step 1: Write the failing tests**
+
+- Person: `I want to get rid of 1 sol once it reaches 110`. The model's instruction is `sell 1 SOL at $110`: runs as a limit.
+- The same with the model's instruction `sell 1 SOL at $120`: refused.
+- A reading with a limit style whose instruction doesn't parse: refused with an example, never turned into a market swap. This is the trap: today a limit reading with `style` ignored would run at market.
+- `buy 2 SOL at 90`: refused with `Say how much you will spend, for example "buy SOL with 180 USDC at $90".`
+- `sell $5 of SOL at $110`: refused with `Say how much SOL, for example "sell 0.05 SOL at $110".`
+- The Stellar prompt hash test still passes.
+
+- [ ] **Step 2: Run them to see them fail**
+- [ ] **Step 3: Implement.** The off-Stellar prompt says a stated price or "when it hits" is a limit, written out as `sell at $` or `buy