// @vitest-environment node // Uses real @solana libs (not mocked) to exercise the actual ATA curve check; // needs Node crypto, so runs under node env rather than the repo-default jsdom. import { describe, it, expect } from 'vitest' import { PublicKey } from '@solana/web3.js' import { getAssociatedTokenAddressSync, decodeTransferCheckedInstruction, TokenOwnerOffCurveError, TOKEN_PROGRAM_ID, TOKEN_2022_PROGRAM_ID, ASSOCIATED_TOKEN_PROGRAM_ID } from '@solana/spl-token' import type { SolanaTokenTransferRequest, SolanaNativeTransferRequest, SolanaGenericTransferRequest } from '@meshconnect/uwc-types' import { SolanaTransactionBuilder } from './solana-transaction-builder' // --- Real, verified fixtures (all confirmed on mainnet) --- // USDPT — the Western Union stablecoin (Token-2022). CARE-286. const USDPT_MINT = 'HVWf8JmLoHs99Lw8Psf3fyqAtA4crWxCPkrmSdNjhNH3' // The WU Stablecard deposit address — an OFF-CURVE owner (a PDA). const OFF_CURVE_OWNER = 'XHgZqiZ3m87bs7eSqipkCuFDZWm8zCDbLqPiMn2ARi4' // On-chain-verified ATA of OFF_CURVE_OWNER for USDPT under Token-2022. This // account exists on mainnet and holds real USDPT — proving this is the correct // destination the fix must produce (derived with allowOwnerOffCurve=true). const WU_USDPT_ATA = 'G2MxpBxkPBUW3FZJdykLr6YwhDRYV9Z8F4xKMjP6no89' // USDC (legacy SPL) + a normal on-curve wallet. const USDC_MINT = 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v' const ON_CURVE_OWNER = '5uvp3sU4xEEwXeuR3x3HSn1oVUK7VNWf5TEKJUCoz8rn' // A generic off-curve PDA (not tied to any mint). const OFF_CURVE_PDA = '9fJNtG6yo8YmPqyQjUR9qrNe4X8oNSK4EKZwHTqwmEzq' // The connected sending wallet (on-curve — it signs the tx). const SENDER = '5uvp3sU4xEEwXeuR3x3HSn1oVUK7VNWf5TEKJUCoz8rn' const BLOCKHASH = '3PQ5dzi2ZCTfnu5aoncPBW1LBkqGGA54xpakg4JlNF1X' const builder = new SolanaTransactionBuilder() const tokenRequest = ( over: Partial = {} ): SolanaTokenTransferRequest => ({ from: SENDER, to: OFF_CURVE_OWNER, amount: BigInt(1_000_000), blockhash: BLOCKHASH, tokenMint: USDPT_MINT, tokenDecimals: 6, tokenProgram: TOKEN_2022_PROGRAM_ID.toBase58(), ...over }) // Guard the fixtures themselves so the suite fails loudly if a constant is edited // to something that no longer models the scenario. describe('fixtures model the intended curve scenario', () => { it('OFF_CURVE_OWNER and OFF_CURVE_PDA are off-curve; SENDER/ON_CURVE_OWNER are on-curve', () => { expect(PublicKey.isOnCurve(new PublicKey(OFF_CURVE_OWNER).toBytes())).toBe( false ) expect(PublicKey.isOnCurve(new PublicKey(OFF_CURVE_PDA).toBytes())).toBe( false ) expect(PublicKey.isOnCurve(new PublicKey(ON_CURVE_OWNER).toBytes())).toBe( true ) expect(PublicKey.isOnCurve(new PublicKey(SENDER).toBytes())).toBe(true) }) }) describe('SolanaTransactionBuilder.buildTransferInstructions — SPL token', () => { it('off-curve (PDA) destination: does NOT throw and derives the owner ATA (Token-2022)', async () => { const ixs = await builder.buildTransferInstructions(tokenRequest()) expect(ixs).toHaveLength(2) const decoded = decodeTransferCheckedInstruction( ixs[1]!, TOKEN_2022_PROGRAM_ID ) // Destination is the owner's ATA — and matches the account that actually // exists on mainnet holding USDPT for this owner. expect(decoded.keys.destination.pubkey.toBase58()).toBe(WU_USDPT_ATA) expect(decoded.keys.destination.pubkey.toBase58()).toBe( getAssociatedTokenAddressSync( new PublicKey(USDPT_MINT), new PublicKey(OFF_CURVE_OWNER), true, TOKEN_2022_PROGRAM_ID ).toBase58() ) expect(decoded.data.amount).toBe(BigInt(1_000_000)) expect(decoded.data.decimals).toBe(6) }) it('emits exactly [idempotent create-ATA, transferChecked] with correct program ids', async () => { const ixs = await builder.buildTransferInstructions(tokenRequest()) // [0] create the destination ATA (idempotent) via the ATA program expect(ixs[0]!.programId.toBase58()).toBe( ASSOCIATED_TOKEN_PROGRAM_ID.toBase58() ) // The ATA-create targets the same off-curve owner's ATA (keys[1] = ata). expect(ixs[0]!.keys[1]!.pubkey.toBase58()).toBe(WU_USDPT_ATA) // [1] transferChecked via the Token-2022 program expect(ixs[1]!.programId.toBase58()).toBe(TOKEN_2022_PROGRAM_ID.toBase58()) }) it('off-curve destination, legacy SPL (tokenProgram omitted → TOKEN_PROGRAM_ID)', async () => { const ixs = await builder.buildTransferInstructions( tokenRequest({ to: OFF_CURVE_PDA, tokenMint: USDC_MINT, tokenProgram: undefined }) ) const decoded = decodeTransferCheckedInstruction(ixs[1]!, TOKEN_PROGRAM_ID) expect(ixs[1]!.programId.toBase58()).toBe(TOKEN_PROGRAM_ID.toBase58()) expect(decoded.keys.destination.pubkey.toBase58()).toBe( getAssociatedTokenAddressSync( new PublicKey(USDC_MINT), new PublicKey(OFF_CURVE_PDA), true, TOKEN_PROGRAM_ID ).toBase58() ) }) it('regression: on-curve destination derives the SAME ATA as before the fix', async () => { const ixs = await builder.buildTransferInstructions( tokenRequest({ to: ON_CURVE_OWNER, tokenMint: USDC_MINT, tokenProgram: undefined }) ) const decoded = decodeTransferCheckedInstruction(ixs[1]!, TOKEN_PROGRAM_ID) // For an on-curve owner, allowOwnerOffCurve true and false produce the // identical address, so existing (on-curve) destinations are unaffected. const preFix = getAssociatedTokenAddressSync( new PublicKey(USDC_MINT), new PublicKey(ON_CURVE_OWNER), false, TOKEN_PROGRAM_ID ).toBase58() const postFix = getAssociatedTokenAddressSync( new PublicKey(USDC_MINT), new PublicKey(ON_CURVE_OWNER), true, TOKEN_PROGRAM_ID ).toBase58() expect(preFix).toBe(postFix) expect(decoded.keys.destination.pubkey.toBase58()).toBe(postFix) }) it('sender (from) MUST stay on-curve: an off-curve signer still throws', async () => { // The sender signs the transaction; a keyless PDA can never be the signer, // so the `from` derivation deliberately keeps allowOwnerOffCurve=false. await expect( builder.buildTransferInstructions(tokenRequest({ from: OFF_CURVE_PDA })) ).rejects.toThrowError(TokenOwnerOffCurveError) }) it('transferChecked carries no extra keys (no transfer-hook resolution today)', async () => { // Documents a known limitation: the builder emits a bare transferChecked. // USDPT currently has transferHook.programId = null, so this is valid; if a // hook program is ever set on the mint, hook extra-account-metas would be // required and this instruction would need updating. const ixs = await builder.buildTransferInstructions(tokenRequest()) // transferChecked standard account count = 4 (source, mint, dest, owner). expect(ixs[1]!.keys).toHaveLength(4) }) it('documents the `to`-must-be-an-owner invariant: a token account passed as `to` does NOT throw here', async () => { // With allowOwnerOffCurve=true, passing a token account (itself off-curve) // as `to` is treated as an owner and its ATA is derived — it does not throw, // and funds would land at an ATA nobody controls. The invariant that `to` is // an owner (never an ATA) is enforced UPSTREAM at transfer-preview validation // (the builder has no RPC to classify the account). WU_USDPT_ATA is itself an // ATA. This test pins current behavior so a future regression is visible. const ixs = await builder.buildTransferInstructions( tokenRequest({ to: WU_USDPT_ATA }) ) const decoded = decodeTransferCheckedInstruction( ixs[1]!, TOKEN_2022_PROGRAM_ID ) // Derives the ATA *of* the token account (a different address), not a direct // send to WU_USDPT_ATA — which is exactly why `to` must be an owner. expect(decoded.keys.destination.pubkey.toBase58()).not.toBe(WU_USDPT_ATA) expect(decoded.keys.destination.pubkey.toBase58()).toBe( getAssociatedTokenAddressSync( new PublicKey(USDPT_MINT), new PublicKey(WU_USDPT_ATA), true, TOKEN_2022_PROGRAM_ID ).toBase58() ) }) }) describe('SolanaTransactionBuilder.buildTransferInstructions — native SOL', () => { it('native transfer to an off-curve address does NOT derive an ATA and does not throw', async () => { const req: SolanaNativeTransferRequest = { from: SENDER, to: OFF_CURVE_PDA, amount: BigInt(1000), blockhash: BLOCKHASH } const ixs = await builder.buildTransferInstructions(req) expect(ixs).toHaveLength(1) // System Program transfer — not a token/ATA instruction. expect(ixs[0]!.programId.toBase58()).toBe( '11111111111111111111111111111111' ) }) }) describe('SolanaTransactionBuilder.buildTransferInstructions — generic request (smart funding)', () => { it('additionalRequests SPL transfer to an off-curve destination re-enters the SPL builder without throwing', async () => { // The smart-funding path wraps the deposit as an additionalRequest on a // SolanaGenericTransferRequest; buildGenericTransferInstructions re-enters // buildSplTokenTransferInstructions per sub-request, so the off-curve fix // must hold here too (this path also failed pre-fix). const req: SolanaGenericTransferRequest = { feePayer: SENDER, blockhash: BLOCKHASH, instructions: [], additionalRequests: [tokenRequest()] // off-curve dest, Token-2022 } const ixs = await builder.buildTransferInstructions(req) // 0 generic instructions + the SPL sub-request's [create-ATA, transferChecked] expect(ixs).toHaveLength(2) const decoded = decodeTransferCheckedInstruction( ixs[ixs.length - 1]!, TOKEN_2022_PROGRAM_ID ) expect(decoded.keys.destination.pubkey.toBase58()).toBe(WU_USDPT_ATA) }) })