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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion apps/dapp/app/api/chain/[chain]/submit/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { NextResponse } from 'next/server'
import { z } from 'zod'

import { chainModule } from '../../../../../lib/chains/registry'
import { withoutWalletGuards } from '../../../../../lib/chains/solana/wallet-guards'
import { ChainActionRefused, type SigningPayload } from '../../../../../lib/chains/types'
import { logExecution } from '../../../../../lib/server/analytics'
import { enforceRateLimit } from '../../../../../lib/server/rate-limit'
Expand Down Expand Up @@ -72,7 +73,8 @@ export async function POST(
if (signed.family === 'solana' || signed.family === 'sui') {
const payload: SigningPayload =
signed.family === 'solana'
? { family: 'solana', transaction: signed.signedTransaction }
? // A wallet's own guard instructions aren't this app's to check; the rest still is.
{ family: 'solana', transaction: withoutWalletGuards(signed.signedTransaction) }
: { family: 'sui', transaction: signed.transaction }
try {
venue.assertSafe(payload, account, context)
Expand Down
103 changes: 103 additions & 0 deletions apps/dapp/e2e/dollar-cashout.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { expect, test, type Page, type Request } from '@playwright/test'

import { BETA_NOTICE_SEEN } from './beta-notice-seen'

/**
* A cash-out sized in dollars, "$0.8 worth of sol", must race a swap of SOL bought with those
* dollars, never 0.8 SOL. The price, Weave's currencies and the race are stubbed.
*/
test.use({ storageState: { cookies: [], origins: BETA_NOTICE_SEEN } })
// The chat page takes over a minute on a dev server's first hit.
test.beforeEach(() => test.slow())

const ADDRESS = 'vines1vzrYbzLMRdu58ou5XTby4qAqVRLmqo36NKPTg'
const SOL_PRICE = 90.24

async function connectFakeSolanaWallet(page: Page): Promise<void> {
await page.addInitScript(
({ address }) => {
const account = {
address,
publicKey: new Uint8Array(32),
chains: ['solana:mainnet'],
features: [],
}
const wallet = {
version: '1.0.0',
name: 'Fake Solana',
icon: 'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciLz4=',
chains: ['solana:mainnet'],
accounts: [account],
features: {
'standard:connect': { version: '1.0.0', connect: async () => ({ accounts: [account] }) },
'standard:events': { version: '1.0.0', on: () => () => undefined },
'solana:signTransaction': {
version: '1.0.0',
supportedTransactionVersions: ['legacy', 0],
signTransaction: async () => [],
},
},
}
const register = (api: { register: (w: unknown) => void }): void => {
api.register(wallet)
}
window.dispatchEvent(new CustomEvent('wallet-standard:register-wallet', { detail: register }))
window.addEventListener('wallet-standard:app-ready', (e) => {
register((e as CustomEvent).detail)
})
},
{ address: ADDRESS }
)

await page.goto('/solana/intents')
await page.getByRole('button', { name: 'Connect wallet' }).click()
const steps = page.locator('[data-connect-steps]')
await steps.getByRole('button', { name: 'Solana' }).click()
await steps.getByRole('button', { name: 'Fake Solana' }).click()
await expect(page.getByRole('textbox', { name: 'Describe your intent' })).toBeVisible()
}

/** Stubs what the chat asks before racing, and returns the race requests it makes. */
async function stubAround(page: Page): Promise<Request[]> {
await page.route('**/api/chain/solana/price**', (r) =>
r.fulfill({ json: { priceUsd: SOL_PRICE, decimals: 9 } })
)
await page.route('**/api/cashout/options**', (r) =>
r.fulfill({
json: {
currencies: [{ code: 'NGN', name: 'Nigerian naira', symbol: '₦', country: 'NG' }],
},
})
)
const races: Request[] = []
await page.route('**/api/agents/compete', (r) => {
races.push(r.request())
return r.fulfill({
status: 200,
contentType: 'text/event-stream',
body: `data: ${JSON.stringify({ type: 'competition:started', competitionId: 'race-1', agents: [{ key: 'groq:qwen', name: 'Qwen3.8 27B', gradient: 'a', model: 'qwen/qwen3.8-27b' }], windowSeconds: 30 })}

`,
})
})
return races
}

async function say(page: Page, text: string): Promise<void> {
await page.getByRole('textbox', { name: 'Describe your intent' }).fill(text)
await page.getByRole('button', { name: 'Send intent' }).click()
}

for (const text of ['swap $0.8 worth of sol to naira', 'offramp $0.8 worth of sol to naira']) {
test(`"${text}" races SOL bought with $0.8, not 0.8 SOL`, async ({ page }) => {
await connectFakeSolanaWallet(page)
const races = await stubAround(page)
await say(page, text)

await expect(page.getByText('$0.8 of SOL at $90.24 is 0.00886524 SOL.')).toBeVisible()
await expect.poll(() => races.length, { timeout: 30_000 }).toBeGreaterThan(0)
const body = JSON.stringify(races[0]?.postDataJSON())
expect(body).toContain('0.00886524')
expect(body).not.toMatch(/"amount":"0\.8"/)
})
}
82 changes: 82 additions & 0 deletions apps/dapp/lib/__tests__/chain-dollars-worth-of.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { describe, expect, it } from 'vitest'

import { dollarsWorthOf } from '../chains/parse-action'
import { readChainMessage } from '../chains/parse-sequence'
import { parseCashout, routeCashout } from '../weave/parse-cashout'

const read = (text: string) => readChainMessage(text, 'solana', 'Solana')

describe('"$X worth of TOKEN" read as dollars, before any model sees it', () => {
it('reads a swap into naira as dollars of SOL cashed out', () => {
for (const text of [
'swap $0.8 worth of sol to naira',
'swap $0.8 of sol to naira',
'convert 0.8 dollars worth of sol to naira',
]) {
expect(read(text), text).toMatchObject({
kind: 'cashout',
route: { kind: 'sequence', token: 'SOL', amount: '0.8', amountIsUsd: true },
})
}
})

it('reads an offramp as dollars of SOL, never 0.8 SOL', () => {
for (const text of [
'offramp $0.8 worth of sol to naira',
'cash out $0.8 of sol to naira',
'offramp 0.8 dollars worth of sol to naira',
'cash out $0.8 worth of my sol to naira',
]) {
const route = routeCashout(parseCashout(text), 'solana', true)
expect(route, text).toMatchObject({
kind: 'sequence',
token: 'SOL',
amount: '0.8',
amountIsUsd: true,
})
}
})

it('reads a swap to a token, and one with a follow-on, as dollars', () => {
expect(read('swap $5 worth of sol to usdc')).toMatchObject({
kind: 'action',
action: { kind: 'swap', amount: '5', amountIsUsd: true },
})
expect(read('swap $5 of sol to usdc then cash out')).toMatchObject({
kind: 'sequence',
request: { swap: { amount: '5', amountIsUsd: true } },
})
})

it('leaves a count of the token as a count', () => {
expect(read('swap 0.8 sol to usdc')).toMatchObject({
kind: 'action',
action: { amount: '0.8' },
})
expect(read('swap 0.8 sol to usdc')).not.toMatchObject({ action: { amountIsUsd: true } })
expect(routeCashout(parseCashout('offramp 0.8 sol to naira'), 'solana', true)).toMatchObject({
kind: 'sequence',
amount: '0.8',
})
})

it('keeps a dollar of USDC as one USDC', () => {
expect(read('swap $5 worth of usdc to sol')).toMatchObject({
kind: 'action',
action: { amount: '5', tokenIn: 'usdc' },
})
expect(read('swap $5 worth of usdc to sol')).not.toMatchObject({
action: { amountIsUsd: true },
})
})

it('rewrites only a figure followed by "worth of" or "of" and a word', () => {
expect(dollarsWorthOf('swap $0.8 worth of sol to naira')).toBe('swap $0.8 sol to naira')
expect(dollarsWorthOf('cash out 5 usd worth of my sol')).toBe('cash out $5 sol')
expect(dollarsWorthOf('swap 5 sol to usdc')).toBe('swap 5 sol to usdc')
expect(dollarsWorthOf('swap 5 of my sol to usdc')).toBe('swap 5 of my sol to usdc')
expect(dollarsWorthOf('sell 1 sol if it hits $110 of profit')).toBe(
'sell 1 sol if it hits $110 of profit'
)
})
})
65 changes: 65 additions & 0 deletions apps/dapp/lib/__tests__/chain-submit-route-guards.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { afterEach, describe, expect, it, vi } from 'vitest'

import { guarded } from './solana-wallet-guards.fixture'
import {
CAPTURE_ACCOUNT,
JUPITER_SOL_TO_USDC,
} from '../chains/solana/__fixtures__/captured-transactions'
import type { SubmitInput, SubmitResult } from '../chains/types'

const state = vi.hoisted(() => ({ sent: [] as string[] }))
const restore: (() => void)[] = []

vi.mock('../server/analytics', () => ({ logExecution: async () => undefined }))
vi.mock('../server/rate-limit', () => ({ enforceRateLimit: async () => undefined }))
vi.mock('../server/report', () => ({ reportError: () => undefined }))
/** The real Solana module and checks, with only Jupiter's sending replaced. */
async function submit(signedTransaction: string): Promise<Response> {
const { POST } = await import('../../app/api/chain/[chain]/submit/route')
const { chainModule } = await import('../chains/registry')
const jupiter = chainModule('solana')?.venues.find((v) => v.id === 'jupiter')
if (jupiter === undefined) throw new Error('no jupiter venue')
const original = jupiter.submit
restore.push(() => {
jupiter.submit = original
})
jupiter.submit = async (input: SubmitInput): Promise<SubmitResult> => {
if (input.signed.family === 'solana') state.sent.push(input.signed.signedTransaction)
return { ok: true, hash: 'sig', explorerUrl: 'https://solscan.io/tx/sig' }
}
return POST(
new Request('http://localhost/api/chain/solana/submit', {
method: 'POST',
body: JSON.stringify({
account: CAPTURE_ACCOUNT,
venueId: 'jupiter',
signed: { family: 'solana', signedTransaction },
context: { requestId: 'r' },
}),
}),
{ params: Promise.resolve({ chain: 'solana' }) }
)
}

afterEach(() => {
state.sent = []
restore.splice(0).forEach((undo) => undo())
vi.resetModules()
})

describe('the chain submit route and a wallet’s guard instructions', { timeout: 60_000 }, () => {
it('sends a Jupiter swap Phantom guarded, exactly as the wallet signed it', async () => {
const signed = guarded(JUPITER_SOL_TO_USDC, 'L2TExMFKdjpN9kozasaurPirfHy9P8sbXoAN1qA3S95')
const res = await submit(signed)
expect(res.status).toBe(200)
expect(state.sent).toEqual([signed])
})

it('still refuses, and sends nothing, when anything else was added', async () => {
const signed = guarded(JUPITER_SOL_TO_USDC, 'CAMMCzo5YL8w4VFF8KVHrK22GGUsp5VTaW7grrKgrWqK')
const res = await submit(signed)
expect(res.status).toBe(422)
expect((await res.json()).error).toMatch(/Jupiter should not need/)
expect(state.sent).toEqual([])
})
})
Loading
Loading