// @vitest-environment node // Reason: @solana/web3.js + @solana/buffer-layout fail `instanceof Uint8Array` checks under // jsdom because Buffer in the polyfilled env is not the same prototype the layout library // checks against. Running this file in node fixes the encoding pipeline without affecting // the rest of the suite (which stays on jsdom for DOM-dependent tests). import { describe, it, expect, vi, beforeEach } from 'vitest' import { SolanaTransactionService } from './solana-transaction-service' import { Keypair, PublicKey, Transaction, SystemProgram, VersionedTransaction, TransactionMessage } from '@solana/web3.js' import { Buffer } from 'buffer' import base58 from 'bs58' import type SignClient from '@walletconnect/sign-client' import type { SessionTypes } from '@walletconnect/types' // submitTransaction broadcasts the fallback-signed tx via a real Connection. // Stub Connection.sendRawTransaction so the pre-check tests never hit the network; the // rest of @solana/web3.js stays real (the sign/serialize round-trips the other suites // rely on are untouched). const { sendRawTransaction } = vi.hoisted(() => ({ sendRawTransaction: vi.fn() })) vi.mock('@solana/web3.js', async importOriginal => { const actual = await importOriginal() return { ...actual, Connection: vi.fn(function () { return { sendRawTransaction } }) } }) const SOL_CHAIN_ID = 'solana:5eykt4UsFv8P8NJdTREpY1vzqKqZKvdp' const MOCK_TOPIC = 'mock-session-topic' // We sign with a real keypair so the test exercises the full bytes-in / bytes-out contract: // produce a signature locally, hand it to the service via the mocked WC response, and verify // the returned bytes deserialize cleanly. const userKeypair = Keypair.generate() const recipient = Keypair.generate().publicKey function buildLegacyTx(blockhash: string): Transaction { const tx = new Transaction() tx.recentBlockhash = blockhash tx.feePayer = userKeypair.publicKey tx.add( SystemProgram.transfer({ fromPubkey: userKeypair.publicKey, toPubkey: recipient, lamports: 1 }) ) return tx } function buildVersionedTx(blockhash: string): VersionedTransaction { const message = new TransactionMessage({ payerKey: userKeypair.publicKey, recentBlockhash: blockhash, instructions: [ SystemProgram.transfer({ fromPubkey: userKeypair.publicKey, toPubkey: recipient, lamports: 1 }) ] }).compileToV0Message() return new VersionedTransaction(message) } function makeMockSignClient( response: { transaction: string } | { signature: string } ): { request: ReturnType; client: SignClient } { const request = vi.fn().mockResolvedValue(response) return { request, client: { request } as unknown as SignClient } } const mockSession = { topic: MOCK_TOPIC } as SessionTypes.Struct describe('SolanaTransactionService.signTransactionBytes', () => { let service: SolanaTransactionService beforeEach(() => { service = new SolanaTransactionService() }) it('decodes the {transaction} response shape directly without re-signing locally (V0)', async () => { // Modern wallets (Phantom, Solflare) return the fully-signed tx as base64 — we should not // touch web3.js at all in this branch. We simulate the wallet's signature by signing the // V0 tx with the user keypair and base64-encoding the result. const blockhash = '11111111111111111111111111111111' const unsigned = buildVersionedTx(blockhash) const signedLocally = buildVersionedTx(blockhash) signedLocally.sign([userKeypair]) const fullySignedBase64 = Buffer.from(signedLocally.serialize()).toString( 'base64' ) const { request, client } = makeMockSignClient({ transaction: fullySignedBase64 }) const result = await service.signTransactionBytes( client, mockSession, unsigned.serialize(), SOL_CHAIN_ID, userKeypair.publicKey.toBase58() ) expect(request).toHaveBeenCalledWith({ topic: MOCK_TOPIC, chainId: SOL_CHAIN_ID, request: { method: 'solana_signTransaction', params: { transaction: expect.any(String) } } }) // Bytes round-trip: result should deserialize as a signed VersionedTransaction. const out = VersionedTransaction.deserialize(result) expect(out.signatures.length).toBeGreaterThan(0) // Signature must be from the user's keypair, not zero-bytes. const isAllZero = out.signatures[0]?.every((b: number) => b === 0) expect(isAllZero).toBe(false) }) it('handles the {signature} response shape by splicing into a V0 tx', async () => { // Older WC wallets return only the bare signature; the service must add it to the input. const blockhash = '11111111111111111111111111111111' const unsigned = buildVersionedTx(blockhash) const signedLocally = buildVersionedTx(blockhash) signedLocally.sign([userKeypair]) const wireSignature = base58.encode(signedLocally.signatures[0]!) const { request, client } = makeMockSignClient({ signature: wireSignature }) const result = await service.signTransactionBytes( client, mockSession, unsigned.serialize(), SOL_CHAIN_ID, userKeypair.publicKey.toBase58() ) expect(request).toHaveBeenCalled() const out = VersionedTransaction.deserialize(result) // Must contain the signature we returned, attached to the user account. expect(out.signatures[0]).toBeDefined() expect(Array.from(out.signatures[0]!)).toEqual( Array.from(signedLocally.signatures[0]!) ) }) it('handles the {signature} response shape by splicing into a legacy tx', async () => { const blockhash = '11111111111111111111111111111111' const unsigned = buildLegacyTx(blockhash) // Sign locally to produce the wire signature the wallet would have returned. const signedLocally = buildLegacyTx(blockhash) signedLocally.sign(userKeypair) const userSig = signedLocally.signatures.find(s => s.publicKey.equals(userKeypair.publicKey) )!.signature! const wireSignature = base58.encode(userSig) const { client } = makeMockSignClient({ signature: wireSignature }) const result = await service.signTransactionBytes( client, mockSession, unsigned.serialize({ requireAllSignatures: false }), SOL_CHAIN_ID, userKeypair.publicKey.toBase58() ) const out = Transaction.from(result) const sigEntry = out.signatures.find(s => s.publicKey.equals(userKeypair.publicKey) ) expect(sigEntry?.signature).not.toBeNull() expect(Array.from(sigEntry!.signature!)).toEqual(Array.from(userSig)) }) it('never calls solana_signAndSendTransaction (sign-only contract)', async () => { // Critical for fee-payer relay: if this path ever broadcasts, the relay server's signature // never lands on-chain because the wallet's own RPC fronts the tx with insufficient fee // payer balance. Lock this in. const blockhash = '11111111111111111111111111111111' const unsigned = buildVersionedTx(blockhash) const signedLocally = buildVersionedTx(blockhash) signedLocally.sign([userKeypair]) const { request, client } = makeMockSignClient({ transaction: Buffer.from(signedLocally.serialize()).toString('base64') }) await service.signTransactionBytes( client, mockSession, unsigned.serialize(), SOL_CHAIN_ID, userKeypair.publicKey.toBase58() ) const calls = request.mock.calls.map( c => (c[0] as { request: { method: string } }).request.method ) expect(calls).toEqual(['solana_signTransaction']) expect(calls).not.toContain('solana_signAndSendTransaction') }) it('throws when the wallet returns an unexpected response shape', async () => { const blockhash = '11111111111111111111111111111111' const unsigned = buildVersionedTx(blockhash) const { client } = makeMockSignClient( // @ts-expect-error — intentionally malformed { foo: 'bar' } ) await expect( service.signTransactionBytes( client, mockSession, unsigned.serialize(), SOL_CHAIN_ID, userKeypair.publicKey.toBase58() ) ).rejects.toThrow() }) it('propagates wallet RPC errors via parseError', async () => { const blockhash = '11111111111111111111111111111111' const unsigned = buildVersionedTx(blockhash) const request = vi.fn().mockRejectedValue(new Error('User rejected')) const client = { request } as unknown as SignClient await expect( service.signTransactionBytes( client, mockSession, unsigned.serialize(), SOL_CHAIN_ID, userKeypair.publicKey.toBase58() ) ).rejects.toThrow(/User rejected/) }) it('correctly routes V0 vs legacy through the {signature} branch', async () => { // The version flag lives on the message byte, not the first tx byte (the first byte is // the shortvec signature count). Both shapes must round-trip cleanly when the wallet // returns only a bare signature — exercised by the two tests above. // This test is a regression guard: build one of each, call the service, and assert // each deserializes back to the matching type. const blockhash = '11111111111111111111111111111111' const v0Unsigned = buildVersionedTx(blockhash) const v0Signed = buildVersionedTx(blockhash) v0Signed.sign([userKeypair]) const v0Sig = base58.encode(v0Signed.signatures[0]!) const v0Result = await new SolanaTransactionService().signTransactionBytes( makeMockSignClient({ signature: v0Sig }).client, mockSession, v0Unsigned.serialize(), SOL_CHAIN_ID, userKeypair.publicKey.toBase58() ) // Must deserialize as V0 — Transaction.from on V0 bytes throws. expect(() => VersionedTransaction.deserialize(v0Result)).not.toThrow() const legacyUnsigned = buildLegacyTx(blockhash) const legacySigned = buildLegacyTx(blockhash) legacySigned.sign(userKeypair) const userSig = legacySigned.signatures.find(s => s.publicKey.equals(userKeypair.publicKey) )!.signature! const legacySigB58 = base58.encode(userSig) const legacyResult = await new SolanaTransactionService().signTransactionBytes( makeMockSignClient({ signature: legacySigB58 }).client, mockSession, legacyUnsigned.serialize({ requireAllSignatures: false }), SOL_CHAIN_ID, userKeypair.publicKey.toBase58() ) // Must deserialize as legacy. expect(() => Transaction.from(legacyResult)).not.toThrow() }) it('rejects a {transaction} whose message was altered by the wallet (tamper guard)', async () => { // A wallet must only ADD the user's signature. If it returns a transaction // that pays a different recipient, we refuse instead of handing tampered // bytes to the relay for broadcast. (The happy path — same message, signature // added — is covered by the {transaction} round-trip test above.) const blockhash = '11111111111111111111111111111111' const requested = buildVersionedTx(blockhash) // pays `recipient` const attackerRecipient = Keypair.generate().publicKey const tamperedMsg = new TransactionMessage({ payerKey: userKeypair.publicKey, recentBlockhash: blockhash, instructions: [ SystemProgram.transfer({ fromPubkey: userKeypair.publicKey, toPubkey: attackerRecipient, lamports: 1 }) ] }).compileToV0Message() const tampered = new VersionedTransaction(tamperedMsg) tampered.sign([userKeypair]) const tamperedBase64 = Buffer.from(tampered.serialize()).toString('base64') const { client } = makeMockSignClient({ transaction: tamperedBase64 }) await expect( service.signTransactionBytes( client, mockSession, requested.serialize(), SOL_CHAIN_ID, userKeypair.publicKey.toBase58() ) ).rejects.toThrow('Signed transaction does not match the request') }) }) describe('SolanaTransactionService — keys are not concatenated into params', () => { it('encodes serializedTx as a single base64 string in params.transaction', async () => { // Regression guard: the WC Solana spec puts the tx under params.transaction (not as a // positional array). Wallets reject the request silently if we pass an array. const blockhash = '11111111111111111111111111111111' const unsigned = buildVersionedTx(blockhash) const signedLocally = buildVersionedTx(blockhash) signedLocally.sign([userKeypair]) const request = vi.fn().mockResolvedValue({ transaction: Buffer.from(signedLocally.serialize()).toString('base64') }) const client = { request } as unknown as SignClient const service = new SolanaTransactionService() await service.signTransactionBytes( client, mockSession, unsigned.serialize(), SOL_CHAIN_ID, userKeypair.publicKey.toBase58() ) const params = request.mock.calls[0]![0]!.request.params expect(typeof params.transaction).toBe('string') expect(Array.isArray(params)).toBe(false) // Must be valid base64. expect(() => Buffer.from(params.transaction, 'base64')).not.toThrow() }) }) describe('SolanaTransactionService — public method preserves PublicKey usage', () => { it('attaches signature using the activeAddress, not feePayer from the message', async () => { // The relay flow has two signers: relay (slot 0 = fee payer) and user (slot 1). // We pass activeAddress = user, so the signature must land on the user slot. const blockhash = '11111111111111111111111111111111' const relayKeypair = Keypair.generate() const message = new TransactionMessage({ payerKey: relayKeypair.publicKey, recentBlockhash: blockhash, instructions: [ SystemProgram.transfer({ fromPubkey: userKeypair.publicKey, toPubkey: recipient, lamports: 1 }) ] }).compileToV0Message() const unsigned = new VersionedTransaction(message) // Local: sign with user keypair only (not relay) to simulate what the wallet would do. const signedLocally = new VersionedTransaction(message) signedLocally.sign([userKeypair]) const userSlotIndex = unsigned.message.staticAccountKeys.findIndex(k => k.equals(userKeypair.publicKey) ) const userSignature = signedLocally.signatures[userSlotIndex]! const wireSignature = base58.encode(userSignature) const { client } = makeMockSignClient({ signature: wireSignature }) const service = new SolanaTransactionService() const result = await service.signTransactionBytes( client, mockSession, unsigned.serialize(), SOL_CHAIN_ID, userKeypair.publicKey.toBase58() ) const out = VersionedTransaction.deserialize(result) // Relay slot (0) still empty zeros; user slot (>=1) populated. expect(Array.from(out.signatures[userSlotIndex]!)).toEqual( Array.from(userSignature) ) const relaySlot = out.message.staticAccountKeys.findIndex(k => k.equals(relayKeypair.publicKey) ) expect(out.signatures[relaySlot]?.every(b => b === 0)).toBe(true) // PublicKey.toBase58 should equal activeAddress to confirm we used it correctly. expect(userKeypair.publicKey.toBase58()).toBe( new PublicKey(userKeypair.publicKey.toBase58()).toBase58() ) }) }) describe('SolanaTransactionService.submitTransaction — signAndSend pre-check', () => { const blockhash = '11111111111111111111111111111111' const activeAddress = userKeypair.publicKey.toBase58() const nativeRequest = { from: activeAddress, to: recipient.toBase58(), amount: 1n, blockhash } // The fully-signed legacy tx the mocked wallet returns for solana_signTransaction, so // submitTransaction's broadcast branch can deserialize + (mock-)broadcast it. function signedLegacyBase64(): string { const tx = buildLegacyTx(blockhash) tx.sign(userKeypair) return tx.serialize().toString('base64') } // Reject signAndSend exactly as the WC SignClient does when the method isn't in the // approved namespace; resolve signTransaction with a signed tx. function makeDualMethodClient(): { request: ReturnType client: SignClient } { const signed = signedLegacyBase64() const request = vi .fn() .mockImplementation((args: { request: { method: string } }) => { const method = args.request.method if (method === 'solana_signAndSendTransaction') { return Promise.reject( new Error( 'Missing or invalid. request() method: solana_signAndSendTransaction' ) ) } if (method === 'solana_signTransaction') { return Promise.resolve({ transaction: signed }) } return Promise.reject(new Error(`unexpected method ${method}`)) }) return { request, client: { request } as unknown as SignClient } } function sessionWithMethods(methods: string[]): SessionTypes.Struct { return { topic: MOCK_TOPIC, namespaces: { solana: { accounts: [`${SOL_CHAIN_ID}:${activeAddress}`], methods, events: [] } } } as unknown as SessionTypes.Struct } beforeEach(() => { sendRawTransaction.mockReset() sendRawTransaction.mockResolvedValue(base58.encode(new Uint8Array(64))) }) it('does NOT attempt solana_signAndSendTransaction when the session never approved it', async () => { // Trust Wallet / Exodus approve a Solana account but only solana_signTransaction. // Attempting signAndSend anyway is what logs the noisy WC error in prod. const { request, client } = makeDualMethodClient() await new SolanaTransactionService().sendTransaction( client, sessionWithMethods(['solana_signTransaction']), nativeRequest, SOL_CHAIN_ID, activeAddress ) const methods = request.mock.calls.map( c => (c[0] as { request: { method: string } }).request.method ) expect(methods).not.toContain('solana_signAndSendTransaction') expect(methods).toEqual(['solana_signTransaction']) expect(sendRawTransaction).toHaveBeenCalledTimes(1) }) it('DOES attempt solana_signAndSendTransaction when the session approved it', async () => { // Phantom / Solflare approve signAndSend — preserve the wallet-broadcast path. // The WC Solana spec response shape: { signature } (base58, 64 bytes). const walletSignature = base58.encode(new Uint8Array(64).fill(1)) const request = vi.fn().mockResolvedValue({ signature: walletSignature }) const client = { request } as unknown as SignClient const result = await new SolanaTransactionService().sendTransaction( client, sessionWithMethods([ 'solana_signTransaction', 'solana_signAndSendTransaction' ]), nativeRequest, SOL_CHAIN_ID, activeAddress ) const methods = request.mock.calls.map( c => (c[0] as { request: { method: string } }).request.method ) expect(methods).toEqual(['solana_signAndSendTransaction']) expect(result).toBe(walletSignature) expect(sendRawTransaction).not.toHaveBeenCalled() }) describe('signAndSend response shapes', () => { // A real ed25519 signature is 64 bytes, base58-encoded (87-88 chars). const realSignature = base58.encode(new Uint8Array(64).fill(7)) const shortSignature = base58.encode(new Uint8Array(32).fill(7)) const splRequest = { ...nativeRequest, tokenMint: Keypair.generate().publicKey.toBase58(), tokenDecimals: 6 } const bothMethods = [ 'solana_signTransaction', 'solana_signAndSendTransaction' ] async function sendWithResponse( walletResponse: unknown, request: typeof nativeRequest | typeof splRequest = nativeRequest ) { const walletRequest = vi.fn().mockResolvedValue(walletResponse) const result = await new SolanaTransactionService().sendTransaction( { request: walletRequest } as unknown as SignClient, sessionWithMethods(bothMethods), request, SOL_CHAIN_ID, activeAddress ) return { result, walletRequest } } // Ledger Live / TokenPocket answer with { signature } per the WC spec; returning that // object as the tx hash crashed Link's deposit success page (ONC-1964). it.each([ ['native SOL', nativeRequest], ['SPL token (USDC)', splRequest] ])( 'unwraps the WC-spec { signature } object for a %s transfer', async (_label, request) => { const { result, walletRequest } = await sendWithResponse( { signature: realSignature }, request ) expect(result).toBe(realSignature) expect(typeof result).toBe('string') // One wallet prompt only: a successful signAndSend must not fall back to a second // signTransaction prompt, and UWC must not broadcast a tx the wallet already sent. expect(walletRequest).toHaveBeenCalledTimes(1) expect(sendRawTransaction).not.toHaveBeenCalled() } ) it('passes a bare signature string through', async () => { const { result } = await sendWithResponse(realSignature) expect(result).toBe(realSignature) }) it.each([ ['an object without a signature', { hash: realSignature }], ['a non-string signature', { signature: 42 }], ['an empty signature', { signature: '' }], ['a signature that is not base58', { signature: '0OIl+/' }], ['a signature of the wrong length', { signature: shortSignature }], ['an empty string', ''], ['an array', [realSignature]], ['null', null], ['undefined', undefined] ])( 'resolves an empty string, without throwing or re-prompting, for %s', async (_label, walletResponse) => { // The wallet already broadcast: throwing here would show a failure for a transfer // that went through, and falling back would ask the user to sign it a second time. const { result, walletRequest } = await sendWithResponse(walletResponse) expect(result).toBe('') expect(walletRequest).toHaveBeenCalledTimes(1) expect(sendRawTransaction).not.toHaveBeenCalled() } ) }) it('falls back to solana_signTransaction when an approved signAndSend is rejected at runtime', async () => { // Safety net: a wallet that advertises signAndSend but rejects it must still recover. const { request, client } = makeDualMethodClient() await new SolanaTransactionService().sendTransaction( client, sessionWithMethods([ 'solana_signTransaction', 'solana_signAndSendTransaction' ]), nativeRequest, SOL_CHAIN_ID, activeAddress ) const methods = request.mock.calls.map( c => (c[0] as { request: { method: string } }).request.method ) expect(methods).toEqual([ 'solana_signAndSendTransaction', 'solana_signTransaction' ]) expect(sendRawTransaction).toHaveBeenCalledTimes(1) }) it('rethrows a user rejection of an approved signAndSend even when the message echoes the method name', async () => { // A wallet may reject signAndSend (user declined) with a message containing the method // name. We must NOT fall back and re-prompt signTransaction — surface the rejection. const request = vi .fn() .mockRejectedValueOnce( new Error('User rejected the request: solana_signAndSendTransaction') ) const client = { request } as unknown as SignClient await expect( new SolanaTransactionService().sendTransaction( client, sessionWithMethods([ 'solana_signTransaction', 'solana_signAndSendTransaction' ]), nativeRequest, SOL_CHAIN_ID, activeAddress ) ).rejects.toThrow(/User rejected/) const methods = request.mock.calls.map( c => (c[0] as { request: { method: string } }).request.method ) expect(methods).toEqual(['solana_signAndSendTransaction']) expect(sendRawTransaction).not.toHaveBeenCalled() }) it('resolves approved methods for per-chain-keyed namespaces (solana:), skipping signAndSend when absent', async () => { // WalletConnect / CAIP-25 lets a session key namespaces per chain. The pre-check defers // to isValidNamespacesRequest, which reads methods by chain id regardless of key shape — // so a `solana:` key that omits signAndSend is still detected. A plain // `namespaces['solana']` lookup would miss it and wrongly attempt signAndSend. const { request, client } = makeDualMethodClient() const session = { topic: MOCK_TOPIC, namespaces: { [SOL_CHAIN_ID]: { accounts: [`${SOL_CHAIN_ID}:${activeAddress}`], methods: ['solana_signTransaction'], events: [] } } } as unknown as SessionTypes.Struct await new SolanaTransactionService().sendTransaction( client, session, nativeRequest, SOL_CHAIN_ID, activeAddress ) const methods = request.mock.calls.map( c => (c[0] as { request: { method: string } }).request.method ) expect(methods).toEqual(['solana_signTransaction']) expect(methods).not.toContain('solana_signAndSendTransaction') expect(sendRawTransaction).toHaveBeenCalledTimes(1) }) it('drives the SPL (VersionedTransaction) path through submitTransaction, skipping signAndSend when unapproved', async () => { // submitTransaction serializes/deserializes both legacy (native) and VersionedTransaction // (SPL / generic) inputs. SPL routes through the versioned branch, so this guards that // path end-to-end through the pre-check, not just the native one. const blockhash = '11111111111111111111111111111111' const signedVersioned = buildVersionedTx(blockhash) signedVersioned.sign([userKeypair]) const signedVersionedB64 = Buffer.from( signedVersioned.serialize() ).toString('base64') const request = vi .fn() .mockImplementation((args: { request: { method: string } }) => { const method = args.request.method if (method === 'solana_signAndSendTransaction') { return Promise.reject( new Error( 'Missing or invalid. request() method: solana_signAndSendTransaction' ) ) } if (method === 'solana_signTransaction') { return Promise.resolve({ transaction: signedVersionedB64 }) } return Promise.reject(new Error(`unexpected method ${method}`)) }) const client = { request } as unknown as SignClient const splRequest = { from: activeAddress, to: recipient.toBase58(), amount: 1n, blockhash, tokenMint: Keypair.generate().publicKey.toBase58(), tokenDecimals: 6 } await new SolanaTransactionService().sendTransaction( client, sessionWithMethods(['solana_signTransaction']), splRequest, SOL_CHAIN_ID, activeAddress ) const methods = request.mock.calls.map( c => (c[0] as { request: { method: string } }).request.method ) expect(methods).toEqual(['solana_signTransaction']) expect(sendRawTransaction).toHaveBeenCalledTimes(1) }) it('skips signAndSend when the session exposes no approved methods for the chain', async () => { // Conservative default: if the session doesn't approve signAndSend (empty/missing methods), // we don't optimistically try it — isValidNamespacesRequest returns false and we // sign-then-broadcast. The SignClient would reject an optimistic attempt anyway. const { request, client } = makeDualMethodClient() await new SolanaTransactionService().sendTransaction( client, sessionWithMethods([]), nativeRequest, SOL_CHAIN_ID, activeAddress ) const methods = request.mock.calls.map( c => (c[0] as { request: { method: string } }).request.method ) expect(methods).toEqual(['solana_signTransaction']) expect(sendRawTransaction).toHaveBeenCalledTimes(1) }) })