From a25e14cecb8b5b6c23990c1fd2b4516323289d6c Mon Sep 17 00:00:00 2001 From: 0xDeon <110722148+0xDeon@users.noreply.github.com> Date: Fri, 6 Nov 2026 15:12:31 +0100 Subject: [PATCH] fix(dapp): let phantom's lighthouse guards through the cash-out deposit check #157 fixed the chain submit route only. The cash-out deposit has its own submit, which checked the signed bytes as they came from the wallet, so a Phantom user's swap-then-cash-out sent the swap and then refused the deposit: "calls a program a USDC deposit does not need". The deposit check now reads the same guard-free copy, and sends what the wallet signed. A test now fails on any check that reads raw signed Solana bytes, so a third route can't miss it. --- .../solana-signed-checks-guarded.test.ts | 44 +++++++++++++++++++ .../__tests__/weave-deposit-solana.test.ts | 34 ++++++++++++++ apps/dapp/lib/weave/deposit/solana.ts | 7 ++- 3 files changed, 84 insertions(+), 1 deletion(-) create mode 100644 apps/dapp/lib/__tests__/solana-signed-checks-guarded.test.ts diff --git a/apps/dapp/lib/__tests__/solana-signed-checks-guarded.test.ts b/apps/dapp/lib/__tests__/solana-signed-checks-guarded.test.ts new file mode 100644 index 00000000..5f6b35b6 --- /dev/null +++ b/apps/dapp/lib/__tests__/solana-signed-checks-guarded.test.ts @@ -0,0 +1,44 @@ +import { readdirSync, readFileSync, statSync } from 'node:fs' +import { join, relative } from 'node:path' + +import { describe, expect, it } from 'vitest' + +const ROOT = join(__dirname, '..', '..') + +function sources(dir: string): string[] { + return readdirSync(dir).flatMap((name) => { + const path = join(dir, name) + if (statSync(path).isDirectory()) { + return name === 'node_modules' || name === '__tests__' || name.startsWith('.') + ? [] + : sources(path) + } + return /\.tsx?$/.test(name) ? [path] : [] + }) +} + +/** Signed Solana bytes handed to a check as they came from the wallet, guards and all. */ +const RAW_SIGNED = /transaction:\s*[\w.]*\bsignedTransaction\b/ + +describe('every check of a signed Solana transaction', () => { + it('first leaves out the wallet’s own Lighthouse guards', () => { + // Phantom adds them before signing. A check that reads the raw bytes refuses every Phantom user, + // as the cash-out deposit did on 6 November after the chain submit route was fixed alone. + const raw = [join(ROOT, 'app'), join(ROOT, 'lib')] + .flatMap(sources) + .filter((file) => RAW_SIGNED.test(readFileSync(file, 'utf8'))) + .map((file) => relative(ROOT, file)) + expect(raw).toEqual([]) + }) + + it('would have caught the two places that read them raw', () => { + expect(RAW_SIGNED.test("{ family: 'solana', transaction: signed.signedTransaction }")).toBe( + true + ) + expect( + RAW_SIGNED.test( + "{ family: 'solana', transaction: withoutWalletGuards(signed.signedTransaction) }" + ) + ).toBe(false) + }) +}) diff --git a/apps/dapp/lib/__tests__/weave-deposit-solana.test.ts b/apps/dapp/lib/__tests__/weave-deposit-solana.test.ts index cbcd80f3..d8a18dc9 100644 --- a/apps/dapp/lib/__tests__/weave-deposit-solana.test.ts +++ b/apps/dapp/lib/__tests__/weave-deposit-solana.test.ts @@ -627,6 +627,40 @@ describe('submit', () => { expect(fetchMock).not.toHaveBeenCalled() }) + /** What Phantom adds before signing: a Lighthouse assertion on the signer's own account. */ + const lighthouse: Instruction = { + programAddress: 'L2TExMFKdjpN9kozasaurPirfHy9P8sbXoAN1qA3S95', + accounts: [{ address: ACCOUNT, role: AccountRole.READONLY }], + data: Uint8Array.from([5, 0, 1]), + } + + it('sends a deposit Phantom guarded, exactly as the wallet signed it', async () => { + const fetchMock = stubRpc() + const guarded = signed(compile([createAta(), transfer(), lighthouse])) + const result = await solanaDeposit.submit( + { family: 'solana', signedTransaction: guarded }, + EXPECTATION + ) + expect(result.ok).toBe(true) + const sent = fetchMock.mock.calls + .map( + ([, init]) => + JSON.parse(String((init as RequestInit).body)) as { method: string; params: unknown[] } + ) + .find((call) => call.method === 'sendTransaction') + expect(sent?.params[0]).toBe(guarded) + }) + + it('still refuses any other program a wallet adds beside the guard', async () => { + const fetchMock = stubRpc() + const other: Instruction = { programAddress: OTHER, accounts: [], data: Uint8Array.from([1]) } + const tampered = signed(compile([createAta(), transfer(), lighthouse, other])) + await expect( + solanaDeposit.submit({ family: 'solana', signedTransaction: tampered }, EXPECTATION) + ).rejects.toThrow(/does not need/) + expect(fetchMock).not.toHaveBeenCalled() + }) + it('refuses another family without sending', async () => { const fetchMock = stubRpc() const result = await solanaDeposit.submit({ family: 'near', hashes: ['x'] }, EXPECTATION) diff --git a/apps/dapp/lib/weave/deposit/solana.ts b/apps/dapp/lib/weave/deposit/solana.ts index 04d7cde2..fcca7fd6 100644 --- a/apps/dapp/lib/weave/deposit/solana.ts +++ b/apps/dapp/lib/weave/deposit/solana.ts @@ -32,6 +32,7 @@ import { import { venueFetch } from '../../chains/solana/http' import { createSolanaSubmit, explorerUrl, feePayerSignature } from '../../chains/solana/submit' import { SOLANA_TOKENS } from '../../chains/solana/tokens' +import { withoutWalletGuards } from '../../chains/solana/wallet-guards' import { ChainActionRefused, tokenBySymbol, @@ -416,7 +417,11 @@ async function submit( if (signed.family !== 'solana') { return { ok: false, error: 'That is not a Solana transaction, so nothing was sent.' } } - assertSafe({ family: 'solana', transaction: signed.signedTransaction }, expectation) + // Phantom's Lighthouse guards are its own and only make the transfer fail; the rest is checked. + assertSafe( + { family: 'solana', transaction: withoutWalletGuards(signed.signedTransaction) }, + expectation + ) const hash = feePayerSignature(signed.signedTransaction) if (hash === undefined) { return {