From b82773ae83cdd7ffacbd3a9ce0fb65b09e64993d Mon Sep 17 00:00:00 2001 From: 0xDeon <110722148+0xDeon@users.noreply.github.com> Date: Fri, 6 Nov 2026 14:16:25 +0100 Subject: [PATCH 1/5] fix(dapp): let phantom's lighthouse guards through the solana submit check Phantom adds Lighthouse guard instructions to a transaction before signing it. The submit route checked the signed bytes against the venue's program list, so a Jupiter swap that passed at prepare was refused at submit with "calls a program Jupiter should not need" (seen on prod, POST /api/chain/solana/submit, 6 November). The check now runs on a copy without the Lighthouse instructions. What is sent is what the wallet signed, guards included. Any other added program, and bytes after the message, are still refused. --- .../app/api/chain/[chain]/submit/route.ts | 4 +- .../chain-submit-route-guards.test.ts | 59 +++++++++++++++++++ .../__tests__/solana-wallet-guards.fixture.ts | 42 +++++++++++++ .../__tests__/solana-wallet-guards.test.ts | 51 ++++++++++++++++ apps/dapp/lib/chains/solana/wallet-guards.ts | 50 ++++++++++++++++ 5 files changed, 205 insertions(+), 1 deletion(-) create mode 100644 apps/dapp/lib/__tests__/chain-submit-route-guards.test.ts create mode 100644 apps/dapp/lib/__tests__/solana-wallet-guards.fixture.ts create mode 100644 apps/dapp/lib/__tests__/solana-wallet-guards.test.ts create mode 100644 apps/dapp/lib/chains/solana/wallet-guards.ts 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/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..d8bdae2b --- /dev/null +++ b/apps/dapp/lib/__tests__/chain-submit-route-guards.test.ts @@ -0,0 +1,59 @@ +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' + +const state = vi.hoisted(() => ({ sent: [] as string[] })) + +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') + vi.spyOn(jupiter, 'submit').mockImplementation(async (input) => { + if (input.signed.family === 'solana') state.sent.push(input.signed.signedTransaction) + return { ok: true, txHash: '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 = [] + vi.restoreAllMocks() + 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__/solana-wallet-guards.fixture.ts b/apps/dapp/lib/__tests__/solana-wallet-guards.fixture.ts new file mode 100644 index 00000000..bedaa31a --- /dev/null +++ b/apps/dapp/lib/__tests__/solana-wallet-guards.fixture.ts @@ -0,0 +1,42 @@ +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 + 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/solana/wallet-guards.ts b/apps/dapp/lib/chains/solana/wallet-guards.ts new file mode 100644 index 00000000..c7b83ab4 --- /dev/null +++ b/apps/dapp/lib/chains/solana/wallet-guards.ts @@ -0,0 +1,50 @@ +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]) + if (!exact) 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 + } +} From 124d832d39fd8135e2da2058ba8213d55e94352d Mon Sep 17 00:00:00 2001 From: 0xDeon <110722148+0xDeon@users.noreply.github.com> Date: Fri, 6 Nov 2026 14:25:15 +0100 Subject: [PATCH 2/5] fix(dapp): read "$0.8 worth of sol" as dollars on every chain, before any model Only "$0.8 sol" was read as dollars off Stellar. "$0.8 worth of sol", "$0.8 of sol" and "0.8 dollars worth of sol" fell through to the model, so "offramp $0.8 worth of sol to naira" could come back as 0.8 SOL. Both the chain parser and the cash-out parser now rewrite those to "$0.8 sol" first. A bare "of" counts only before a token, so "$110 of profit" is left alone, and a dollar of USDC is still one USDC. --- .../__tests__/chain-dollars-worth-of.test.ts | 82 +++++++++++++++++++ apps/dapp/lib/chains/parse-action.ts | 50 +++++++++++ apps/dapp/lib/chains/parse-sequence.ts | 11 ++- apps/dapp/lib/weave/parse-cashout.ts | 4 +- 4 files changed, 144 insertions(+), 3 deletions(-) create mode 100644 apps/dapp/lib/__tests__/chain-dollars-worth-of.test.ts 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/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/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, ' ') From e2fbac2ed02e5240aa6f3e1c00941bc453fb27bc Mon Sep 17 00:00:00 2001 From: 0xDeon <110722148+0xDeon@users.noreply.github.com> Date: Fri, 6 Nov 2026 14:31:13 +0100 Subject: [PATCH 3/5] test(dapp): a dollar cash-out races sol bought with those dollars, in the browser --- apps/dapp/e2e/dollar-cashout.spec.ts | 103 +++++++++++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 apps/dapp/e2e/dollar-cashout.spec.ts 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"/) + }) +} From e437fb681144ef72d448675c5014cf2086bf34e3 Mon Sep 17 00:00:00 2001 From: 0xDeon <110722148+0xDeon@users.noreply.github.com> Date: Fri, 6 Nov 2026 14:45:38 +0100 Subject: [PATCH 4/5] fix(dapp): type the wallet guard helpers for kit's v1 messages --- .../__tests__/chain-submit-route-guards.test.ts | 14 ++++++++++---- .../lib/__tests__/solana-wallet-guards.fixture.ts | 1 + apps/dapp/lib/chains/solana/wallet-guards.ts | 3 ++- 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/apps/dapp/lib/__tests__/chain-submit-route-guards.test.ts b/apps/dapp/lib/__tests__/chain-submit-route-guards.test.ts index d8bdae2b..323b8cea 100644 --- a/apps/dapp/lib/__tests__/chain-submit-route-guards.test.ts +++ b/apps/dapp/lib/__tests__/chain-submit-route-guards.test.ts @@ -5,8 +5,10 @@ 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 })) @@ -17,10 +19,14 @@ async function submit(signedTransaction: string): Promise { 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') - vi.spyOn(jupiter, 'submit').mockImplementation(async (input) => { - if (input.signed.family === 'solana') state.sent.push(input.signed.signedTransaction) - return { ok: true, txHash: 'sig' } + 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', @@ -37,7 +43,7 @@ async function submit(signedTransaction: string): Promise { afterEach(() => { state.sent = [] - vi.restoreAllMocks() + restore.splice(0).forEach((undo) => undo()) vi.resetModules() }) diff --git a/apps/dapp/lib/__tests__/solana-wallet-guards.fixture.ts b/apps/dapp/lib/__tests__/solana-wallet-guards.fixture.ts index bedaa31a..653c2fe9 100644 --- a/apps/dapp/lib/__tests__/solana-wallet-guards.fixture.ts +++ b/apps/dapp/lib/__tests__/solana-wallet-guards.fixture.ts @@ -14,6 +14,7 @@ type Message = ReturnType (i >= old ? i + 1 : i) const next = { diff --git a/apps/dapp/lib/chains/solana/wallet-guards.ts b/apps/dapp/lib/chains/solana/wallet-guards.ts index c7b83ab4..6a00a1e0 100644 --- a/apps/dapp/lib/chains/solana/wallet-guards.ts +++ b/apps/dapp/lib/chains/solana/wallet-guards.ts @@ -31,7 +31,8 @@ export function withoutWalletGuards(base64: string): string { const exact = encoded.length === transaction.messageBytes.length && encoded.every((byte, i) => byte === transaction.messageBytes[i]) - if (!exact) return base64 + // 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 From c913bc5e3fae245b000102565e339aae1ff86d75 Mon Sep 17 00:00:00 2001 From: 0xDeon <110722148+0xDeon@users.noreply.github.com> Date: Fri, 6 Nov 2026 14:45:39 +0100 Subject: [PATCH 5/5] feat(dapp): race raydium beside jupiter for solana swaps Raydium's Trade API quotes a swap and builds the transaction, so no SDK is added. The swap lands through the Solana module's own submit, since Raydium has no endpoint that sends. Its router's source is not public, so the check reads the three route instructions the API built for SOL to USDC, USDC to SOL and USDC to USDT on 6 November: one swap of exactly the amount asked for no less than the quote's floor, a SOL wrap of that same amount only when SOL is spent, and one closing step. Anything else is refused. A quote is held to the same slippage, price impact and value checks as Jupiter's. Ships with Raydium's logo. --- apps/dapp/lib/__tests__/raydium-swap.test.ts | 166 ++++++++++ apps/dapp/lib/__tests__/solana-venues.test.ts | 13 +- .../solana/__fixtures__/raydium-captured.ts | 12 + apps/dapp/lib/chains/solana/module.ts | 2 + apps/dapp/lib/chains/solana/programs.ts | 13 + apps/dapp/lib/chains/solana/raydium-swap.ts | 299 ++++++++++++++++++ apps/dapp/lib/chains/solana/venues.ts | 12 + apps/dapp/public/images/venues/raydium.webp | Bin 0 -> 6200 bytes 8 files changed, 514 insertions(+), 3 deletions(-) create mode 100644 apps/dapp/lib/__tests__/raydium-swap.test.ts create mode 100644 apps/dapp/lib/chains/solana/__fixtures__/raydium-captured.ts create mode 100644 apps/dapp/lib/chains/solana/raydium-swap.ts create mode 100644 apps/dapp/public/images/venues/raydium.webp 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/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/public/images/venues/raydium.webp b/apps/dapp/public/images/venues/raydium.webp new file mode 100644 index 0000000000000000000000000000000000000000..d7feea6098bf853f47e3f7ca717483d169c8b46d GIT binary patch literal 6200 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 WEpR{-(4;+n=l)tVso($r0001Fs4nXO literal 0 HcmV?d00001