diff --git a/apps/dapp/app/api/chain/[chain]/submit/route.ts b/apps/dapp/app/api/chain/[chain]/submit/route.ts index b39d46b8..abc29490 100644 --- a/apps/dapp/app/api/chain/[chain]/submit/route.ts +++ b/apps/dapp/app/api/chain/[chain]/submit/route.ts @@ -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' @@ -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) diff --git a/apps/dapp/e2e/dollar-cashout.spec.ts b/apps/dapp/e2e/dollar-cashout.spec.ts new file mode 100644 index 00000000..bad01970 --- /dev/null +++ b/apps/dapp/e2e/dollar-cashout.spec.ts @@ -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 { + 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 { + 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 { + 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"/) + }) +} diff --git a/apps/dapp/lib/__tests__/chain-dollars-worth-of.test.ts b/apps/dapp/lib/__tests__/chain-dollars-worth-of.test.ts new file mode 100644 index 00000000..c1df02af --- /dev/null +++ b/apps/dapp/lib/__tests__/chain-dollars-worth-of.test.ts @@ -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' + ) + }) +}) diff --git a/apps/dapp/lib/__tests__/chain-submit-route-guards.test.ts b/apps/dapp/lib/__tests__/chain-submit-route-guards.test.ts new file mode 100644 index 00000000..323b8cea --- /dev/null +++ b/apps/dapp/lib/__tests__/chain-submit-route-guards.test.ts @@ -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 { + 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 => { + 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([]) + }) +}) diff --git a/apps/dapp/lib/__tests__/raydium-swap.test.ts b/apps/dapp/lib/__tests__/raydium-swap.test.ts new file mode 100644 index 00000000..81f99b3c --- /dev/null +++ b/apps/dapp/lib/__tests__/raydium-swap.test.ts @@ -0,0 +1,166 @@ +import { + getCompiledTransactionMessageDecoder, + getCompiledTransactionMessageEncoder, + getTransactionDecoder, +} from '@solana/kit' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { RAYDIUM_SOL_TO_USDC, RAYDIUM_WALLET } from '../chains/solana/__fixtures__/raydium-captured' +import { RAYDIUM_ROUTE_PROGRAM } from '../chains/solana/programs' +import { assertRaydiumRoute, createRaydiumSwapVenue } from '../chains/solana/raydium-swap' +import { ChainActionRefused } from '../chains/types' + +import { guarded } from './solana-wallet-guards.fixture' + +const { transaction, amountIn, minimumOut, outputAmount } = RAYDIUM_SOL_TO_USDC +const expected = { amountIn: BigInt(amountIn), minimumOut: BigInt(minimumOut), wrapsSol: true } +const context = { amountIn, minimumOut, wrapsSol: '1' } +const solana = (tx: string) => ({ family: 'solana' as const, transaction: tx }) + +/** The capture with one route instruction's data rewritten, the way a tampered build would read. */ +function withRouteData(index: number, data: number[]): string { + const { messageBytes } = getTransactionDecoder().decode(Buffer.from(transaction, 'base64')) + const message = getCompiledTransactionMessageDecoder().decode(messageBytes) + if (message.version !== 'legacy' && message.version !== 0) throw new Error('not v0') + const route = message.staticAccounts.indexOf(RAYDIUM_ROUTE_PROGRAM as never) + let seen = -1 + const instructions = message.instructions.map((ix) => { + if (ix.programAddressIndex !== route) return ix + seen += 1 + return seen === index ? { ...ix, data: new Uint8Array(data) } : ix + }) + const bytes = getCompiledTransactionMessageEncoder().encode({ ...message, instructions }) + return Buffer.concat([Buffer.from([1]), Buffer.alloc(64), Buffer.from(bytes)]).toString('base64') +} + +const u64 = (value: bigint): number[] => + Array.from({ length: 8 }, (_, i) => Number((value >> BigInt(8 * i)) & BigInt(255))) + +describe('a Raydium swap Raydium built', () => { + it('passes, held to the quote it came from', () => { + expect(() => assertRaydiumRoute(transaction, expected)).not.toThrow() + const venue = createRaydiumSwapVenue({ priceUsd: async () => 1 }) + expect(() => venue.assertSafe(solana(transaction), RAYDIUM_WALLET, context)).not.toThrow() + }) + + it('refuses a swap of another amount, or for less than the floor', () => { + expect(() => assertRaydiumRoute(transaction, { ...expected, amountIn: BigInt(1) })).toThrow( + /different amount|wrap SOL/ + ) + expect(() => + assertRaydiumRoute(transaction, { ...expected, minimumOut: expected.minimumOut + BigInt(1) }) + ).toThrow(/accepts less/) + }) + + it('refuses a swap whose minimum was rewritten to zero', () => { + const tampered = withRouteData(1, [0, ...u64(expected.amountIn), ...u64(BigInt(0))]) + expect(() => assertRaydiumRoute(tampered, expected)).toThrow(/accepts less/) + }) + + it('refuses wrapping SOL when SOL is not what is spent, or more than is spent', () => { + expect(() => assertRaydiumRoute(transaction, { ...expected, wrapsSol: false })).toThrow( + /wrap SOL/ + ) + const more = withRouteData(0, [5, ...u64(expected.amountIn * BigInt(100))]) + expect(() => assertRaydiumRoute(more, expected)).toThrow(/wrap SOL/) + }) + + it('refuses a route instruction it can’t read', () => { + expect(() => assertRaydiumRoute(withRouteData(2, [9]), expected)).toThrow(/didn’t expect/) + }) + + it('refuses without the quote it was built from, and any other program added', () => { + const venue = createRaydiumSwapVenue({ priceUsd: async () => 1 }) + expect(() => venue.assertSafe(solana(transaction), RAYDIUM_WALLET, {})).toThrow( + ChainActionRefused + ) + const foreign = guarded(transaction, 'CAMMCzo5YL8w4VFF8KVHrK22GGUsp5VTaW7grrKgrWqK') + expect(() => venue.assertSafe(solana(foreign), RAYDIUM_WALLET, context)).toThrow( + /Raydium should not need/ + ) + }) +}) + +describe('Raydium’s quote and build', () => { + afterEach(() => vi.unstubAllGlobals()) + + const quoteBody = (over: Record = {}) => ({ + success: true, + data: { + swapType: 'BaseIn', + inputMint: 'So11111111111111111111111111111111111111112', + outputMint: 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', + inputAmount: amountIn, + outputAmount, + otherAmountThreshold: minimumOut, + slippageBps: 100, + priceImpactPct: 0, + routePlan: [{ poolId: 'CYbD9RaToYMtWKA7QZyoLahnHdWq553Vm62Lh6qWtuxq' }], + ...over, + }, + }) + + function stub(quote: unknown, built: unknown = { success: true, data: [{ transaction }] }) { + vi.stubGlobal( + 'fetch', + vi.fn(async (url: string) => Response.json(String(url).includes('/compute/') ? quote : built)) + ) + } + + const venue = () => + createRaydiumSwapVenue({ + priceUsd: async (token) => (token.symbol === 'SOL' ? 90 : 1), + retryDelayMs: 0, + }) + const request = { kind: 'swap' as const, amount: '0.007', tokenIn: 'SOL', tokenOut: 'USDC' } + + it('prepares the transaction Raydium built, with what the check needs to hold it', async () => { + stub(quoteBody()) + const prepared = await venue().prepare(request, RAYDIUM_WALLET) + expect(prepared).toMatchObject({ + venueId: 'raydium', + signing: { family: 'solana', transaction }, + context, + }) + }) + + it('refuses a quote for another amount, too much slippage or a large price impact', async () => { + stub(quoteBody({ inputAmount: '1' })) + await expect(venue().quote?.(request, RAYDIUM_WALLET)).rejects.toThrow(/not what you asked/) + stub(quoteBody({ slippageBps: 500 })) + await expect(venue().quote?.(request, RAYDIUM_WALLET)).rejects.toThrow(/slippage/) + stub(quoteBody({ priceImpactPct: 7 })) + await expect(venue().quote?.(request, RAYDIUM_WALLET)).rejects.toThrow(/price impact/) + }) + + it('sends Raydium back its whole quote, route details and all', async () => { + const sent: unknown[] = [] + const quote = quoteBody() + const route = quote.data.routePlan[0] as Record + route['remainingAccounts'] = ['A9kPFmUkWMu99n66QGKKLXohNhR9hRFhQj3hi9JGLdqA'] + vi.stubGlobal( + 'fetch', + vi.fn(async (url: string, init?: RequestInit) => { + if (String(url).includes('/compute/')) return Response.json({ ...quote, id: 'q-1' }) + sent.push(JSON.parse(String(init?.body))) + return Response.json({ success: true, data: [{ transaction }] }) + }) + ) + await venue().prepare(request, RAYDIUM_WALLET) + expect(sent[0]).toMatchObject({ + wallet: RAYDIUM_WALLET, + wrapSol: true, + swapResponse: { + id: 'q-1', + data: { routePlan: [{ remainingAccounts: [expect.any(String)] }] }, + }, + }) + }) + + it('refuses a build of more than one transaction', async () => { + stub(quoteBody(), { success: true, data: [{ transaction }, { transaction }] }) + await expect(venue().prepare(request, RAYDIUM_WALLET)).rejects.toThrow( + /could not build this swap/ + ) + }) +}) diff --git a/apps/dapp/lib/__tests__/solana-venues.test.ts b/apps/dapp/lib/__tests__/solana-venues.test.ts index 3365e985..11af1c01 100644 --- a/apps/dapp/lib/__tests__/solana-venues.test.ts +++ b/apps/dapp/lib/__tests__/solana-venues.test.ts @@ -668,6 +668,7 @@ describe('module', () => { it('adds 1Click once the secret that protects its deposits is there', () => { expect(buildSolanaVenues({ AUTH_SECRET: 'x'.repeat(48) }).map((v) => v.id)).toEqual([ 'jupiter', + 'raydium', '1click-solana', 'jupiter-lend', 'kamino', @@ -684,6 +685,7 @@ describe('module', () => { it('offers the venues that need no key', () => { expect(buildSolanaVenues({}).map((v) => v.id)).toEqual([ 'jupiter', + 'raydium', 'jupiter-lend', 'kamino', 'kamino-limit', @@ -697,8 +699,13 @@ describe('module', () => { }) it('lets only the swaps land their own transactions', () => { - const [swap, oneClick, lend, kamino] = buildSolanaVenues({ AUTH_SECRET: 'x'.repeat(48) }) + const [swap, raydium, oneClick, lend, kamino] = buildSolanaVenues({ + AUTH_SECRET: 'x'.repeat(48), + }) expect(swap?.submit).toBeTypeOf('function') + // Raydium has no endpoint that sends, so its swap lands through the module's own submit. + expect(raydium?.id).toBe('raydium') + expect(raydium?.submit).toBeUndefined() expect(oneClick?.submit).toBeTypeOf('function') expect(lend?.submit).toBeUndefined() expect(kamino?.submit).toBeUndefined() @@ -741,8 +748,8 @@ describe('module', () => { describe('venues page', () => { it('lists every row of the report', () => { - expect(solanaVenues).toHaveLength(21) - expect(new Set(solanaVenues.map((v) => v.id)).size).toBe(21) + expect(solanaVenues).toHaveLength(22) + expect(new Set(solanaVenues.map((v) => v.id)).size).toBe(22) for (const v of solanaVenues) { expect(v.family).toBe('solana') expect(v.chains).toEqual(['Solana']) diff --git a/apps/dapp/lib/__tests__/solana-wallet-guards.fixture.ts b/apps/dapp/lib/__tests__/solana-wallet-guards.fixture.ts new file mode 100644 index 00000000..653c2fe9 --- /dev/null +++ b/apps/dapp/lib/__tests__/solana-wallet-guards.fixture.ts @@ -0,0 +1,43 @@ +import { + getCompiledTransactionMessageDecoder, + getCompiledTransactionMessageEncoder, + getTransactionDecoder, + type Address, +} from '@solana/kit' + +type Message = ReturnType['decode']> + +/** + * What a wallet does when it adds an instruction before signing: the program goes on the end of + * the read-only accounts, lookup indices after it shift up by one, and the instruction is appended. + */ +export function guarded(base64: string, program: string, data: number[] = [5]): string { + const { messageBytes } = getTransactionDecoder().decode(Buffer.from(base64, 'base64')) + const message = getCompiledTransactionMessageDecoder().decode(messageBytes) as Message + if (message.version !== 'legacy' && message.version !== 0) throw new Error('not a v0 capture') + const old = message.staticAccounts.length + const shift = (i: number): number => (i >= old ? i + 1 : i) + const next = { + ...message, + header: { + ...message.header, + numReadonlyNonSignerAccounts: message.header.numReadonlyNonSignerAccounts + 1, + }, + staticAccounts: [...message.staticAccounts, program as Address], + instructions: [ + ...message.instructions.map((ix) => ({ + ...ix, + programAddressIndex: shift(ix.programAddressIndex), + accountIndices: (ix.accountIndices ?? []).map(shift), + })), + { programAddressIndex: old, accountIndices: [0], data: new Uint8Array(data) }, + ], + } + const bytes = getCompiledTransactionMessageEncoder().encode(next as Message) + const signers = message.header.numSignerAccounts + return Buffer.concat([ + Buffer.from([signers]), + Buffer.alloc(64 * signers), + Buffer.from(bytes), + ]).toString('base64') +} diff --git a/apps/dapp/lib/__tests__/solana-wallet-guards.test.ts b/apps/dapp/lib/__tests__/solana-wallet-guards.test.ts new file mode 100644 index 00000000..5a0fcd5d --- /dev/null +++ b/apps/dapp/lib/__tests__/solana-wallet-guards.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest' + +import { + CAPTURE_ACCOUNT, + JUPITER_SOL_TO_USDC, + KAMINO_USDC_DEPOSIT, +} from '../chains/solana/__fixtures__/captured-transactions' +import { solanaAssertSafe } from '../chains/solana/assert-safe' +import { JUPITER_SWAP_POLICY, KAMINO_POLICY } from '../chains/solana/programs' +import { LIGHTHOUSE_PROGRAM, withoutWalletGuards } from '../chains/solana/wallet-guards' + +import { guarded as added } from './solana-wallet-guards.fixture' + +const FOREIGN_PROGRAM = 'CAMMCzo5YL8w4VFF8KVHrK22GGUsp5VTaW7grrKgrWqK' +const solana = (transaction: string) => ({ family: 'solana' as const, transaction }) +const swapSafe = solanaAssertSafe(JUPITER_SWAP_POLICY) +const kaminoSafe = solanaAssertSafe(KAMINO_POLICY) + +describe('a wallet’s Lighthouse guards', () => { + it('are what the check refused before, on a real Jupiter swap', () => { + const guarded = added(JUPITER_SOL_TO_USDC, LIGHTHOUSE_PROGRAM, [5, 0, 1]) + expect(() => swapSafe(solana(guarded), CAPTURE_ACCOUNT)).toThrow(/Jupiter should not need/) + }) + + it('are left out of what the check sees, so the swap passes it', () => { + const guarded = added(JUPITER_SOL_TO_USDC, LIGHTHOUSE_PROGRAM, [5, 0, 1]) + expect(() => swapSafe(solana(withoutWalletGuards(guarded)), CAPTURE_ACCOUNT)).not.toThrow() + const deposit = added(KAMINO_USDC_DEPOSIT, LIGHTHOUSE_PROGRAM, [5]) + expect(() => kaminoSafe(solana(withoutWalletGuards(deposit)), CAPTURE_ACCOUNT)).not.toThrow() + }) + + it('do not cover any other program added beside them', () => { + const both = added(added(JUPITER_SOL_TO_USDC, LIGHTHOUSE_PROGRAM, [5]), FOREIGN_PROGRAM, [1]) + expect(() => swapSafe(solana(withoutWalletGuards(both)), CAPTURE_ACCOUNT)).toThrow( + /should not need/ + ) + }) + + it('change nothing in a transaction that has none, or one that cannot be read', () => { + expect(withoutWalletGuards(JUPITER_SOL_TO_USDC)).toBe(JUPITER_SOL_TO_USDC) + expect(withoutWalletGuards('not a transaction')).toBe('not a transaction') + }) + + it('leave smuggled bytes after the message for the check to refuse', () => { + const guarded = added(JUPITER_SOL_TO_USDC, LIGHTHOUSE_PROGRAM, [5]) + const smuggled = Buffer.concat([Buffer.from(guarded, 'base64'), Buffer.from([1, 2, 3])]) + const base64 = smuggled.toString('base64') + expect(withoutWalletGuards(base64)).toBe(base64) + expect(() => swapSafe(solana(withoutWalletGuards(base64)), CAPTURE_ACCOUNT)).toThrow() + }) +}) diff --git a/apps/dapp/lib/chains/parse-action.ts b/apps/dapp/lib/chains/parse-action.ts index ca6a4481..0e9b82ea 100644 --- a/apps/dapp/lib/chains/parse-action.ts +++ b/apps/dapp/lib/chains/parse-action.ts @@ -113,6 +113,56 @@ export function chainNamesOf(token: string): string[] { return [...TOKEN_OF_CHAIN_NAME].filter(([, symbol]) => symbol === wanted).map(([name]) => name) } +const FIGURE = String.raw`(\d[\d,]*(?:\.\d+)?|\.\d+)` +/** "$5 worth of sol", "$5 of sol", "5 dollars worth of sol" and "5 usd of sol": a figure in dollars. */ +const WORTH_OF = new RegExp( + String.raw`(^|[^\w$.,])(?:\$\s?${FIGURE}|${FIGURE}\s?(?:usd|dollars?|bucks))\s+(worth\s+of|of)\s+(?:my\s+|the\s+)?([a-z][a-z0-9.+]{0,11})(?![a-z0-9])`, + 'gi' +) +/** After a bare "of", only a token counts: "$110 of profit" is no amount of anything. */ +const TOKEN_AFTER_OF = new Set([ + 'sol', + 'sui', + 'near', + 'avax', + 'mon', + 'xlm', + 'usdc', + 'usdt', + 'eth', + 'btc', + 'wbtc', + 'wal', + 'deep', + 'jup', + 'bonk', + 'jitosol', + 'msol', +]) + +/** + * "$0.8 worth of sol" as "$0.8 sol", the one shape the strict parsers read as dollars, so no model + * is left to decide between dollars and a count. Nothing else in the text changes. + */ +export function dollarsWorthOf(text: string): string { + return text.replace( + WORTH_OF, + ( + whole, + lead: string, + dollar: string | undefined, + word: string | undefined, + link: string, + token: string + ) => { + const isToken = + TOKEN_AFTER_OF.has(token.toLowerCase()) || TOKEN_OF_CHAIN_NAME.has(token.toLowerCase()) + if (!/^worth/i.test(link) && !isToken) return whole + return `${lead}$${dollar ?? word} ${token}` + } + ) +} + /** Plain decimal text: no separators, one leading zero on ".5" and none on "05". */ export function amountOf(text: string): string { const plain = text.replace(/,/g, '') diff --git a/apps/dapp/lib/chains/parse-sequence.ts b/apps/dapp/lib/chains/parse-sequence.ts index 84152aed..02746baf 100644 --- a/apps/dapp/lib/chains/parse-sequence.ts +++ b/apps/dapp/lib/chains/parse-sequence.ts @@ -8,7 +8,14 @@ import { 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 { + AMOUNT, + amountOf, + chainTokenWord, + dollarsOn, + dollarsWorthOf, + parseChainAction, +} from './parse-action' import { SEQUENCE_CASHOUT_CHAINS, followOnRefusal, @@ -158,6 +165,8 @@ export function readChainMessage( chain: ChainWithActions, chainName: string ): ChainMessageRead { + // "$5 worth of sol" is read here as dollars, so it never waits on a model to say which it is. + text = dollarsWorthOf(text) const sequence = parseChainSequence(text) if (sequence !== null) { return 'refusal' in sequence diff --git a/apps/dapp/lib/chains/solana/__fixtures__/raydium-captured.ts b/apps/dapp/lib/chains/solana/__fixtures__/raydium-captured.ts new file mode 100644 index 00000000..d4669006 --- /dev/null +++ b/apps/dapp/lib/chains/solana/__fixtures__/raydium-captured.ts @@ -0,0 +1,12 @@ +/** + * A Raydium swap of 0.007 SOL to USDC, quoted and built by its Trade API on 6 November 2026 for a + * mainnet wallet holding SOL and no USDC account. Unsigned, as the API returns it. + */ +export const RAYDIUM_WALLET = 'D1P9dPB1VLnPBMXe52hpJXgScaKjexZ3jx3KctwqHfAS' +export const RAYDIUM_SOL_TO_USDC = { + amountIn: '7000000', + minimumOut: '625927', + outputAmount: '632250', + transaction: + 'AQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAQADELJlSWqjY+mi4QsICy1wALDtUFXgyEJ5jxL/sy6+/br7OhKTyJCVQavw3+xTqBVeXjKok3ohG4BTqdCCmE6CqioGm4hX/quBhPtof2NGGMA12sQ53BrrO1WYoPAAAAAAAcRKFZ0xdEPtNlaTxGSLA3L9Ye4eYfyt3XWcwYQsBnktxvp6877brTo9ZfNqq8l0MbG75MLS9uDkfKYCA0UvXWHKeszODNwtUne7liBz99xuNgD9mYPLnJ/5+pnkI4w9uquIPfFXPG5PXNZDEtsSGLQecaw/VCp28G+GN3vn4xr+7KJBVmCZifcEYsmpg02BhcQhQHE3HNgzpdvONYmhQq4mjlrT/u66uEVsgrsFhNF3/1Fpr3t1tm6oWJxqpNyYcogMaVyqFKIDplH7zb/O/VGBOo5CTuRB6NMUxWX0x3o3WZelM/Kp+/r362y1lXoXNo4aHWVpvev+YZkKNrMUQoKH9uIGxJ7bShszyGpRb9P5/ZpCb+JQi4WmCqzGdqqFAQ0SaYdlQcIwJnWZ7PE69iqw0Um3C3TcCYiNfeDQdRgADMKAy2vjGSxQODgBz0QwGA+eP4ZjIjrIAQaXENv31QcFSlNamSkhBk0k6HFg2jh8fDW13bySu4HkH6hAQQVEjQMGRm/lIRcy/+ytunLDm+e8jOW7xfcSayxDmzpAAAAAB7sl0KKHtnkKOMa+d17beTdWRk4YbMzeZSX56zIIyWUFDQYAAQIQERIJBcDPagAAAAAADRQQExESAAEDFAMCBAUGBwgJDgoLDBEAwM9qAAAAAAAHjQkAAAAAAA0GAAEAEBESAQYPAAkDZXIFAAAAAAAPAAUCJiMCAAEZjx9MOkUiY9QTss0X68vBoOWIc2TmJhoSqBeS6hZaPgAFAgcAAxA=', +} diff --git a/apps/dapp/lib/chains/solana/module.ts b/apps/dapp/lib/chains/solana/module.ts index dbf6a5ad..331be65a 100644 --- a/apps/dapp/lib/chains/solana/module.ts +++ b/apps/dapp/lib/chains/solana/module.ts @@ -11,6 +11,7 @@ import { createKaminoLendVenue } from './kamino-lend' import { createLuloVenue } from './lulo' import { createMarinadeVenue } from './marinade' import { createPriceSource } from './price' +import { createRaydiumSwapVenue } from './raydium-swap' import { createSolayerVenue } from './solayer' import { createStreamflowVenue } from './streamflow' import { createSolanaSubmit } from './submit' @@ -27,6 +28,7 @@ export function buildSolanaVenues( const luloKey = env['LULO_API_KEY']?.trim() return [ createJupiterSwapVenue({ priceUsd, env }), + createRaydiumSwapVenue({ priceUsd }), ...(canIssueOneClick(env) ? [oneClickOriginVenue('solana')] : []), createJupiterLendVenue({ env }), // After Earn, so "supply on Kamino" stays the vault it has always meant. diff --git a/apps/dapp/lib/chains/solana/programs.ts b/apps/dapp/lib/chains/solana/programs.ts index 4f0f9872..8234d41c 100644 --- a/apps/dapp/lib/chains/solana/programs.ts +++ b/apps/dapp/lib/chains/solana/programs.ts @@ -97,6 +97,19 @@ export const JUPITER_SWAP_POLICY: SolanaVenuePolicy = { mayWrapSol: true, } +/** + * Raydium's AMM routing program, https://docs.raydium.io/reference/program-addresses. Its source and + * IDL are not public, so `assertRaydiumRoute` reads the three instructions its Trade API builds. + */ +export const RAYDIUM_ROUTE_PROGRAM = 'routeUGWgWzqBWFcrCfv8tritsqukccJPu3q5GPP3xS' + +/** Raydium wraps and unwraps SOL inside its router, which `assertRaydiumRoute` bounds. */ +export const RAYDIUM_SWAP_POLICY: SolanaVenuePolicy = { + venueName: 'Raydium', + programs: { [RAYDIUM_ROUTE_PROGRAM]: {} }, + mayWrapSol: true, +} + export const JUPITER_LEND_POLICY: SolanaVenuePolicy = { venueName: 'Jupiter Lend', programs: { [JUPITER_LEND_PROGRAM]: { discriminators: [DEPOSIT_DISCRIMINATOR] } }, diff --git a/apps/dapp/lib/chains/solana/raydium-swap.ts b/apps/dapp/lib/chains/solana/raydium-swap.ts new file mode 100644 index 00000000..7e5b0540 --- /dev/null +++ b/apps/dapp/lib/chains/solana/raydium-swap.ts @@ -0,0 +1,299 @@ +import { isAddress } from '@solana/kit' +import { z } from 'zod' + +import { + ChainActionRefused, + tokenBySymbol, + toBaseUnits, + type ChainActionRequest, + type ChainToken, + type ChainVenue, + type PreparedAction, + type SigningPayload, + type VenueQuote, +} from '../types' +import { + associatedTokenAccount, + decodeSolanaTransaction, + readLittleEndian, + solanaAssertSafe, +} from './assert-safe' +import { venueFetch } from './http' +import { RAYDIUM_ROUTE_PROGRAM, RAYDIUM_SWAP_POLICY } from './programs' +import { fromBaseUnits, SOLANA_TOKENS } from './tokens' + +/** Raydium's Trade API: https://docs.raydium.io/sdk-api/trade-api. No key. */ +const RAYDIUM_API = 'https://transaction-v1.raydium.io' +const SLIPPAGE_BPS = 100 +const MAX_PRICE_IMPACT_PERCENT = 3 +const MAX_VALUE_LOSS = 0.05 +/** About 10,000 lamports at the compute Raydium asks for, far under assert-safe's ceiling. */ +const PRIORITY_MICRO_LAMPORTS = '50000' +const WRAPPED_SOL = 'So11111111111111111111111111111111111111112' + +/** + * Raydium's route instructions, by their first byte, as its Trade API built them for SOL to USDC, + * USDC to SOL and USDC to USDT on 6 November 2026: a swap with the amount in and the least out, a + * wrap of the SOL spent, and a closing step with no data. Nothing else is expected. + */ +const SWAP_BASE_IN = 0 +const WRAP_SOL = 5 +const CLOSE = 6 + +const Quote = z.object({ + success: z.literal(true), + data: z.object({ + swapType: z.literal('BaseIn'), + inputMint: z.string(), + outputMint: z.string(), + inputAmount: z.string().regex(/^\d+$/), + outputAmount: z.string().regex(/^\d+$/), + otherAmountThreshold: z.string().regex(/^\d+$/), + slippageBps: z.number(), + priceImpactPct: z.number(), + routePlan: z.array(z.object({ poolId: z.string() })).min(1), + }), +}) + +const Built = z.object({ + success: z.literal(true), + data: z.array(z.object({ transaction: z.string().min(1) })), +}) + +function refuse(sentence: string): never { + throw new ChainActionRefused( + `${sentence} I refused it, so nothing was signed or sent. Try again, or use another venue if it keeps happening.` + ) +} + +/** What a Raydium transaction must do: spend this much, accept no less than this, and wrap only it. */ +export interface RaydiumExpectation { + amountIn: bigint + minimumOut: bigint + wrapsSol: boolean +} + +/** + * Holds each Raydium route instruction to what was asked: one swap of exactly the amount, for no + * less than the quote's floor, a SOL wrap of that same amount only when SOL is spent, and at most + * one closing step. Any other route instruction is refused, since its meaning can't be read. + */ +export function assertRaydiumRoute(base64: string, expected: RaydiumExpectation): void { + const transaction = decodeSolanaTransaction(base64) + let swaps = 0 + let wraps = 0 + let closes = 0 + for (const instruction of transaction.instructions) { + if (transaction.staticAccounts[instruction.programIndex] !== RAYDIUM_ROUTE_PROGRAM) continue + const { data } = instruction + const tag = data[0] + if (tag === SWAP_BASE_IN && data.length === 17) { + swaps += 1 + if (readLittleEndian(data, 1, 8) !== expected.amountIn) { + refuse('This Raydium swap spends a different amount from the one you asked.') + } + if (readLittleEndian(data, 9, 8) !== expected.minimumOut) { + refuse('This Raydium swap accepts less than the quote it was built from.') + } + } else if (tag === WRAP_SOL && data.length === 9) { + wraps += 1 + if (!expected.wrapsSol || readLittleEndian(data, 1, 8) !== expected.amountIn) { + refuse('This Raydium swap would wrap SOL you did not ask to spend.') + } + } else if (tag === CLOSE && data.length === 1) { + closes += 1 + } else { + refuse('This transaction asks Raydium for an instruction I didn’t expect.') + } + } + if (swaps !== 1 || wraps > 1 || closes > 1) { + refuse('This transaction is not the one Raydium swap that was quoted.') + } +} + +function expectationOf(context: Record | undefined): RaydiumExpectation { + const amountIn = context?.['amountIn'] + const minimumOut = context?.['minimumOut'] + if ( + amountIn === undefined || + minimumOut === undefined || + !/^\d+$/.test(amountIn) || + !/^\d+$/.test(minimumOut) + ) { + return refuse('This swap lost track of its quote.') + } + return { + amountIn: BigInt(amountIn), + minimumOut: BigInt(minimumOut), + wrapsSol: context?.['wrapsSol'] === '1', + } +} + +function shortAmount(amount: string): string { + const value = Number(amount) + return value >= 1 ? String(Number(value.toFixed(4))) : String(Number(value.toPrecision(4))) +} + +export interface RaydiumSwapOptions { + priceUsd: (token: ChainToken) => Promise + retryDelayMs?: number +} + +/** + * Raydium's Trade API: a quote, then a transaction built from it for the wallet to sign. It lands + * through the Solana module's own submit, since Raydium has no endpoint that sends. + */ +export function createRaydiumSwapVenue(options: RaydiumSwapOptions): ChainVenue { + const programsSafe = solanaAssertSafe(RAYDIUM_SWAP_POLICY) + const retry = options.retryDelayMs !== undefined ? { retryDelayMs: options.retryDelayMs } : {} + + async function checkedQuote(request: ChainActionRequest, account: string) { + if (!isAddress(account)) throw new ChainActionRefused('That is not a Solana wallet address.') + if (request.kind !== 'swap') { + throw new ChainActionRefused(`Raydium swaps tokens. It can't ${request.kind} here.`) + } + const tokenIn = tokenBySymbol(SOLANA_TOKENS, request.tokenIn) + const tokenOut = tokenBySymbol(SOLANA_TOKENS, request.tokenOut ?? '') + if (tokenIn === undefined || tokenOut === undefined || tokenIn.id === tokenOut.id) { + throw new ChainActionRefused('I can only swap tokens I have confirmed on Solana.') + } + const amount = toBaseUnits(request.amount, tokenIn.decimals) + const query = new URLSearchParams({ + inputMint: tokenIn.id, + outputMint: tokenOut.id, + amount, + slippageBps: String(SLIPPAGE_BPS), + txVersion: 'V0', + }) + const { status, body } = await venueFetch(`${RAYDIUM_API}/compute/swap-base-in?${query}`, { + label: 'Raydium', + ...retry, + }) + const parsed = Quote.safeParse(body) + if (status !== 200 || !parsed.success) { + throw new ChainActionRefused( + 'Raydium has no route for this swap right now. Nothing was signed or sent.' + ) + } + const quote = parsed.data.data + if ( + quote.inputMint !== tokenIn.id || + quote.outputMint !== tokenOut.id || + quote.inputAmount !== amount + ) { + throw new ChainActionRefused( + 'Raydium returned a quote that is not what you asked for, so I stopped here. Nothing was signed or sent.' + ) + } + if (quote.slippageBps > SLIPPAGE_BPS) { + throw new ChainActionRefused( + 'This quote allows more slippage than the 1% I accept, so I stopped here. Nothing was signed or sent.' + ) + } + if (Math.abs(quote.priceImpactPct) > MAX_PRICE_IMPACT_PERCENT) { + throw new ChainActionRefused( + `This swap has a price impact of about ${Math.abs(quote.priceImpactPct).toFixed(1)}% on Raydium, above the ${MAX_PRICE_IMPACT_PERCENT}% I accept. Try a smaller amount.` + ) + } + const minimum = fromBaseUnits(quote.otherAmountThreshold, tokenOut.decimals) + const [priceIn, priceOut] = await Promise.all([ + options.priceUsd(tokenIn), + options.priceUsd(tokenOut), + ]) + if (priceIn === undefined || priceOut === undefined) { + throw new ChainActionRefused( + `I can't value both sides of this swap right now, so I can't check it's fair. Nothing was signed or sent, so try again in a moment.` + ) + } + const valueIn = Number(request.amount) * priceIn + const valueOut = Number(minimum) * priceOut + if (valueOut < valueIn * (1 - MAX_VALUE_LOSS)) { + throw new ChainActionRefused( + `At worst this swap returns about $${valueOut.toFixed(2)} of ${tokenOut.symbol} for $${valueIn.toFixed(2)} of ${tokenIn.symbol}, more than ${MAX_VALUE_LOSS * 100}% less than it is worth. That usually means thin liquidity, so I stopped here.` + ) + } + // Sent back whole: the build reads route fields this check has no need of, and parsing drops them. + return { raw: body, quote, tokenIn, tokenOut, amount, minimum } + } + + async function quote(request: ChainActionRequest, account: string): Promise { + const { quote: q, tokenOut } = await checkedQuote(request, account) + return { + receive: { + amount: fromBaseUnits(q.outputAmount, tokenOut.decimals), + symbol: tokenOut.symbol, + }, + } + } + + async function prepare(request: ChainActionRequest, account: string): Promise { + const { + raw, + quote: q, + tokenIn, + tokenOut, + amount, + minimum, + } = await checkedQuote(request, account) + const wrapsSol = tokenIn.id === WRAPPED_SOL + const { status, body } = await venueFetch(`${RAYDIUM_API}/transaction/swap-base-in`, { + label: 'Raydium', + method: 'POST', + body: { + computeUnitPriceMicroLamports: PRIORITY_MICRO_LAMPORTS, + swapResponse: raw, + txVersion: 'V0', + wallet: account, + wrapSol: wrapsSol, + unwrapSol: tokenOut.id === WRAPPED_SOL, + ...(wrapsSol ? {} : { inputAccount: associatedTokenAccount(account, tokenIn.id) }), + }, + ...retry, + }) + const built = Built.safeParse(body) + if (status !== 200 || !built.success || built.data.data.length !== 1) { + throw new ChainActionRefused( + 'Raydium could not build this swap. Nothing was signed or sent. Check that you hold the token and a little SOL for fees, then try again.' + ) + } + const transaction = (built.data.data[0] as { transaction: string }).transaction + const expected = { + amountIn: BigInt(amount), + minimumOut: BigInt(q.otherAmountThreshold), + wrapsSol, + } + assertRaydiumRoute(transaction, expected) + + const received = fromBaseUnits(q.outputAmount, tokenOut.decimals) + return { + venueId: 'raydium', + venueName: 'Raydium', + kind: 'swap', + summary: `Swap ${request.amount} ${tokenIn.symbol} for about ${shortAmount(received)} ${tokenOut.symbol} through Raydium.`, + pay: { amount: request.amount, symbol: tokenIn.symbol }, + receive: { amount: received, symbol: tokenOut.symbol, approximate: true }, + notes: [ + `The price can move before this lands. You get at least ${minimum} ${tokenOut.symbol} or the swap fails, and network fees still apply.`, + ], + signing: { family: 'solana', transaction }, + context: { + amountIn: amount, + minimumOut: q.otherAmountThreshold, + wrapsSol: wrapsSol ? '1' : '0', + }, + } + } + + return { + id: 'raydium', + name: 'Raydium', + kinds: ['swap'], + quote, + prepare, + assertSafe: (payload: SigningPayload, account: string, context?: Record) => { + programsSafe(payload, account) + if (payload.family !== 'solana') return + assertRaydiumRoute(payload.transaction, expectationOf(context)) + }, + } +} diff --git a/apps/dapp/lib/chains/solana/venues.ts b/apps/dapp/lib/chains/solana/venues.ts index 2189b855..46b6613f 100644 --- a/apps/dapp/lib/chains/solana/venues.ts +++ b/apps/dapp/lib/chains/solana/venues.ts @@ -24,6 +24,18 @@ export const solanaVenues: Venue[] = [ capability: 'Swaps USDC, USDT, SOL, JitoSOL, mSOL, sSOL and ONyc through Jupiter’s on-chain router. Refuses thin liquidity, and checks every transaction before you sign.', }, + { + id: 'raydium', + family: 'solana', + name: 'Raydium', + category: 'swap', + chains: ['Solana'], + bestFor: 'Swapping straight against Raydium’s own pools', + url: 'https://raydium.io/swap', + integration: 'executes', + capability: + 'Swaps USDC, USDT, SOL, JitoSOL, mSOL, sSOL and ONyc through Raydium’s router, which races Jupiter for the better price. Checks that every transaction spends exactly the amount quoted and accepts no less than the quote’s floor before you sign.', + }, { // Listed, as Lulo is: whether this runs depends on a secret this client-safe file can't read. id: '1click-solana', diff --git a/apps/dapp/lib/chains/solana/wallet-guards.ts b/apps/dapp/lib/chains/solana/wallet-guards.ts new file mode 100644 index 00000000..6a00a1e0 --- /dev/null +++ b/apps/dapp/lib/chains/solana/wallet-guards.ts @@ -0,0 +1,51 @@ +import { + getCompiledTransactionMessageDecoder, + getCompiledTransactionMessageEncoder, + getTransactionDecoder, + getTransactionEncoder, + type Address, +} from '@solana/kit' + +/** + * Lighthouse, the assertion program Phantom adds "guard instructions" from when it signs: each one + * checks an account against what the wallet previewed, and fails the whole transaction if it + * differs. https://phantom.com/learn/blog/anti-spoofing-security. Executable on mainnet on + * 6 November 2026, an upgradeable program, so trusting it means trusting its upgrade authority. + */ +export const LIGHTHOUSE_PROGRAM = 'L2TExMFKdjpN9kozasaurPirfHy9P8sbXoAN1qA3S95' + +/** + * The signed transaction as a venue's check should see it: the same bytes, without the guard + * instructions a wallet added after this app built it. Only for checking. What is sent is what the + * wallet signed, guards and all. + * + * Anything else a wallet or page adds still reaches the check and is refused there. A transaction + * that cannot be read, or carries bytes past its message, is returned as it came, so the check + * refuses it for that. + */ +export function withoutWalletGuards(base64: string): string { + try { + const transaction = getTransactionDecoder().decode(Buffer.from(base64, 'base64')) + const message = getCompiledTransactionMessageDecoder().decode(transaction.messageBytes) + const encoded = getCompiledTransactionMessageEncoder().encode(message) + const exact = + encoded.length === transaction.messageBytes.length && + encoded.every((byte, i) => byte === transaction.messageBytes[i]) + // Only the forms the venue checks read: a v1 message is refused there, untouched here. + if (!exact || (message.version !== 'legacy' && message.version !== 0)) return base64 + + const guard = message.staticAccounts.indexOf(LIGHTHOUSE_PROGRAM as Address) + if (guard === -1) return base64 + const instructions = message.instructions.filter((ix) => ix.programAddressIndex !== guard) + if (instructions.length === message.instructions.length) return base64 + + const messageBytes = getCompiledTransactionMessageEncoder().encode({ ...message, instructions }) + const unguarded = getTransactionEncoder().encode({ + messageBytes, + signatures: transaction.signatures, + } as Parameters['encode']>[0]) + return Buffer.from(unguarded).toString('base64') + } catch { + return base64 + } +} diff --git a/apps/dapp/lib/weave/parse-cashout.ts b/apps/dapp/lib/weave/parse-cashout.ts index ee0b3617..0228f8e1 100644 --- a/apps/dapp/lib/weave/parse-cashout.ts +++ b/apps/dapp/lib/weave/parse-cashout.ts @@ -1,4 +1,4 @@ -import { chainTokenWord } from '../chains/parse-action' +import { chainTokenWord, dollarsWorthOf } from '../chains/parse-action' import { SEQUENCE_CASHOUT_CHAINS } from '../chains/sequence' import { closedCashoutReply, isCashoutChain } from './types' @@ -176,7 +176,7 @@ function readAmount(text: string): string | undefined { * also mean other things, so they count only with a fiat destination. */ export function parseCashout(text: string): CashoutRead | null { - const clean = text + const clean = dollarsWorthOf(text) .trim() .toLowerCase() .replace(/\s+/g, ' ') diff --git a/apps/dapp/public/images/venues/raydium.webp b/apps/dapp/public/images/venues/raydium.webp new file mode 100644 index 00000000..d7feea60 Binary files /dev/null and b/apps/dapp/public/images/venues/raydium.webp differ