// @vitest-environment node // // Node env (not jsdom): @solana/web3.js's VersionedTransaction.deserialize relies // on a Uint8Array prototype check that fails under jsdom's polyfilled Buffer. // Same reason as injected-connector/src/signSolanaTransactionBytes.test.ts. import { describe, it, expect, vi } from 'vitest' import { Keypair, SystemProgram, TransactionMessage, VersionedTransaction } from '@solana/web3.js' import { signSolanaBytes, type SignableSolanaAdapter } from './signSolanaBytes' const payer = Keypair.generate() const recipient = Keypair.generate().publicKey function buildSerializedV0Tx(): Uint8Array { const msg = new TransactionMessage({ payerKey: payer.publicKey, recentBlockhash: '11111111111111111111111111111111', instructions: [ SystemProgram.transfer({ fromPubkey: payer.publicKey, toPubkey: recipient, lamports: 1 }) ] }).compileToV0Message() return new VersionedTransaction(msg).serialize() } function buildSignedV0Bytes(): Uint8Array { const msg = new TransactionMessage({ payerKey: payer.publicKey, recentBlockhash: '11111111111111111111111111111111', instructions: [ SystemProgram.transfer({ fromPubkey: payer.publicKey, toPubkey: recipient, lamports: 1 }) ] }).compileToV0Message() const tx = new VersionedTransaction(msg) tx.sign([payer]) return tx.serialize() } describe('signSolanaBytes (uwc-bridge-parent)', () => { // ── Preferred path: raw Wallet Standard feature ─────────────────────────── it('signs via the solana:signTransaction feature with raw bytes (no Transaction object built)', async () => { const signedBytes = buildSignedV0Bytes() const featureSignTx = vi .fn() .mockResolvedValue([{ signedTransaction: signedBytes }]) const account = { address: payer.publicKey.toBase58() } const adapter: SignableSolanaAdapter = { publicKey: { toBase58: () => payer.publicKey.toBase58() }, // adapter.signTransaction is present but must NOT be used on this path. signTransaction: vi.fn(), wallet: { accounts: [account], features: { 'solana:signTransaction': { signTransaction: featureSignTx } } } } const serialized = buildSerializedV0Tx() const result = await signSolanaBytes(adapter, serialized) expect(result).toBe(signedBytes) // Raw bytes in — the exact same reference, never a deserialized object. expect(featureSignTx).toHaveBeenCalledWith({ account, transaction: serialized }) expect(adapter.signTransaction).not.toHaveBeenCalled() }) it('signs with the connected account, not blindly the first one', async () => { const signedBytes = buildSignedV0Bytes() const featureSignTx = vi .fn() .mockResolvedValue([{ signedTransaction: signedBytes }]) const accountAAA = { address: 'AAA' } const accountBBB = { address: 'BBB' } const adapter: SignableSolanaAdapter = { publicKey: { toBase58: () => 'BBB' }, wallet: { accounts: [accountAAA, accountBBB], features: { 'solana:signTransaction': { signTransaction: featureSignTx } } } } await signSolanaBytes(adapter, buildSerializedV0Tx()) expect(featureSignTx.mock.calls[0]?.[0]?.account).toBe(accountBBB) }) it('prefers the explicit connectedAddress over the adapter publicKey', async () => { // The parent's publicKey may have drifted; the caller-supplied address wins. const signedBytes = buildSignedV0Bytes() const featureSignTx = vi .fn() .mockResolvedValue([{ signedTransaction: signedBytes }]) const accountAAA = { address: 'AAA' } const accountBBB = { address: 'BBB' } const adapter: SignableSolanaAdapter = { publicKey: { toBase58: () => 'AAA' }, // parent's current account wallet: { accounts: [accountAAA, accountBBB], features: { 'solana:signTransaction': { signTransaction: featureSignTx } } } } await signSolanaBytes(adapter, buildSerializedV0Tx(), 'BBB') expect(featureSignTx.mock.calls[0]?.[0]?.account).toBe(accountBBB) }) it('throws when the explicit connectedAddress is not in the wallet, even if publicKey is', async () => { const featureSignTx = vi.fn() const adapter: SignableSolanaAdapter = { publicKey: { toBase58: () => 'AAA' }, // in the wallet, but not what we asked for wallet: { accounts: [{ address: 'AAA' }, { address: 'BBB' }], features: { 'solana:signTransaction': { signTransaction: featureSignTx } } } } await expect( signSolanaBytes(adapter, buildSerializedV0Tx(), 'NOT_IN_WALLET') ).rejects.toThrow('Connected Solana account not found in the wallet') expect(featureSignTx).not.toHaveBeenCalled() }) it('throws when the connected account is not in the wallet (never signs with a different account)', async () => { const featureSignTx = vi.fn() const adapter: SignableSolanaAdapter = { publicKey: { toBase58: () => 'NOT_IN_WALLET' }, wallet: { accounts: [{ address: 'AAA' }, { address: 'BBB' }], features: { 'solana:signTransaction': { signTransaction: featureSignTx } } } } await expect( signSolanaBytes(adapter, buildSerializedV0Tx()) ).rejects.toThrow('Connected Solana account not found in the wallet') expect(featureSignTx).not.toHaveBeenCalled() }) it('throws when no account is selected and several exist', async () => { const featureSignTx = vi.fn() const adapter: SignableSolanaAdapter = { publicKey: null, // toBase58 yields undefined → no selection wallet: { accounts: [{ address: 'AAA' }, { address: 'BBB' }], features: { 'solana:signTransaction': { signTransaction: featureSignTx } } } } await expect( signSolanaBytes(adapter, buildSerializedV0Tx()) ).rejects.toThrow('Cannot determine which Solana account to sign with') }) // ── Legacy fallback: deserialize → adapter.signTransaction → reserialize ─── it('falls back to adapter.signTransaction when the wallet has no Standard feature', async () => { const adapterSignTx = vi .fn() .mockResolvedValue(VersionedTransaction.deserialize(buildSignedV0Bytes())) const adapter: SignableSolanaAdapter = { // No `wallet` → legacy base adapter shape. signTransaction: adapterSignTx } const result = await signSolanaBytes(adapter, buildSerializedV0Tx()) expect(adapterSignTx).toHaveBeenCalledOnce() // The adapter receives a deserialized Transaction object, not raw bytes. expect(adapterSignTx.mock.calls[0]?.[0]).toBeInstanceOf( VersionedTransaction ) expect(result).toBeInstanceOf(Uint8Array) expect(result.length).toBeGreaterThan(0) }) it('throws when a legacy adapter cannot sign at all', async () => { const adapter: SignableSolanaAdapter = {} // no feature, no signTransaction await expect( signSolanaBytes(adapter, buildSerializedV0Tx()) ).rejects.toThrow('Adapter does not support signTransaction') }) // ── Invariant: sign-only, never broadcasts ──────────────────────────────── it('INVARIANT: never invokes any send/broadcast method', async () => { const signAndSend = vi.fn() const sendTransaction = vi.fn() const featureSignTx = vi .fn() .mockResolvedValue([{ signedTransaction: buildSignedV0Bytes() }]) const adapter = { publicKey: { toBase58: () => payer.publicKey.toBase58() }, signTransaction: vi.fn(), sendTransaction, signAndSendTransaction: signAndSend, wallet: { accounts: [{ address: payer.publicKey.toBase58() }], features: { 'solana:signTransaction': { signTransaction: featureSignTx } } } } as unknown as SignableSolanaAdapter await signSolanaBytes(adapter, buildSerializedV0Tx()) expect(signAndSend).not.toHaveBeenCalled() expect(sendTransaction).not.toHaveBeenCalled() }) })