diff --git a/package.json b/package.json index 508fe11..88b7524 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@yieldxyz/shield", - "version": "1.4.1", + "version": "1.5.0", "description": "Zero-trust transaction validation library for Yield.xyz integrations.", "packageManager": "pnpm@10.33.1", "engines": { @@ -81,4 +81,4 @@ "esbuild" ] } -} +} \ No newline at end of file diff --git a/src/json/schema.ts b/src/json/schema.ts index fc97237..4e8e70c 100644 --- a/src/json/schema.ts +++ b/src/json/schema.ts @@ -57,6 +57,11 @@ export const requestSchema = { items: { type: 'string', maxLength: 256 }, maxItems: 100, }, + stakeAccounts: { + type: 'array', + items: { type: 'string', maxLength: 128 }, + maxItems: 100, + }, }, }, context: { diff --git a/src/types/index.ts b/src/types/index.ts index 54419b3..2c91192 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -32,6 +32,11 @@ export type ActionArguments = { nominatorAddress?: string; receiverAddress?: string; nftIds?: string[]; + /** + * Solana MERGE (optional): signer-controlled stake account pubkeys. + * When provided, destination and source of every merge instruction must be in this list. + */ + stakeAccounts?: string[]; }; export interface FeeConfiguration { diff --git a/src/validators/solana/native-staking/native-staking.validator.test.ts b/src/validators/solana/native-staking/native-staking.validator.test.ts index 3e2e013..ac7ed3c 100644 --- a/src/validators/solana/native-staking/native-staking.validator.test.ts +++ b/src/validators/solana/native-staking/native-staking.validator.test.ts @@ -1006,11 +1006,25 @@ describe('SolanaNativeStakingValidator via Shield', () => { }); }); - describe('WITHDRAW validation', () => { - it('should reject WITHDRAW with wrong instruction count', () => { + describe('MERGE validation', () => { + const destinationStake = new PublicKey( + 'HzcH95P8DJnmjWfNLKeWYrNSYuMrbAGcp6MhXwWfeezk', + ); + const sourceStakes = [ + new PublicKey('9ZmDXFKKaLb5ct3cqbfqHzJPaag4KbZdk3HgAVfCWpMc'), + new PublicKey('2ejUissotvQJda8tnD9iqYbdSuz6Gv6dxDnZE8hEKwr5'), + new PublicKey('HaAebbtwqajTNEBJ2ys3yxWJXk6fi7tk7WXpvj6hMEXZ'), + ]; + + const mergeArgs = ( + accounts: PublicKey[] = [destinationStake, sourceStakes[0]], + ) => ({ + stakeAccounts: accounts.map((a) => a.toBase58()), + }); + + it('should reject MERGE with too few instructions', () => { const userPubkey = new PublicKey(userAddress); - // Only 2 instructions instead of 3 const transaction = new Transaction(); transaction.add( ComputeBudgetProgram.setComputeUnitLimit({ units: 350000 }), @@ -1018,7 +1032,6 @@ describe('SolanaNativeStakingValidator via Shield', () => { transaction.add( ComputeBudgetProgram.setComputeUnitPrice({ microLamports: 1 }), ); - // Missing Withdraw instruction transaction.recentBlockhash = '11111111111111111111111111111111'; transaction.feePayer = userPubkey; @@ -1030,40 +1043,78 @@ describe('SolanaNativeStakingValidator via Shield', () => { yieldId, unsignedTransaction: txHex, userAddress, + args: mergeArgs(), }); expect(result.isValid).toBe(false); expect(result.reason).toContain('No matching operation pattern found'); expect( result.details?.attempts?.some((attempt) => - attempt.reason?.includes('Invalid instruction count for WITHDRAW'), + attempt.reason?.includes('Invalid instruction count for MERGE'), ), ).toBe(true); }); - it('should reject WITHDRAW with missing SetComputeUnitLimit', () => { + it('should reject MERGE with too many instructions', () => { const userPubkey = new PublicKey(userAddress); - const stakeAccount = new PublicKey( - 'HzcH95P8DJnmjWfNLKeWYrNSYuMrbAGcp6MhXwWfeezk', + + const transaction = new Transaction(); + transaction.add( + ComputeBudgetProgram.setComputeUnitLimit({ units: 350000 }), + ); + transaction.add( + ComputeBudgetProgram.setComputeUnitPrice({ microLamports: 1 }), + ); + + Array.from({ length: 11 }, (_, i) => + transaction.add( + StakeProgram.merge({ + stakePubkey: destinationStake, + sourceStakePubKey: sourceStakes[i % sourceStakes.length], + authorizedPubkey: userPubkey, + }), + ), ); + transaction.recentBlockhash = '11111111111111111111111111111111'; + transaction.feePayer = userPubkey; + const txHex = transaction + .serialize({ requireAllSignatures: false, verifySignatures: false }) + .toString('hex'); + + const result = shield.validate({ + yieldId, + unsignedTransaction: txHex, + userAddress, + args: mergeArgs([destinationStake, ...sourceStakes]), + }); + + expect(result.isValid).toBe(false); + expect(result.reason).toContain('No matching operation pattern found'); + expect( + result.details?.attempts?.some((attempt) => + attempt.reason?.includes('Invalid instruction count for MERGE'), + ), + ).toBe(true); + }); + + it('should reject MERGE with missing SetComputeUnitLimit', () => { + const userPubkey = new PublicKey(userAddress); + const transaction = new Transaction(); - // Missing SetComputeUnitLimit transaction.add( ComputeBudgetProgram.setComputeUnitPrice({ microLamports: 1 }), ); transaction.add( - StakeProgram.withdraw({ - stakePubkey: stakeAccount, + StakeProgram.merge({ + stakePubkey: destinationStake, + sourceStakePubKey: sourceStakes[0], authorizedPubkey: userPubkey, - toPubkey: userPubkey, - lamports: 100000000, }), - StakeProgram.withdraw({ - stakePubkey: stakeAccount, + StakeProgram.merge({ + stakePubkey: destinationStake, + sourceStakePubKey: sourceStakes[1], authorizedPubkey: userPubkey, - toPubkey: userPubkey, - lamports: 100000000, }), ); @@ -1077,6 +1128,7 @@ describe('SolanaNativeStakingValidator via Shield', () => { yieldId, unsignedTransaction: txHex, userAddress, + args: mergeArgs([destinationStake, sourceStakes[0], sourceStakes[1]]), }); expect(result.isValid).toBe(false); @@ -1088,29 +1140,23 @@ describe('SolanaNativeStakingValidator via Shield', () => { ).toBe(true); }); - it('should reject WITHDRAW with missing SetComputeUnitPrice', () => { + it('should reject MERGE with missing SetComputeUnitPrice', () => { const userPubkey = new PublicKey(userAddress); - const stakeAccount = new PublicKey( - 'HzcH95P8DJnmjWfNLKeWYrNSYuMrbAGcp6MhXwWfeezk', - ); const transaction = new Transaction(); transaction.add( ComputeBudgetProgram.setComputeUnitLimit({ units: 350000 }), ); - // Missing SetComputeUnitPrice transaction.add( - StakeProgram.withdraw({ - stakePubkey: stakeAccount, + StakeProgram.merge({ + stakePubkey: destinationStake, + sourceStakePubKey: sourceStakes[0], authorizedPubkey: userPubkey, - toPubkey: userPubkey, - lamports: 100000000, }), - StakeProgram.withdraw({ - stakePubkey: stakeAccount, + StakeProgram.merge({ + stakePubkey: destinationStake, + sourceStakePubKey: sourceStakes[1], authorizedPubkey: userPubkey, - toPubkey: userPubkey, - lamports: 100000000, }), ); @@ -1124,6 +1170,7 @@ describe('SolanaNativeStakingValidator via Shield', () => { yieldId, unsignedTransaction: txHex, userAddress, + args: mergeArgs([destinationStake, sourceStakes[0], sourceStakes[1]]), }); expect(result.isValid).toBe(false); @@ -1135,7 +1182,7 @@ describe('SolanaNativeStakingValidator via Shield', () => { ).toBe(true); }); - it('should reject WITHDRAW with missing Withdraw instruction', () => { + it('should reject MERGE with non-Merge instruction in loop', () => { const userPubkey = new PublicKey(userAddress); const transaction = new Transaction(); @@ -1145,7 +1192,13 @@ describe('SolanaNativeStakingValidator via Shield', () => { transaction.add( ComputeBudgetProgram.setComputeUnitPrice({ microLamports: 1 }), ); - // Add wrong instruction instead of Withdraw + transaction.add( + StakeProgram.merge({ + stakePubkey: destinationStake, + sourceStakePubKey: sourceStakes[0], + authorizedPubkey: userPubkey, + }), + ); transaction.add( SystemProgram.transfer({ fromPubkey: userPubkey, @@ -1164,22 +1217,20 @@ describe('SolanaNativeStakingValidator via Shield', () => { yieldId, unsignedTransaction: txHex, userAddress, + args: mergeArgs([destinationStake, sourceStakes[0]]), }); expect(result.isValid).toBe(false); expect(result.reason).toContain('No matching operation pattern found'); expect( result.details?.attempts?.some((attempt) => - attempt.reason?.includes('Missing or invalid Withdraw instruction'), + attempt.reason?.includes('Instruction 3 must be a Merge instruction'), ), - ); + ).toBe(true); }); - it('should accept valid WITHDRAW transaction', () => { + it('should accept MERGE without stakeAccounts argument', () => { const userPubkey = new PublicKey(userAddress); - const stakeAccount = new PublicKey( - 'HzcH95P8DJnmjWfNLKeWYrNSYuMrbAGcp6MhXwWfeezk', - ); const transaction = new Transaction(); transaction.add( @@ -1189,11 +1240,10 @@ describe('SolanaNativeStakingValidator via Shield', () => { ComputeBudgetProgram.setComputeUnitPrice({ microLamports: 1 }), ); transaction.add( - StakeProgram.withdraw({ - stakePubkey: stakeAccount, + StakeProgram.merge({ + stakePubkey: destinationStake, + sourceStakePubKey: sourceStakes[0], authorizedPubkey: userPubkey, - toPubkey: userPubkey, - lamports: 100000000, }), ); @@ -1210,16 +1260,13 @@ describe('SolanaNativeStakingValidator via Shield', () => { }); expect(result.isValid).toBe(true); - expect(result.detectedType).toBe(TransactionType.WITHDRAW); + expect(result.detectedType).toBe(TransactionType.MERGE); }); - it('should reject WITHDRAW to wrong toPubkey recipient', () => { + it('should reject MERGE with destination not in stakeAccounts', () => { const userPubkey = new PublicKey(userAddress); - const maliciousRecipient = new PublicKey( - '2ejUissotvQJda8tnD9iqYbdSuz6Gv6dxDnZE8hEKwr5', - ); - const stakeAccount = new PublicKey( - 'HzcH95P8DJnmjWfNLKeWYrNSYuMrbAGcp6MhXwWfeezk', + const attackerDestination = new PublicKey( + 'BbM5kJgrwEj3tYFfBPnjcARB54wDUHkXmLUTkazUmt2x', ); const transaction = new Transaction(); @@ -1230,11 +1277,10 @@ describe('SolanaNativeStakingValidator via Shield', () => { ComputeBudgetProgram.setComputeUnitPrice({ microLamports: 1 }), ); transaction.add( - StakeProgram.withdraw({ - stakePubkey: stakeAccount, + StakeProgram.merge({ + stakePubkey: attackerDestination, + sourceStakePubKey: sourceStakes[0], authorizedPubkey: userPubkey, - toPubkey: maliciousRecipient, // Wrong recipient! - lamports: 100000000, }), ); @@ -1248,20 +1294,22 @@ describe('SolanaNativeStakingValidator via Shield', () => { yieldId, unsignedTransaction: txHex, userAddress, + args: mergeArgs([destinationStake, sourceStakes[0]]), }); expect(result.isValid).toBe(false); expect(result.reason).toContain('No matching operation pattern found'); + expect( + result.details?.attempts?.some((attempt) => + attempt.reason?.includes( + 'Merge destination for instruction 2 is not in stakeAccounts', + ), + ), + ).toBe(true); }); - it('should reject WITHDRAW to wrong authorizedPubkey recipient', () => { + it('should reject MERGE with source not in stakeAccounts', () => { const userPubkey = new PublicKey(userAddress); - const maliciousRecipient = new PublicKey( - '2ejUissotvQJda8tnD9iqYbdSuz6Gv6dxDnZE8hEKwr5', - ); - const stakeAccount = new PublicKey( - 'HzcH95P8DJnmjWfNLKeWYrNSYuMrbAGcp6MhXwWfeezk', - ); const transaction = new Transaction(); transaction.add( @@ -1271,11 +1319,10 @@ describe('SolanaNativeStakingValidator via Shield', () => { ComputeBudgetProgram.setComputeUnitPrice({ microLamports: 1 }), ); transaction.add( - StakeProgram.withdraw({ - stakePubkey: stakeAccount, - authorizedPubkey: maliciousRecipient, // Wrong authorizedPubkey! - toPubkey: userPubkey, - lamports: 100000000, + StakeProgram.merge({ + stakePubkey: destinationStake, + sourceStakePubKey: sourceStakes[1], + authorizedPubkey: userPubkey, }), ); @@ -1289,21 +1336,23 @@ describe('SolanaNativeStakingValidator via Shield', () => { yieldId, unsignedTransaction: txHex, userAddress, + args: mergeArgs([destinationStake, sourceStakes[0]]), }); expect(result.isValid).toBe(false); expect(result.reason).toContain('No matching operation pattern found'); + expect( + result.details?.attempts?.some((attempt) => + attempt.reason?.includes( + 'Merge source for instruction 2 is not in stakeAccounts', + ), + ), + ).toBe(true); }); - }); - describe('WITHDRAW_ALL validation', () => { - it('should reject WITHDRAW_ALL with too few instructions', () => { + it('should accept valid single MERGE transaction', () => { const userPubkey = new PublicKey(userAddress); - const stakeAccount = new PublicKey( - 'HzcH95P8DJnmjWfNLKeWYrNSYuMrbAGcp6MhXwWfeezk', - ); - // Only 3 instructions instead of minimum 4 const transaction = new Transaction(); transaction.add( ComputeBudgetProgram.setComputeUnitLimit({ units: 350000 }), @@ -1312,14 +1361,12 @@ describe('SolanaNativeStakingValidator via Shield', () => { ComputeBudgetProgram.setComputeUnitPrice({ microLamports: 1 }), ); transaction.add( - StakeProgram.withdraw({ - stakePubkey: stakeAccount, + StakeProgram.merge({ + stakePubkey: destinationStake, + sourceStakePubKey: sourceStakes[0], authorizedPubkey: userPubkey, - toPubkey: userPubkey, - lamports: 100000000, }), ); - // Need at least one more withdraw instruction for WITHDRAW_ALL transaction.recentBlockhash = '11111111111111111111111111111111'; transaction.feePayer = userPubkey; @@ -1331,37 +1378,29 @@ describe('SolanaNativeStakingValidator via Shield', () => { yieldId, unsignedTransaction: txHex, userAddress, + args: mergeArgs(), }); - expect(result.isValid).toBe(true); // Will match regular WITHDRAW - expect( - result.details?.attempts?.some((attempt) => - attempt.reason?.includes( - 'Invalid instruction count for WITHDRAW_ALL', - ), - ), - ); + expect(result.isValid).toBe(true); + expect(result.detectedType).toBe(TransactionType.MERGE); }); - it('should reject WITHDRAW_ALL with missing SetComputeUnitLimit', () => { + it('should accept valid batch MERGE transaction', () => { const userPubkey = new PublicKey(userAddress); - const stakeAccount = new PublicKey( - 'HzcH95P8DJnmjWfNLKeWYrNSYuMrbAGcp6MhXwWfeezk', - ); const transaction = new Transaction(); - // Missing SetComputeUnitLimit + transaction.add( + ComputeBudgetProgram.setComputeUnitLimit({ units: 350000 }), + ); transaction.add( ComputeBudgetProgram.setComputeUnitPrice({ microLamports: 1 }), ); - - Array.from({ length: 2 }, (_, i) => + Array.from({ length: sourceStakes.length }, (_, i) => transaction.add( - StakeProgram.withdraw({ - stakePubkey: stakeAccount, + StakeProgram.merge({ + stakePubkey: destinationStake, + sourceStakePubKey: sourceStakes[i], authorizedPubkey: userPubkey, - toPubkey: userPubkey, - lamports: 100000000 + i * 100, }), ), ); @@ -1376,38 +1415,29 @@ describe('SolanaNativeStakingValidator via Shield', () => { yieldId, unsignedTransaction: txHex, userAddress, + args: mergeArgs([destinationStake, ...sourceStakes]), }); - expect(result.isValid).toBe(false); - expect(result.reason).toContain('No matching operation pattern found'); - expect( - result.details?.attempts?.some((attempt) => - attempt.reason?.includes('Missing or invalid SetComputeUnitLimit'), - ), - ).toBe(true); + expect(result.isValid).toBe(true); + expect(result.detectedType).toBe(TransactionType.MERGE); }); - it('should reject WITHDRAW_ALL with missing SetComputeUnitPrice', () => { + it('should accept MERGE when stakeAccounts includes unused accounts', () => { const userPubkey = new PublicKey(userAddress); - const stakeAccount = new PublicKey( - 'HzcH95P8DJnmjWfNLKeWYrNSYuMrbAGcp6MhXwWfeezk', - ); const transaction = new Transaction(); transaction.add( ComputeBudgetProgram.setComputeUnitLimit({ units: 350000 }), ); - // Missing SetComputeUnitPrice - - Array.from({ length: 2 }, (_, i) => - transaction.add( - StakeProgram.withdraw({ - stakePubkey: stakeAccount, - authorizedPubkey: userPubkey, - toPubkey: userPubkey, - lamports: 100000000 + i * 100, - }), - ), + transaction.add( + ComputeBudgetProgram.setComputeUnitPrice({ microLamports: 1 }), + ); + transaction.add( + StakeProgram.merge({ + stakePubkey: destinationStake, + sourceStakePubKey: sourceStakes[0], + authorizedPubkey: userPubkey, + }), ); transaction.recentBlockhash = '11111111111111111111111111111111'; @@ -1420,21 +1450,17 @@ describe('SolanaNativeStakingValidator via Shield', () => { yieldId, unsignedTransaction: txHex, userAddress, + args: mergeArgs([destinationStake, ...sourceStakes]), }); - expect(result.isValid).toBe(false); - expect(result.reason).toContain('No matching operation pattern found'); - expect( - result.details?.attempts?.some((attempt) => - attempt.reason?.includes('Missing or invalid SetComputeUnitPrice'), - ), - ).toBe(true); + expect(result.isValid).toBe(true); + expect(result.detectedType).toBe(TransactionType.MERGE); }); - it('should reject WITHDRAW_ALL with non-Withdraw instruction in loop', () => { + it('should reject MERGE with wrong authority', () => { const userPubkey = new PublicKey(userAddress); - const stakeAccount = new PublicKey( - 'HzcH95P8DJnmjWfNLKeWYrNSYuMrbAGcp6MhXwWfeezk', + const maliciousPubkey = new PublicKey( + '2ejUissotvQJda8tnD9iqYbdSuz6Gv6dxDnZE8hEKwr5', ); const transaction = new Transaction(); @@ -1445,19 +1471,10 @@ describe('SolanaNativeStakingValidator via Shield', () => { ComputeBudgetProgram.setComputeUnitPrice({ microLamports: 1 }), ); transaction.add( - StakeProgram.withdraw({ - stakePubkey: stakeAccount, - authorizedPubkey: userPubkey, - toPubkey: userPubkey, - lamports: 100000000, - }), - ); - // Add a non-Withdraw instruction - transaction.add( - SystemProgram.transfer({ - fromPubkey: userPubkey, - toPubkey: userPubkey, - lamports: 1000, + StakeProgram.merge({ + stakePubkey: destinationStake, + sourceStakePubKey: sourceStakes[0], + authorizedPubkey: maliciousPubkey, }), ); @@ -1471,23 +1488,26 @@ describe('SolanaNativeStakingValidator via Shield', () => { yieldId, unsignedTransaction: txHex, userAddress, + args: mergeArgs(), }); expect(result.isValid).toBe(false); expect(result.reason).toContain('No matching operation pattern found'); expect( result.details?.attempts?.some((attempt) => - attempt.reason?.includes('Missing or invalid Withdraw instruction'), + attempt.reason?.includes( + 'Merge authority for instruction 2 is not user address', + ), ), ).toBe(true); }); + }); - it('should accept valid WITHDRAW_ALL transaction', () => { + describe('WITHDRAW validation', () => { + it('should reject WITHDRAW with wrong instruction count', () => { const userPubkey = new PublicKey(userAddress); - const stakeAccount = new PublicKey( - 'HzcH95P8DJnmjWfNLKeWYrNSYuMrbAGcp6MhXwWfeezk', - ); + // Only 2 instructions instead of 3 const transaction = new Transaction(); transaction.add( ComputeBudgetProgram.setComputeUnitLimit({ units: 350000 }), @@ -1495,16 +1515,7 @@ describe('SolanaNativeStakingValidator via Shield', () => { transaction.add( ComputeBudgetProgram.setComputeUnitPrice({ microLamports: 1 }), ); - Array.from({ length: 10 }, (_, i) => - transaction.add( - StakeProgram.withdraw({ - stakePubkey: stakeAccount, - authorizedPubkey: userPubkey, - toPubkey: userPubkey, - lamports: 100000000 + i * 100, - }), - ), - ); + // Missing Withdraw instruction transaction.recentBlockhash = '11111111111111111111111111111111'; transaction.feePayer = userPubkey; @@ -1518,30 +1529,516 @@ describe('SolanaNativeStakingValidator via Shield', () => { userAddress, }); - expect(result.isValid).toBe(true); - expect(result.detectedType).toBe(TransactionType.WITHDRAW_ALL); + expect(result.isValid).toBe(false); + expect(result.reason).toContain('No matching operation pattern found'); + expect( + result.details?.attempts?.some((attempt) => + attempt.reason?.includes('Invalid instruction count for WITHDRAW'), + ), + ).toBe(true); }); - it('should reject WITHDRAW_ALL to wrong toPubkey recipient', () => { + it('should reject WITHDRAW with missing SetComputeUnitLimit', () => { const userPubkey = new PublicKey(userAddress); - const maliciousRecipient = new PublicKey( - '2ejUissotvQJda8tnD9iqYbdSuz6Gv6dxDnZE8hEKwr5', - ); const stakeAccount = new PublicKey( 'HzcH95P8DJnmjWfNLKeWYrNSYuMrbAGcp6MhXwWfeezk', ); const transaction = new Transaction(); - transaction.add( - ComputeBudgetProgram.setComputeUnitLimit({ units: 350000 }), - ); + // Missing SetComputeUnitLimit transaction.add( ComputeBudgetProgram.setComputeUnitPrice({ microLamports: 1 }), ); - Array.from({ length: 10 }, (_, i) => - transaction.add( - StakeProgram.withdraw({ - stakePubkey: stakeAccount, + transaction.add( + StakeProgram.withdraw({ + stakePubkey: stakeAccount, + authorizedPubkey: userPubkey, + toPubkey: userPubkey, + lamports: 100000000, + }), + StakeProgram.withdraw({ + stakePubkey: stakeAccount, + authorizedPubkey: userPubkey, + toPubkey: userPubkey, + lamports: 100000000, + }), + ); + + transaction.recentBlockhash = '11111111111111111111111111111111'; + transaction.feePayer = userPubkey; + const txHex = transaction + .serialize({ requireAllSignatures: false, verifySignatures: false }) + .toString('hex'); + + const result = shield.validate({ + yieldId, + unsignedTransaction: txHex, + userAddress, + }); + + expect(result.isValid).toBe(false); + expect(result.reason).toContain('No matching operation pattern found'); + expect( + result.details?.attempts?.some((attempt) => + attempt.reason?.includes('Missing or invalid SetComputeUnitLimit'), + ), + ).toBe(true); + }); + + it('should reject WITHDRAW with missing SetComputeUnitPrice', () => { + const userPubkey = new PublicKey(userAddress); + const stakeAccount = new PublicKey( + 'HzcH95P8DJnmjWfNLKeWYrNSYuMrbAGcp6MhXwWfeezk', + ); + + const transaction = new Transaction(); + transaction.add( + ComputeBudgetProgram.setComputeUnitLimit({ units: 350000 }), + ); + // Missing SetComputeUnitPrice + transaction.add( + StakeProgram.withdraw({ + stakePubkey: stakeAccount, + authorizedPubkey: userPubkey, + toPubkey: userPubkey, + lamports: 100000000, + }), + StakeProgram.withdraw({ + stakePubkey: stakeAccount, + authorizedPubkey: userPubkey, + toPubkey: userPubkey, + lamports: 100000000, + }), + ); + + transaction.recentBlockhash = '11111111111111111111111111111111'; + transaction.feePayer = userPubkey; + const txHex = transaction + .serialize({ requireAllSignatures: false, verifySignatures: false }) + .toString('hex'); + + const result = shield.validate({ + yieldId, + unsignedTransaction: txHex, + userAddress, + }); + + expect(result.isValid).toBe(false); + expect(result.reason).toContain('No matching operation pattern found'); + expect( + result.details?.attempts?.some((attempt) => + attempt.reason?.includes('Missing or invalid SetComputeUnitPrice'), + ), + ).toBe(true); + }); + + it('should reject WITHDRAW with missing Withdraw instruction', () => { + const userPubkey = new PublicKey(userAddress); + + const transaction = new Transaction(); + transaction.add( + ComputeBudgetProgram.setComputeUnitLimit({ units: 350000 }), + ); + transaction.add( + ComputeBudgetProgram.setComputeUnitPrice({ microLamports: 1 }), + ); + // Add wrong instruction instead of Withdraw + transaction.add( + SystemProgram.transfer({ + fromPubkey: userPubkey, + toPubkey: userPubkey, + lamports: 1000, + }), + ); + + transaction.recentBlockhash = '11111111111111111111111111111111'; + transaction.feePayer = userPubkey; + const txHex = transaction + .serialize({ requireAllSignatures: false, verifySignatures: false }) + .toString('hex'); + + const result = shield.validate({ + yieldId, + unsignedTransaction: txHex, + userAddress, + }); + + expect(result.isValid).toBe(false); + expect(result.reason).toContain('No matching operation pattern found'); + expect( + result.details?.attempts?.some((attempt) => + attempt.reason?.includes('Missing or invalid Withdraw instruction'), + ), + ); + }); + + it('should accept valid WITHDRAW transaction', () => { + const userPubkey = new PublicKey(userAddress); + const stakeAccount = new PublicKey( + 'HzcH95P8DJnmjWfNLKeWYrNSYuMrbAGcp6MhXwWfeezk', + ); + + const transaction = new Transaction(); + transaction.add( + ComputeBudgetProgram.setComputeUnitLimit({ units: 350000 }), + ); + transaction.add( + ComputeBudgetProgram.setComputeUnitPrice({ microLamports: 1 }), + ); + transaction.add( + StakeProgram.withdraw({ + stakePubkey: stakeAccount, + authorizedPubkey: userPubkey, + toPubkey: userPubkey, + lamports: 100000000, + }), + ); + + transaction.recentBlockhash = '11111111111111111111111111111111'; + transaction.feePayer = userPubkey; + const txHex = transaction + .serialize({ requireAllSignatures: false, verifySignatures: false }) + .toString('hex'); + + const result = shield.validate({ + yieldId, + unsignedTransaction: txHex, + userAddress, + }); + + expect(result.isValid).toBe(true); + expect(result.detectedType).toBe(TransactionType.WITHDRAW); + }); + + it('should reject WITHDRAW to wrong toPubkey recipient', () => { + const userPubkey = new PublicKey(userAddress); + const maliciousRecipient = new PublicKey( + '2ejUissotvQJda8tnD9iqYbdSuz6Gv6dxDnZE8hEKwr5', + ); + const stakeAccount = new PublicKey( + 'HzcH95P8DJnmjWfNLKeWYrNSYuMrbAGcp6MhXwWfeezk', + ); + + const transaction = new Transaction(); + transaction.add( + ComputeBudgetProgram.setComputeUnitLimit({ units: 350000 }), + ); + transaction.add( + ComputeBudgetProgram.setComputeUnitPrice({ microLamports: 1 }), + ); + transaction.add( + StakeProgram.withdraw({ + stakePubkey: stakeAccount, + authorizedPubkey: userPubkey, + toPubkey: maliciousRecipient, // Wrong recipient! + lamports: 100000000, + }), + ); + + transaction.recentBlockhash = '11111111111111111111111111111111'; + transaction.feePayer = userPubkey; + const txHex = transaction + .serialize({ requireAllSignatures: false, verifySignatures: false }) + .toString('hex'); + + const result = shield.validate({ + yieldId, + unsignedTransaction: txHex, + userAddress, + }); + + expect(result.isValid).toBe(false); + expect(result.reason).toContain('No matching operation pattern found'); + }); + + it('should reject WITHDRAW to wrong authorizedPubkey recipient', () => { + const userPubkey = new PublicKey(userAddress); + const maliciousRecipient = new PublicKey( + '2ejUissotvQJda8tnD9iqYbdSuz6Gv6dxDnZE8hEKwr5', + ); + const stakeAccount = new PublicKey( + 'HzcH95P8DJnmjWfNLKeWYrNSYuMrbAGcp6MhXwWfeezk', + ); + + const transaction = new Transaction(); + transaction.add( + ComputeBudgetProgram.setComputeUnitLimit({ units: 350000 }), + ); + transaction.add( + ComputeBudgetProgram.setComputeUnitPrice({ microLamports: 1 }), + ); + transaction.add( + StakeProgram.withdraw({ + stakePubkey: stakeAccount, + authorizedPubkey: maliciousRecipient, // Wrong authorizedPubkey! + toPubkey: userPubkey, + lamports: 100000000, + }), + ); + + transaction.recentBlockhash = '11111111111111111111111111111111'; + transaction.feePayer = userPubkey; + const txHex = transaction + .serialize({ requireAllSignatures: false, verifySignatures: false }) + .toString('hex'); + + const result = shield.validate({ + yieldId, + unsignedTransaction: txHex, + userAddress, + }); + + expect(result.isValid).toBe(false); + expect(result.reason).toContain('No matching operation pattern found'); + }); + }); + + describe('WITHDRAW_ALL validation', () => { + it('should reject WITHDRAW_ALL with too few instructions', () => { + const userPubkey = new PublicKey(userAddress); + const stakeAccount = new PublicKey( + 'HzcH95P8DJnmjWfNLKeWYrNSYuMrbAGcp6MhXwWfeezk', + ); + + // Only 3 instructions instead of minimum 4 + const transaction = new Transaction(); + transaction.add( + ComputeBudgetProgram.setComputeUnitLimit({ units: 350000 }), + ); + transaction.add( + ComputeBudgetProgram.setComputeUnitPrice({ microLamports: 1 }), + ); + transaction.add( + StakeProgram.withdraw({ + stakePubkey: stakeAccount, + authorizedPubkey: userPubkey, + toPubkey: userPubkey, + lamports: 100000000, + }), + ); + // Need at least one more withdraw instruction for WITHDRAW_ALL + + transaction.recentBlockhash = '11111111111111111111111111111111'; + transaction.feePayer = userPubkey; + const txHex = transaction + .serialize({ requireAllSignatures: false, verifySignatures: false }) + .toString('hex'); + + const result = shield.validate({ + yieldId, + unsignedTransaction: txHex, + userAddress, + }); + + expect(result.isValid).toBe(true); // Will match regular WITHDRAW + expect( + result.details?.attempts?.some((attempt) => + attempt.reason?.includes( + 'Invalid instruction count for WITHDRAW_ALL', + ), + ), + ); + }); + + it('should reject WITHDRAW_ALL with missing SetComputeUnitLimit', () => { + const userPubkey = new PublicKey(userAddress); + const stakeAccount = new PublicKey( + 'HzcH95P8DJnmjWfNLKeWYrNSYuMrbAGcp6MhXwWfeezk', + ); + + const transaction = new Transaction(); + // Missing SetComputeUnitLimit + transaction.add( + ComputeBudgetProgram.setComputeUnitPrice({ microLamports: 1 }), + ); + + Array.from({ length: 2 }, (_, i) => + transaction.add( + StakeProgram.withdraw({ + stakePubkey: stakeAccount, + authorizedPubkey: userPubkey, + toPubkey: userPubkey, + lamports: 100000000 + i * 100, + }), + ), + ); + + transaction.recentBlockhash = '11111111111111111111111111111111'; + transaction.feePayer = userPubkey; + const txHex = transaction + .serialize({ requireAllSignatures: false, verifySignatures: false }) + .toString('hex'); + + const result = shield.validate({ + yieldId, + unsignedTransaction: txHex, + userAddress, + }); + + expect(result.isValid).toBe(false); + expect(result.reason).toContain('No matching operation pattern found'); + expect( + result.details?.attempts?.some((attempt) => + attempt.reason?.includes('Missing or invalid SetComputeUnitLimit'), + ), + ).toBe(true); + }); + + it('should reject WITHDRAW_ALL with missing SetComputeUnitPrice', () => { + const userPubkey = new PublicKey(userAddress); + const stakeAccount = new PublicKey( + 'HzcH95P8DJnmjWfNLKeWYrNSYuMrbAGcp6MhXwWfeezk', + ); + + const transaction = new Transaction(); + transaction.add( + ComputeBudgetProgram.setComputeUnitLimit({ units: 350000 }), + ); + // Missing SetComputeUnitPrice + + Array.from({ length: 2 }, (_, i) => + transaction.add( + StakeProgram.withdraw({ + stakePubkey: stakeAccount, + authorizedPubkey: userPubkey, + toPubkey: userPubkey, + lamports: 100000000 + i * 100, + }), + ), + ); + + transaction.recentBlockhash = '11111111111111111111111111111111'; + transaction.feePayer = userPubkey; + const txHex = transaction + .serialize({ requireAllSignatures: false, verifySignatures: false }) + .toString('hex'); + + const result = shield.validate({ + yieldId, + unsignedTransaction: txHex, + userAddress, + }); + + expect(result.isValid).toBe(false); + expect(result.reason).toContain('No matching operation pattern found'); + expect( + result.details?.attempts?.some((attempt) => + attempt.reason?.includes('Missing or invalid SetComputeUnitPrice'), + ), + ).toBe(true); + }); + + it('should reject WITHDRAW_ALL with non-Withdraw instruction in loop', () => { + const userPubkey = new PublicKey(userAddress); + const stakeAccount = new PublicKey( + 'HzcH95P8DJnmjWfNLKeWYrNSYuMrbAGcp6MhXwWfeezk', + ); + + const transaction = new Transaction(); + transaction.add( + ComputeBudgetProgram.setComputeUnitLimit({ units: 350000 }), + ); + transaction.add( + ComputeBudgetProgram.setComputeUnitPrice({ microLamports: 1 }), + ); + transaction.add( + StakeProgram.withdraw({ + stakePubkey: stakeAccount, + authorizedPubkey: userPubkey, + toPubkey: userPubkey, + lamports: 100000000, + }), + ); + // Add a non-Withdraw instruction + transaction.add( + SystemProgram.transfer({ + fromPubkey: userPubkey, + toPubkey: userPubkey, + lamports: 1000, + }), + ); + + transaction.recentBlockhash = '11111111111111111111111111111111'; + transaction.feePayer = userPubkey; + const txHex = transaction + .serialize({ requireAllSignatures: false, verifySignatures: false }) + .toString('hex'); + + const result = shield.validate({ + yieldId, + unsignedTransaction: txHex, + userAddress, + }); + + expect(result.isValid).toBe(false); + expect(result.reason).toContain('No matching operation pattern found'); + expect( + result.details?.attempts?.some((attempt) => + attempt.reason?.includes('Missing or invalid Withdraw instruction'), + ), + ).toBe(true); + }); + + it('should accept valid WITHDRAW_ALL transaction', () => { + const userPubkey = new PublicKey(userAddress); + const stakeAccount = new PublicKey( + 'HzcH95P8DJnmjWfNLKeWYrNSYuMrbAGcp6MhXwWfeezk', + ); + + const transaction = new Transaction(); + transaction.add( + ComputeBudgetProgram.setComputeUnitLimit({ units: 350000 }), + ); + transaction.add( + ComputeBudgetProgram.setComputeUnitPrice({ microLamports: 1 }), + ); + Array.from({ length: 10 }, (_, i) => + transaction.add( + StakeProgram.withdraw({ + stakePubkey: stakeAccount, + authorizedPubkey: userPubkey, + toPubkey: userPubkey, + lamports: 100000000 + i * 100, + }), + ), + ); + + transaction.recentBlockhash = '11111111111111111111111111111111'; + transaction.feePayer = userPubkey; + const txHex = transaction + .serialize({ requireAllSignatures: false, verifySignatures: false }) + .toString('hex'); + + const result = shield.validate({ + yieldId, + unsignedTransaction: txHex, + userAddress, + }); + + expect(result.isValid).toBe(true); + expect(result.detectedType).toBe(TransactionType.WITHDRAW_ALL); + }); + + it('should reject WITHDRAW_ALL to wrong toPubkey recipient', () => { + const userPubkey = new PublicKey(userAddress); + const maliciousRecipient = new PublicKey( + '2ejUissotvQJda8tnD9iqYbdSuz6Gv6dxDnZE8hEKwr5', + ); + const stakeAccount = new PublicKey( + 'HzcH95P8DJnmjWfNLKeWYrNSYuMrbAGcp6MhXwWfeezk', + ); + + const transaction = new Transaction(); + transaction.add( + ComputeBudgetProgram.setComputeUnitLimit({ units: 350000 }), + ); + transaction.add( + ComputeBudgetProgram.setComputeUnitPrice({ microLamports: 1 }), + ); + Array.from({ length: 10 }, (_, i) => + transaction.add( + StakeProgram.withdraw({ + stakePubkey: stakeAccount, authorizedPubkey: userPubkey, toPubkey: i === 5 ? maliciousRecipient : userPubkey, lamports: 100000000 + i * 100, @@ -1565,14 +2062,434 @@ describe('SolanaNativeStakingValidator via Shield', () => { expect(result.reason).toContain('No matching operation pattern found'); }); - it('should reject WITHDRAW_ALL to wrong authorizedPubkey recipient', () => { + it('should reject WITHDRAW_ALL to wrong authorizedPubkey recipient', () => { + const userPubkey = new PublicKey(userAddress); + const maliciousRecipient = new PublicKey( + '2ejUissotvQJda8tnD9iqYbdSuz6Gv6dxDnZE8hEKwr5', + ); + const stakeAccount = new PublicKey( + 'HzcH95P8DJnmjWfNLKeWYrNSYuMrbAGcp6MhXwWfeezk', + ); + + const transaction = new Transaction(); + transaction.add( + ComputeBudgetProgram.setComputeUnitLimit({ units: 350000 }), + ); + transaction.add( + ComputeBudgetProgram.setComputeUnitPrice({ microLamports: 1 }), + ); + Array.from({ length: 10 }, (_, i) => + transaction.add( + StakeProgram.withdraw({ + stakePubkey: stakeAccount, + authorizedPubkey: i === 5 ? maliciousRecipient : userPubkey, + toPubkey: userPubkey, + lamports: 100000000 + i * 100, + }), + ), + ); + + transaction.recentBlockhash = '11111111111111111111111111111111'; + transaction.feePayer = userPubkey; + const txHex = transaction + .serialize({ requireAllSignatures: false, verifySignatures: false }) + .toString('hex'); + + const result = shield.validate({ + yieldId, + unsignedTransaction: txHex, + userAddress, + }); + + expect(result.isValid).toBe(false); + expect(result.reason).toContain('No matching operation pattern found'); + }); + }); + + describe('SPLIT validation', () => { + it('should reject SPLIT with wrong instruction count', () => { + const userPubkey = new PublicKey(userAddress); + const sourceStake = new PublicKey( + 'HzcH95P8DJnmjWfNLKeWYrNSYuMrbAGcp6MhXwWfeezk', + ); + const newStake = new PublicKey( + '9ZmDXFKKaLb5ct3cqbfqHzJPaag4KbZdk3HgAVfCWpMc', + ); + + // Only 5 instructions instead of 6 + const transaction = new Transaction(); + transaction.add( + ComputeBudgetProgram.setComputeUnitLimit({ units: 350000 }), + ); + transaction.add( + ComputeBudgetProgram.setComputeUnitPrice({ microLamports: 1 }), + ); + + const splitWithSeedTx = StakeProgram.splitWithSeed( + { + stakePubkey: sourceStake, + authorizedPubkey: userPubkey, + splitStakePubkey: newStake, + basePubkey: userPubkey, + seed: 'split', + lamports: 50000000, + }, + 2282880, + ); + + transaction.add(...splitWithSeedTx.instructions); + // Missing Deactivate instruction + + transaction.recentBlockhash = '11111111111111111111111111111111'; + transaction.feePayer = userPubkey; + const txHex = transaction + .serialize({ requireAllSignatures: false, verifySignatures: false }) + .toString('hex'); + + const result = shield.validate({ + yieldId, + unsignedTransaction: txHex, + userAddress, + }); + + expect(result.isValid).toBe(false); + expect(result.reason).toContain('No matching operation pattern found'); + expect( + result.details?.attempts?.some((attempt) => + attempt.reason?.includes('Invalid instruction count for SPLIT'), + ), + ).toBe(true); + }); + + it('should reject SPLIT with missing SetComputeUnitLimit', () => { + const userPubkey = new PublicKey(userAddress); + const sourceStake = new PublicKey( + 'HzcH95P8DJnmjWfNLKeWYrNSYuMrbAGcp6MhXwWfeezk', + ); + const newStake = new PublicKey( + '9ZmDXFKKaLb5ct3cqbfqHzJPaag4KbZdk3HgAVfCWpMc', + ); + + const transaction = new Transaction(); + // Missing SetComputeUnitLimit + transaction.add( + ComputeBudgetProgram.setComputeUnitPrice({ microLamports: 1 }), + ); + + const splitWithSeedTx = StakeProgram.splitWithSeed( + { + stakePubkey: sourceStake, + authorizedPubkey: userPubkey, + splitStakePubkey: newStake, + basePubkey: userPubkey, + seed: 'split', + lamports: 50000000, + }, + 2282880, + ); + + transaction.add(...splitWithSeedTx.instructions); + transaction.add( + StakeProgram.deactivate({ + stakePubkey: newStake, + authorizedPubkey: userPubkey, + }), + StakeProgram.deactivate({ + stakePubkey: newStake, + authorizedPubkey: userPubkey, + }), + ); + + transaction.recentBlockhash = '11111111111111111111111111111111'; + transaction.feePayer = userPubkey; + const txHex = transaction + .serialize({ requireAllSignatures: false, verifySignatures: false }) + .toString('hex'); + + const result = shield.validate({ + yieldId, + unsignedTransaction: txHex, + userAddress, + }); + + expect(result.isValid).toBe(false); + expect(result.reason).toContain('No matching operation pattern found'); + expect( + result.details?.attempts?.some((attempt) => + attempt.reason?.includes('Missing or invalid SetComputeUnitLimit'), + ), + ).toBe(true); + }); + + it('should reject SPLIT with missing SetComputeUnitPrice', () => { + const userPubkey = new PublicKey(userAddress); + const sourceStake = new PublicKey( + 'HzcH95P8DJnmjWfNLKeWYrNSYuMrbAGcp6MhXwWfeezk', + ); + const newStake = new PublicKey( + '9ZmDXFKKaLb5ct3cqbfqHzJPaag4KbZdk3HgAVfCWpMc', + ); + + const transaction = new Transaction(); + transaction.add( + ComputeBudgetProgram.setComputeUnitLimit({ units: 350000 }), + ); + // Missing SetComputeUnitPrice + + const splitWithSeedTx = StakeProgram.splitWithSeed( + { + stakePubkey: sourceStake, + authorizedPubkey: userPubkey, + splitStakePubkey: newStake, + basePubkey: userPubkey, + seed: 'split', + lamports: 50000000, + }, + 2282880, + ); + + transaction.add(...splitWithSeedTx.instructions); + transaction.add( + StakeProgram.deactivate({ + stakePubkey: newStake, + authorizedPubkey: userPubkey, + }), + StakeProgram.deactivate({ + stakePubkey: newStake, + authorizedPubkey: userPubkey, + }), + ); + + transaction.recentBlockhash = '11111111111111111111111111111111'; + transaction.feePayer = userPubkey; + const txHex = transaction + .serialize({ requireAllSignatures: false, verifySignatures: false }) + .toString('hex'); + + const result = shield.validate({ + yieldId, + unsignedTransaction: txHex, + userAddress, + }); + + expect(result.isValid).toBe(false); + expect(result.reason).toContain('No matching operation pattern found'); + expect( + result.details?.attempts?.some((attempt) => + attempt.reason?.includes('Missing or invalid SetComputeUnitPrice'), + ), + ).toBe(true); + }); + + it('should reject SPLIT with missing AllocateWithSeed', () => { + const userPubkey = new PublicKey(userAddress); + const sourceStake = new PublicKey( + 'HzcH95P8DJnmjWfNLKeWYrNSYuMrbAGcp6MhXwWfeezk', + ); + const newStake = new PublicKey( + '9ZmDXFKKaLb5ct3cqbfqHzJPaag4KbZdk3HgAVfCWpMc', + ); + + const transaction = new Transaction(); + transaction.add( + ComputeBudgetProgram.setComputeUnitLimit({ units: 350000 }), + ); + transaction.add( + ComputeBudgetProgram.setComputeUnitPrice({ microLamports: 1 }), + ); + + // Add wrong instruction instead of AllocateWithSeed + transaction.add( + SystemProgram.transfer({ + fromPubkey: userPubkey, + toPubkey: newStake, + lamports: 2282880, + }), + ); + + const splitWithSeedTx = StakeProgram.splitWithSeed( + { + stakePubkey: sourceStake, + authorizedPubkey: userPubkey, + splitStakePubkey: newStake, + basePubkey: userPubkey, + seed: 'split', + lamports: 50000000, + }, + 2282880, + ); + + transaction.add(splitWithSeedTx.instructions[1]); // Split instruction + transaction.add(splitWithSeedTx.instructions[2]); // Transfer instruction + transaction.add( + StakeProgram.deactivate({ + stakePubkey: newStake, + authorizedPubkey: userPubkey, + }), + ); + + transaction.recentBlockhash = '11111111111111111111111111111111'; + transaction.feePayer = userPubkey; + const txHex = transaction + .serialize({ requireAllSignatures: false, verifySignatures: false }) + .toString('hex'); + + const result = shield.validate({ + yieldId, + unsignedTransaction: txHex, + userAddress, + }); + + expect(result.isValid).toBe(false); + expect(result.reason).toContain('No matching operation pattern found'); + expect( + result.details?.attempts?.some((attempt) => + attempt.reason?.includes('Missing or invalid AllocateWithSeed'), + ), + ).toBe(true); + }); + + it('should reject SPLIT with AllocateWithSeed source not user address', () => { + const userPubkey = new PublicKey(userAddress); + const maliciousPubkey = new PublicKey( + '2ejUissotvQJda8tnD9iqYbdSuz6Gv6dxDnZE8hEKwr5', + ); + const sourceStake = new PublicKey( + 'HzcH95P8DJnmjWfNLKeWYrNSYuMrbAGcp6MhXwWfeezk', + ); + const newStake = new PublicKey( + '9ZmDXFKKaLb5ct3cqbfqHzJPaag4KbZdk3HgAVfCWpMc', + ); + + const transaction = new Transaction(); + transaction.add( + ComputeBudgetProgram.setComputeUnitLimit({ units: 350000 }), + ); + transaction.add( + ComputeBudgetProgram.setComputeUnitPrice({ microLamports: 1 }), + ); + + // Create AllocateWithSeed with wrong source + const splitWithSeedTx = StakeProgram.splitWithSeed( + { + stakePubkey: sourceStake, + authorizedPubkey: userPubkey, + splitStakePubkey: newStake, + basePubkey: maliciousPubkey, + seed: 'split', + lamports: 50000000, + }, + 2282880, + ); + + transaction.add(splitWithSeedTx.instructions[0]); // AllocateWithSeed + transaction.add(splitWithSeedTx.instructions[1]); // Split instruction + transaction.add(splitWithSeedTx.instructions[2]); // Transfer instruction + transaction.add( + StakeProgram.deactivate({ + stakePubkey: newStake, + authorizedPubkey: userPubkey, + }), + ); + + transaction.recentBlockhash = '11111111111111111111111111111111'; + transaction.feePayer = userPubkey; + const txHex = transaction + .serialize({ requireAllSignatures: false, verifySignatures: false }) + .toString('hex'); + + const result = shield.validate({ + yieldId, + unsignedTransaction: txHex, + userAddress, + }); + + expect(result.isValid).toBe(false); + expect(result.reason).toContain('No matching operation pattern found'); + expect( + result.details?.attempts?.some((attempt) => + attempt.reason?.includes( + 'AllocateWithSeed source is not user address', + ), + ), + ).toBe(true); + }); + + it('should reject SPLIT with missing Transfer', () => { + const userPubkey = new PublicKey(userAddress); + const sourceStake = new PublicKey( + 'HzcH95P8DJnmjWfNLKeWYrNSYuMrbAGcp6MhXwWfeezk', + ); + const newStake = new PublicKey( + '9ZmDXFKKaLb5ct3cqbfqHzJPaag4KbZdk3HgAVfCWpMc', + ); + + const transaction = new Transaction(); + transaction.add( + ComputeBudgetProgram.setComputeUnitLimit({ units: 350000 }), + ); + transaction.add( + ComputeBudgetProgram.setComputeUnitPrice({ microLamports: 1 }), + ); + + const splitWithSeedTx = StakeProgram.splitWithSeed( + { + stakePubkey: sourceStake, + authorizedPubkey: userPubkey, + splitStakePubkey: newStake, + basePubkey: userPubkey, + seed: 'split', + lamports: 50000000, + }, + 2282880, + ); + + transaction.add(splitWithSeedTx.instructions[0]); // AllocateWithSeed + // Skip Transfer instruction + transaction.add(splitWithSeedTx.instructions[2]); // Split instruction + transaction.add( + StakeProgram.deactivate({ + stakePubkey: newStake, + authorizedPubkey: userPubkey, + }), + StakeProgram.deactivate({ + stakePubkey: newStake, + authorizedPubkey: userPubkey, + }), + ); + + transaction.recentBlockhash = '11111111111111111111111111111111'; + transaction.feePayer = userPubkey; + const txHex = transaction + .serialize({ requireAllSignatures: false, verifySignatures: false }) + .toString('hex'); + + const result = shield.validate({ + yieldId, + unsignedTransaction: txHex, + userAddress, + }); + + expect(result.isValid).toBe(false); + expect(result.reason).toContain('No matching operation pattern found'); + expect( + result.details?.attempts?.some((attempt) => + attempt.reason?.includes('Missing or invalid Transfer'), + ), + ).toBe(true); + }); + + it('should reject SPLIT with Transfer not from user address', () => { const userPubkey = new PublicKey(userAddress); - const maliciousRecipient = new PublicKey( + const maliciousPubkey = new PublicKey( '2ejUissotvQJda8tnD9iqYbdSuz6Gv6dxDnZE8hEKwr5', ); - const stakeAccount = new PublicKey( + const sourceStake = new PublicKey( 'HzcH95P8DJnmjWfNLKeWYrNSYuMrbAGcp6MhXwWfeezk', ); + const newStake = new PublicKey( + '9ZmDXFKKaLb5ct3cqbfqHzJPaag4KbZdk3HgAVfCWpMc', + ); const transaction = new Transaction(); transaction.add( @@ -1581,15 +2498,33 @@ describe('SolanaNativeStakingValidator via Shield', () => { transaction.add( ComputeBudgetProgram.setComputeUnitPrice({ microLamports: 1 }), ); - Array.from({ length: 10 }, (_, i) => - transaction.add( - StakeProgram.withdraw({ - stakePubkey: stakeAccount, - authorizedPubkey: i === 5 ? maliciousRecipient : userPubkey, - toPubkey: userPubkey, - lamports: 100000000 + i * 100, - }), - ), + + const splitWithSeedTx = StakeProgram.splitWithSeed( + { + stakePubkey: sourceStake, + authorizedPubkey: userPubkey, + splitStakePubkey: newStake, + basePubkey: userPubkey, + seed: 'split', + lamports: 50000000, + }, + 2282880, + ); + + transaction.add(splitWithSeedTx.instructions[0]); // AllocateWithSeed + transaction.add( + SystemProgram.transfer({ + fromPubkey: maliciousPubkey, // Wrong source! + toPubkey: newStake, + lamports: 2282880, + }), + ); + transaction.add(splitWithSeedTx.instructions[2]); // Split instruction + transaction.add( + StakeProgram.deactivate({ + stakePubkey: newStake, + authorizedPubkey: userPubkey, + }), ); transaction.recentBlockhash = '11111111111111111111111111111111'; @@ -1606,11 +2541,14 @@ describe('SolanaNativeStakingValidator via Shield', () => { expect(result.isValid).toBe(false); expect(result.reason).toContain('No matching operation pattern found'); + expect( + result.details?.attempts?.some((attempt) => + attempt.reason?.includes('Transfer not from user address'), + ), + ).toBe(true); }); - }); - describe('SPLIT validation', () => { - it('should reject SPLIT with wrong instruction count', () => { + it('should reject SPLIT with Transfer recipient mismatch', () => { const userPubkey = new PublicKey(userAddress); const sourceStake = new PublicKey( 'HzcH95P8DJnmjWfNLKeWYrNSYuMrbAGcp6MhXwWfeezk', @@ -1618,8 +2556,10 @@ describe('SolanaNativeStakingValidator via Shield', () => { const newStake = new PublicKey( '9ZmDXFKKaLb5ct3cqbfqHzJPaag4KbZdk3HgAVfCWpMc', ); + const wrongRecipient = new PublicKey( + '2ejUissotvQJda8tnD9iqYbdSuz6Gv6dxDnZE8hEKwr5', + ); - // Only 5 instructions instead of 6 const transaction = new Transaction(); transaction.add( ComputeBudgetProgram.setComputeUnitLimit({ units: 350000 }), @@ -1640,8 +2580,21 @@ describe('SolanaNativeStakingValidator via Shield', () => { 2282880, ); - transaction.add(...splitWithSeedTx.instructions); - // Missing Deactivate instruction + transaction.add(splitWithSeedTx.instructions[0]); // AllocateWithSeed + transaction.add( + SystemProgram.transfer({ + fromPubkey: userPubkey, + toPubkey: wrongRecipient, // Wrong recipient! + lamports: 2282880, + }), + ); + transaction.add(splitWithSeedTx.instructions[2]); // Split instruction + transaction.add( + StakeProgram.deactivate({ + stakePubkey: newStake, + authorizedPubkey: userPubkey, + }), + ); transaction.recentBlockhash = '11111111111111111111111111111111'; transaction.feePayer = userPubkey; @@ -1659,12 +2612,14 @@ describe('SolanaNativeStakingValidator via Shield', () => { expect(result.reason).toContain('No matching operation pattern found'); expect( result.details?.attempts?.some((attempt) => - attempt.reason?.includes('Invalid instruction count for SPLIT'), + attempt.reason?.includes( + 'Transfer recipient does not match new stake account', + ), ), ).toBe(true); }); - it('should reject SPLIT with missing SetComputeUnitLimit', () => { + it('should reject SPLIT with missing Split instruction', () => { const userPubkey = new PublicKey(userAddress); const sourceStake = new PublicKey( 'HzcH95P8DJnmjWfNLKeWYrNSYuMrbAGcp6MhXwWfeezk', @@ -1674,7 +2629,9 @@ describe('SolanaNativeStakingValidator via Shield', () => { ); const transaction = new Transaction(); - // Missing SetComputeUnitLimit + transaction.add( + ComputeBudgetProgram.setComputeUnitLimit({ units: 350000 }), + ); transaction.add( ComputeBudgetProgram.setComputeUnitPrice({ microLamports: 1 }), ); @@ -1691,7 +2648,9 @@ describe('SolanaNativeStakingValidator via Shield', () => { 2282880, ); - transaction.add(...splitWithSeedTx.instructions); + transaction.add(splitWithSeedTx.instructions[0]); // AllocateWithSeed + transaction.add(splitWithSeedTx.instructions[1]); // Transfer + // Skip Split instruction transaction.add( StakeProgram.deactivate({ stakePubkey: newStake, @@ -1719,12 +2678,12 @@ describe('SolanaNativeStakingValidator via Shield', () => { expect(result.reason).toContain('No matching operation pattern found'); expect( result.details?.attempts?.some((attempt) => - attempt.reason?.includes('Missing or invalid SetComputeUnitLimit'), + attempt.reason?.includes('Missing or invalid Split instruction'), ), ).toBe(true); }); - it('should reject SPLIT with missing SetComputeUnitPrice', () => { + it('should reject SPLIT with Split stake account mismatch', () => { const userPubkey = new PublicKey(userAddress); const sourceStake = new PublicKey( 'HzcH95P8DJnmjWfNLKeWYrNSYuMrbAGcp6MhXwWfeezk', @@ -1732,12 +2691,17 @@ describe('SolanaNativeStakingValidator via Shield', () => { const newStake = new PublicKey( '9ZmDXFKKaLb5ct3cqbfqHzJPaag4KbZdk3HgAVfCWpMc', ); + const wrongStake = new PublicKey( + '2ejUissotvQJda8tnD9iqYbdSuz6Gv6dxDnZE8hEKwr5', + ); const transaction = new Transaction(); transaction.add( ComputeBudgetProgram.setComputeUnitLimit({ units: 350000 }), ); - // Missing SetComputeUnitPrice + transaction.add( + ComputeBudgetProgram.setComputeUnitPrice({ microLamports: 1 }), + ); const splitWithSeedTx = StakeProgram.splitWithSeed( { @@ -1751,12 +2715,19 @@ describe('SolanaNativeStakingValidator via Shield', () => { 2282880, ); - transaction.add(...splitWithSeedTx.instructions); - transaction.add( - StakeProgram.deactivate({ + transaction.add(splitWithSeedTx.instructions[0]); // AllocateWithSeed + transaction.add(splitWithSeedTx.instructions[1]); // Transfer + const fakeSplit = StakeProgram.split( + { stakePubkey: newStake, authorizedPubkey: userPubkey, - }), + splitStakePubkey: wrongStake, + lamports: 50000000, + }, + 123, + ); + transaction.add(fakeSplit.instructions[1]); // Split instruction + transaction.add( StakeProgram.deactivate({ stakePubkey: newStake, authorizedPubkey: userPubkey, @@ -1779,13 +2750,18 @@ describe('SolanaNativeStakingValidator via Shield', () => { expect(result.reason).toContain('No matching operation pattern found'); expect( result.details?.attempts?.some((attempt) => - attempt.reason?.includes('Missing or invalid SetComputeUnitPrice'), + attempt.reason?.includes( + 'Split stake account does not match new stake account', + ), ), ).toBe(true); }); - it('should reject SPLIT with missing AllocateWithSeed', () => { + it('should reject SPLIT with Split authority not user address', () => { const userPubkey = new PublicKey(userAddress); + const maliciousPubkey = new PublicKey( + '2ejUissotvQJda8tnD9iqYbdSuz6Gv6dxDnZE8hEKwr5', + ); const sourceStake = new PublicKey( 'HzcH95P8DJnmjWfNLKeWYrNSYuMrbAGcp6MhXwWfeezk', ); @@ -1801,15 +2777,6 @@ describe('SolanaNativeStakingValidator via Shield', () => { ComputeBudgetProgram.setComputeUnitPrice({ microLamports: 1 }), ); - // Add wrong instruction instead of AllocateWithSeed - transaction.add( - SystemProgram.transfer({ - fromPubkey: userPubkey, - toPubkey: newStake, - lamports: 2282880, - }), - ); - const splitWithSeedTx = StakeProgram.splitWithSeed( { stakePubkey: sourceStake, @@ -1822,8 +2789,19 @@ describe('SolanaNativeStakingValidator via Shield', () => { 2282880, ); - transaction.add(splitWithSeedTx.instructions[1]); // Split instruction - transaction.add(splitWithSeedTx.instructions[2]); // Transfer instruction + transaction.add(splitWithSeedTx.instructions[0]); // AllocateWithSeed + transaction.add(splitWithSeedTx.instructions[1]); // Transfer + // Use the correct split instruction from splitWithSeedTx + const fakeSplit = StakeProgram.split( + { + stakePubkey: newStake, + authorizedPubkey: maliciousPubkey, + splitStakePubkey: newStake, + lamports: 50000000, + }, + 123, + ); + transaction.add(fakeSplit.instructions[1]); // Split instruction transaction.add( StakeProgram.deactivate({ stakePubkey: newStake, @@ -1847,22 +2825,22 @@ describe('SolanaNativeStakingValidator via Shield', () => { expect(result.reason).toContain('No matching operation pattern found'); expect( result.details?.attempts?.some((attempt) => - attempt.reason?.includes('Missing or invalid AllocateWithSeed'), + attempt.reason?.includes('Split authority is not user address'), ), ).toBe(true); }); - it('should reject SPLIT with AllocateWithSeed source not user address', () => { + it('should reject SPLIT with Transfer recipient not matching Split stake account', () => { const userPubkey = new PublicKey(userAddress); - const maliciousPubkey = new PublicKey( - '2ejUissotvQJda8tnD9iqYbdSuz6Gv6dxDnZE8hEKwr5', - ); const sourceStake = new PublicKey( 'HzcH95P8DJnmjWfNLKeWYrNSYuMrbAGcp6MhXwWfeezk', ); const newStake = new PublicKey( '9ZmDXFKKaLb5ct3cqbfqHzJPaag4KbZdk3HgAVfCWpMc', ); + const wrongStake = new PublicKey( + '2ejUissotvQJda8tnD9iqYbdSuz6Gv6dxDnZE8hEKwr5', + ); const transaction = new Transaction(); transaction.add( @@ -1872,13 +2850,12 @@ describe('SolanaNativeStakingValidator via Shield', () => { ComputeBudgetProgram.setComputeUnitPrice({ microLamports: 1 }), ); - // Create AllocateWithSeed with wrong source const splitWithSeedTx = StakeProgram.splitWithSeed( { stakePubkey: sourceStake, authorizedPubkey: userPubkey, splitStakePubkey: newStake, - basePubkey: maliciousPubkey, + basePubkey: userPubkey, seed: 'split', lamports: 50000000, }, @@ -1886,8 +2863,15 @@ describe('SolanaNativeStakingValidator via Shield', () => { ); transaction.add(splitWithSeedTx.instructions[0]); // AllocateWithSeed - transaction.add(splitWithSeedTx.instructions[1]); // Split instruction - transaction.add(splitWithSeedTx.instructions[2]); // Transfer instruction + transaction.add( + SystemProgram.transfer({ + fromPubkey: userPubkey, + toPubkey: wrongStake, // Wrong recipient! + lamports: 2282880, + }), + ); + // Use the correct split instruction from splitWithSeedTx + transaction.add(splitWithSeedTx.instructions[2]); // Split instruction transaction.add( StakeProgram.deactivate({ stakePubkey: newStake, @@ -1912,13 +2896,13 @@ describe('SolanaNativeStakingValidator via Shield', () => { expect( result.details?.attempts?.some((attempt) => attempt.reason?.includes( - 'AllocateWithSeed source is not user address', + 'Transfer recipient does not match new stake account', ), ), ).toBe(true); }); - it('should reject SPLIT with missing Transfer', () => { + it('should reject SPLIT with missing Deactivate instruction', () => { const userPubkey = new PublicKey(userAddress); const sourceStake = new PublicKey( 'HzcH95P8DJnmjWfNLKeWYrNSYuMrbAGcp6MhXwWfeezk', @@ -1947,16 +2931,67 @@ describe('SolanaNativeStakingValidator via Shield', () => { 2282880, ); - transaction.add(splitWithSeedTx.instructions[0]); // AllocateWithSeed - // Skip Transfer instruction - transaction.add(splitWithSeedTx.instructions[2]); // Split instruction + transaction.add(...splitWithSeedTx.instructions); + transaction.add(splitWithSeedTx.instructions[0]); + // Missing Deactivate instruction + + transaction.recentBlockhash = '11111111111111111111111111111111'; + transaction.feePayer = userPubkey; + const txHex = transaction + .serialize({ requireAllSignatures: false, verifySignatures: false }) + .toString('hex'); + + const result = shield.validate({ + yieldId, + unsignedTransaction: txHex, + userAddress, + }); + + expect(result.isValid).toBe(false); + expect(result.reason).toContain('No matching operation pattern found'); + expect( + result.details?.attempts?.some((attempt) => + attempt.reason?.includes('Missing or invalid Deactivate instruction'), + ), + ).toBe(true); + }); + + it('should reject SPLIT with Deactivate stake account mismatch', () => { + const userPubkey = new PublicKey(userAddress); + const sourceStake = new PublicKey( + 'HzcH95P8DJnmjWfNLKeWYrNSYuMrbAGcp6MhXwWfeezk', + ); + const newStake = new PublicKey( + '9ZmDXFKKaLb5ct3cqbfqHzJPaag4KbZdk3HgAVfCWpMc', + ); + const wrongStake = new PublicKey( + '2ejUissotvQJda8tnD9iqYbdSuz6Gv6dxDnZE8hEKwr5', + ); + + const transaction = new Transaction(); transaction.add( - StakeProgram.deactivate({ - stakePubkey: newStake, + ComputeBudgetProgram.setComputeUnitLimit({ units: 350000 }), + ); + transaction.add( + ComputeBudgetProgram.setComputeUnitPrice({ microLamports: 1 }), + ); + + const splitWithSeedTx = StakeProgram.splitWithSeed( + { + stakePubkey: sourceStake, authorizedPubkey: userPubkey, - }), + splitStakePubkey: newStake, + basePubkey: userPubkey, + seed: 'split', + lamports: 50000000, + }, + 2282880, + ); + + transaction.add(...splitWithSeedTx.instructions); + transaction.add( StakeProgram.deactivate({ - stakePubkey: newStake, + stakePubkey: wrongStake, // Wrong stake account! authorizedPubkey: userPubkey, }), ); @@ -1977,12 +3012,14 @@ describe('SolanaNativeStakingValidator via Shield', () => { expect(result.reason).toContain('No matching operation pattern found'); expect( result.details?.attempts?.some((attempt) => - attempt.reason?.includes('Missing or invalid Transfer'), + attempt.reason?.includes( + 'Deactivate stake account does not match new stake account', + ), ), ).toBe(true); }); - it('should reject SPLIT with Transfer not from user address', () => { + it('should reject SPLIT with Deactivate authority not user address', () => { const userPubkey = new PublicKey(userAddress); const maliciousPubkey = new PublicKey( '2ejUissotvQJda8tnD9iqYbdSuz6Gv6dxDnZE8hEKwr5', @@ -2014,19 +3051,11 @@ describe('SolanaNativeStakingValidator via Shield', () => { 2282880, ); - transaction.add(splitWithSeedTx.instructions[0]); // AllocateWithSeed - transaction.add( - SystemProgram.transfer({ - fromPubkey: maliciousPubkey, // Wrong source! - toPubkey: newStake, - lamports: 2282880, - }), - ); - transaction.add(splitWithSeedTx.instructions[2]); // Split instruction + transaction.add(...splitWithSeedTx.instructions); transaction.add( StakeProgram.deactivate({ stakePubkey: newStake, - authorizedPubkey: userPubkey, + authorizedPubkey: maliciousPubkey, // Wrong authority! }), ); @@ -2046,12 +3075,12 @@ describe('SolanaNativeStakingValidator via Shield', () => { expect(result.reason).toContain('No matching operation pattern found'); expect( result.details?.attempts?.some((attempt) => - attempt.reason?.includes('Transfer not from user address'), + attempt.reason?.includes('Deactivate authority is not user address'), ), ).toBe(true); }); - it('should reject SPLIT with Transfer recipient mismatch', () => { + it('should accept valid SPLIT transaction', () => { const userPubkey = new PublicKey(userAddress); const sourceStake = new PublicKey( 'HzcH95P8DJnmjWfNLKeWYrNSYuMrbAGcp6MhXwWfeezk', @@ -2059,9 +3088,6 @@ describe('SolanaNativeStakingValidator via Shield', () => { const newStake = new PublicKey( '9ZmDXFKKaLb5ct3cqbfqHzJPaag4KbZdk3HgAVfCWpMc', ); - const wrongRecipient = new PublicKey( - '2ejUissotvQJda8tnD9iqYbdSuz6Gv6dxDnZE8hEKwr5', - ); const transaction = new Transaction(); transaction.add( @@ -2083,15 +3109,7 @@ describe('SolanaNativeStakingValidator via Shield', () => { 2282880, ); - transaction.add(splitWithSeedTx.instructions[0]); // AllocateWithSeed - transaction.add( - SystemProgram.transfer({ - fromPubkey: userPubkey, - toPubkey: wrongRecipient, // Wrong recipient! - lamports: 2282880, - }), - ); - transaction.add(splitWithSeedTx.instructions[2]); // Split instruction + transaction.add(...splitWithSeedTx.instructions); transaction.add( StakeProgram.deactivate({ stakePubkey: newStake, @@ -2111,22 +3129,18 @@ describe('SolanaNativeStakingValidator via Shield', () => { userAddress, }); - expect(result.isValid).toBe(false); - expect(result.reason).toContain('No matching operation pattern found'); - expect( - result.details?.attempts?.some((attempt) => - attempt.reason?.includes( - 'Transfer recipient does not match new stake account', - ), - ), - ).toBe(true); + expect(result.isValid).toBe(true); + expect(result.detectedType).toBe(TransactionType.SPLIT); }); - it('should reject SPLIT with missing Split instruction', () => { + it('should accept SPLIT with 1 leading Merge', () => { const userPubkey = new PublicKey(userAddress); const sourceStake = new PublicKey( 'HzcH95P8DJnmjWfNLKeWYrNSYuMrbAGcp6MhXwWfeezk', ); + const mergeSource = new PublicKey( + '2ejUissotvQJda8tnD9iqYbdSuz6Gv6dxDnZE8hEKwr5', + ); const newStake = new PublicKey( '9ZmDXFKKaLb5ct3cqbfqHzJPaag4KbZdk3HgAVfCWpMc', ); @@ -2138,6 +3152,13 @@ describe('SolanaNativeStakingValidator via Shield', () => { transaction.add( ComputeBudgetProgram.setComputeUnitPrice({ microLamports: 1 }), ); + transaction.add( + StakeProgram.merge({ + stakePubkey: sourceStake, + sourceStakePubKey: mergeSource, + authorizedPubkey: userPubkey, + }), + ); const splitWithSeedTx = StakeProgram.splitWithSeed( { @@ -2150,19 +3171,12 @@ describe('SolanaNativeStakingValidator via Shield', () => { }, 2282880, ); - - transaction.add(splitWithSeedTx.instructions[0]); // AllocateWithSeed - transaction.add(splitWithSeedTx.instructions[1]); // Transfer - // Skip Split instruction + transaction.add(...splitWithSeedTx.instructions); transaction.add( StakeProgram.deactivate({ stakePubkey: newStake, authorizedPubkey: userPubkey, }), - StakeProgram.deactivate({ - stakePubkey: newStake, - authorizedPubkey: userPubkey, - }), ); transaction.recentBlockhash = '11111111111111111111111111111111'; @@ -2177,26 +3191,22 @@ describe('SolanaNativeStakingValidator via Shield', () => { userAddress, }); - expect(result.isValid).toBe(false); - expect(result.reason).toContain('No matching operation pattern found'); - expect( - result.details?.attempts?.some((attempt) => - attempt.reason?.includes('Missing or invalid Split instruction'), - ), - ).toBe(true); + expect(result.isValid).toBe(true); + expect(result.detectedType).toBe(TransactionType.SPLIT); }); - it('should reject SPLIT with Split stake account mismatch', () => { + it('should accept SPLIT with 2 leading Merges', () => { const userPubkey = new PublicKey(userAddress); const sourceStake = new PublicKey( 'HzcH95P8DJnmjWfNLKeWYrNSYuMrbAGcp6MhXwWfeezk', ); + const mergeSources = [ + new PublicKey('2ejUissotvQJda8tnD9iqYbdSuz6Gv6dxDnZE8hEKwr5'), + new PublicKey('HaAebbtwqajTNEBJ2ys3yxWJXk6fi7tk7WXpvj6hMEXZ'), + ]; const newStake = new PublicKey( '9ZmDXFKKaLb5ct3cqbfqHzJPaag4KbZdk3HgAVfCWpMc', ); - const wrongStake = new PublicKey( - '2ejUissotvQJda8tnD9iqYbdSuz6Gv6dxDnZE8hEKwr5', - ); const transaction = new Transaction(); transaction.add( @@ -2205,6 +3215,15 @@ describe('SolanaNativeStakingValidator via Shield', () => { transaction.add( ComputeBudgetProgram.setComputeUnitPrice({ microLamports: 1 }), ); + Array.from({ length: mergeSources.length }, (_, i) => + transaction.add( + StakeProgram.merge({ + stakePubkey: sourceStake, + sourceStakePubKey: mergeSources[i], + authorizedPubkey: userPubkey, + }), + ), + ); const splitWithSeedTx = StakeProgram.splitWithSeed( { @@ -2217,19 +3236,7 @@ describe('SolanaNativeStakingValidator via Shield', () => { }, 2282880, ); - - transaction.add(splitWithSeedTx.instructions[0]); // AllocateWithSeed - transaction.add(splitWithSeedTx.instructions[1]); // Transfer - const fakeSplit = StakeProgram.split( - { - stakePubkey: newStake, - authorizedPubkey: userPubkey, - splitStakePubkey: wrongStake, - lamports: 50000000, - }, - 123, - ); - transaction.add(fakeSplit.instructions[1]); // Split instruction + transaction.add(...splitWithSeedTx.instructions); transaction.add( StakeProgram.deactivate({ stakePubkey: newStake, @@ -2249,25 +3256,21 @@ describe('SolanaNativeStakingValidator via Shield', () => { userAddress, }); - expect(result.isValid).toBe(false); - expect(result.reason).toContain('No matching operation pattern found'); - expect( - result.details?.attempts?.some((attempt) => - attempt.reason?.includes( - 'Split stake account does not match new stake account', - ), - ), - ).toBe(true); + expect(result.isValid).toBe(true); + expect(result.detectedType).toBe(TransactionType.SPLIT); }); - it('should reject SPLIT with Split authority not user address', () => { + it('should reject SPLIT with Merge authority not user address', () => { const userPubkey = new PublicKey(userAddress); const maliciousPubkey = new PublicKey( - '2ejUissotvQJda8tnD9iqYbdSuz6Gv6dxDnZE8hEKwr5', + 'BbM5kJgrwEj3tYFfBPnjcARB54wDUHkXmLUTkazUmt2x', ); const sourceStake = new PublicKey( 'HzcH95P8DJnmjWfNLKeWYrNSYuMrbAGcp6MhXwWfeezk', ); + const mergeSource = new PublicKey( + '2ejUissotvQJda8tnD9iqYbdSuz6Gv6dxDnZE8hEKwr5', + ); const newStake = new PublicKey( '9ZmDXFKKaLb5ct3cqbfqHzJPaag4KbZdk3HgAVfCWpMc', ); @@ -2279,6 +3282,13 @@ describe('SolanaNativeStakingValidator via Shield', () => { transaction.add( ComputeBudgetProgram.setComputeUnitPrice({ microLamports: 1 }), ); + transaction.add( + StakeProgram.merge({ + stakePubkey: sourceStake, + sourceStakePubKey: mergeSource, + authorizedPubkey: maliciousPubkey, + }), + ); const splitWithSeedTx = StakeProgram.splitWithSeed( { @@ -2291,20 +3301,7 @@ describe('SolanaNativeStakingValidator via Shield', () => { }, 2282880, ); - - transaction.add(splitWithSeedTx.instructions[0]); // AllocateWithSeed - transaction.add(splitWithSeedTx.instructions[1]); // Transfer - // Use the correct split instruction from splitWithSeedTx - const fakeSplit = StakeProgram.split( - { - stakePubkey: newStake, - authorizedPubkey: maliciousPubkey, - splitStakePubkey: newStake, - lamports: 50000000, - }, - 123, - ); - transaction.add(fakeSplit.instructions[1]); // Split instruction + transaction.add(...splitWithSeedTx.instructions); transaction.add( StakeProgram.deactivate({ stakePubkey: newStake, @@ -2328,22 +3325,27 @@ describe('SolanaNativeStakingValidator via Shield', () => { expect(result.reason).toContain('No matching operation pattern found'); expect( result.details?.attempts?.some((attempt) => - attempt.reason?.includes('Split authority is not user address'), + attempt.reason?.includes( + 'Merge authority for instruction 2 is not user address', + ), ), ).toBe(true); }); - it('should reject SPLIT with Transfer recipient not matching Split stake account', () => { + it('should reject SPLIT when Merge destination does not match Split source', () => { const userPubkey = new PublicKey(userAddress); const sourceStake = new PublicKey( 'HzcH95P8DJnmjWfNLKeWYrNSYuMrbAGcp6MhXwWfeezk', ); - const newStake = new PublicKey( - '9ZmDXFKKaLb5ct3cqbfqHzJPaag4KbZdk3HgAVfCWpMc', + const wrongMergeDestination = new PublicKey( + 'HaAebbtwqajTNEBJ2ys3yxWJXk6fi7tk7WXpvj6hMEXZ', ); - const wrongStake = new PublicKey( + const mergeSource = new PublicKey( '2ejUissotvQJda8tnD9iqYbdSuz6Gv6dxDnZE8hEKwr5', ); + const newStake = new PublicKey( + '9ZmDXFKKaLb5ct3cqbfqHzJPaag4KbZdk3HgAVfCWpMc', + ); const transaction = new Transaction(); transaction.add( @@ -2352,6 +3354,13 @@ describe('SolanaNativeStakingValidator via Shield', () => { transaction.add( ComputeBudgetProgram.setComputeUnitPrice({ microLamports: 1 }), ); + transaction.add( + StakeProgram.merge({ + stakePubkey: wrongMergeDestination, + sourceStakePubKey: mergeSource, + authorizedPubkey: userPubkey, + }), + ); const splitWithSeedTx = StakeProgram.splitWithSeed( { @@ -2364,17 +3373,7 @@ describe('SolanaNativeStakingValidator via Shield', () => { }, 2282880, ); - - transaction.add(splitWithSeedTx.instructions[0]); // AllocateWithSeed - transaction.add( - SystemProgram.transfer({ - fromPubkey: userPubkey, - toPubkey: wrongStake, // Wrong recipient! - lamports: 2282880, - }), - ); - // Use the correct split instruction from splitWithSeedTx - transaction.add(splitWithSeedTx.instructions[2]); // Split instruction + transaction.add(...splitWithSeedTx.instructions); transaction.add( StakeProgram.deactivate({ stakePubkey: newStake, @@ -2399,17 +3398,20 @@ describe('SolanaNativeStakingValidator via Shield', () => { expect( result.details?.attempts?.some((attempt) => attempt.reason?.includes( - 'Transfer recipient does not match new stake account', + 'Merge destination does not match Split source stake account', ), ), ).toBe(true); }); - it('should reject SPLIT with missing Deactivate instruction', () => { + it('should reject SPLIT with Merge accounts outside stakeAccounts allowlist', () => { const userPubkey = new PublicKey(userAddress); const sourceStake = new PublicKey( 'HzcH95P8DJnmjWfNLKeWYrNSYuMrbAGcp6MhXwWfeezk', ); + const mergeSource = new PublicKey( + '2ejUissotvQJda8tnD9iqYbdSuz6Gv6dxDnZE8hEKwr5', + ); const newStake = new PublicKey( '9ZmDXFKKaLb5ct3cqbfqHzJPaag4KbZdk3HgAVfCWpMc', ); @@ -2421,6 +3423,13 @@ describe('SolanaNativeStakingValidator via Shield', () => { transaction.add( ComputeBudgetProgram.setComputeUnitPrice({ microLamports: 1 }), ); + transaction.add( + StakeProgram.merge({ + stakePubkey: sourceStake, + sourceStakePubKey: mergeSource, + authorizedPubkey: userPubkey, + }), + ); const splitWithSeedTx = StakeProgram.splitWithSeed( { @@ -2433,10 +3442,13 @@ describe('SolanaNativeStakingValidator via Shield', () => { }, 2282880, ); - transaction.add(...splitWithSeedTx.instructions); - transaction.add(splitWithSeedTx.instructions[0]); - // Missing Deactivate instruction + transaction.add( + StakeProgram.deactivate({ + stakePubkey: newStake, + authorizedPubkey: userPubkey, + }), + ); transaction.recentBlockhash = '11111111111111111111111111111111'; transaction.feePayer = userPubkey; @@ -2448,28 +3460,33 @@ describe('SolanaNativeStakingValidator via Shield', () => { yieldId, unsignedTransaction: txHex, userAddress, + args: { + stakeAccounts: [sourceStake.toBase58()], + }, }); expect(result.isValid).toBe(false); expect(result.reason).toContain('No matching operation pattern found'); expect( result.details?.attempts?.some((attempt) => - attempt.reason?.includes('Missing or invalid Deactivate instruction'), + attempt.reason?.includes( + 'Merge source for instruction 2 is not in stakeAccounts', + ), ), ).toBe(true); }); - it('should reject SPLIT with Deactivate stake account mismatch', () => { + it('should reject SPLIT when Merge appears after AllocateWithSeed', () => { const userPubkey = new PublicKey(userAddress); const sourceStake = new PublicKey( 'HzcH95P8DJnmjWfNLKeWYrNSYuMrbAGcp6MhXwWfeezk', ); + const mergeSource = new PublicKey( + '2ejUissotvQJda8tnD9iqYbdSuz6Gv6dxDnZE8hEKwr5', + ); const newStake = new PublicKey( '9ZmDXFKKaLb5ct3cqbfqHzJPaag4KbZdk3HgAVfCWpMc', ); - const wrongStake = new PublicKey( - '2ejUissotvQJda8tnD9iqYbdSuz6Gv6dxDnZE8hEKwr5', - ); const transaction = new Transaction(); transaction.add( @@ -2490,11 +3507,19 @@ describe('SolanaNativeStakingValidator via Shield', () => { }, 2282880, ); - - transaction.add(...splitWithSeedTx.instructions); + transaction.add(splitWithSeedTx.instructions[0]); // AllocateWithSeed + transaction.add( + StakeProgram.merge({ + stakePubkey: sourceStake, + sourceStakePubKey: mergeSource, + authorizedPubkey: userPubkey, + }), + ); + transaction.add(splitWithSeedTx.instructions[1]); // Transfer + transaction.add(splitWithSeedTx.instructions[2]); // Split transaction.add( StakeProgram.deactivate({ - stakePubkey: wrongStake, // Wrong stake account! + stakePubkey: newStake, authorizedPubkey: userPubkey, }), ); @@ -2514,22 +3539,23 @@ describe('SolanaNativeStakingValidator via Shield', () => { expect(result.isValid).toBe(false); expect(result.reason).toContain('No matching operation pattern found'); expect( - result.details?.attempts?.some((attempt) => - attempt.reason?.includes( - 'Deactivate stake account does not match new stake account', - ), + result.details?.attempts?.some( + (attempt) => + attempt.reason?.includes( + 'Invalid SPLIT instruction layout after Merge prefix', + ) || attempt.reason?.includes('Missing or invalid Transfer'), ), ).toBe(true); }); - it('should reject SPLIT with Deactivate authority not user address', () => { + it('should reject SPLIT with more than 10 leading Merges', () => { const userPubkey = new PublicKey(userAddress); - const maliciousPubkey = new PublicKey( - '2ejUissotvQJda8tnD9iqYbdSuz6Gv6dxDnZE8hEKwr5', - ); const sourceStake = new PublicKey( 'HzcH95P8DJnmjWfNLKeWYrNSYuMrbAGcp6MhXwWfeezk', ); + const mergeSource = new PublicKey( + '2ejUissotvQJda8tnD9iqYbdSuz6Gv6dxDnZE8hEKwr5', + ); const newStake = new PublicKey( '9ZmDXFKKaLb5ct3cqbfqHzJPaag4KbZdk3HgAVfCWpMc', ); @@ -2541,6 +3567,15 @@ describe('SolanaNativeStakingValidator via Shield', () => { transaction.add( ComputeBudgetProgram.setComputeUnitPrice({ microLamports: 1 }), ); + Array.from({ length: 11 }, () => + transaction.add( + StakeProgram.merge({ + stakePubkey: sourceStake, + sourceStakePubKey: mergeSource, + authorizedPubkey: userPubkey, + }), + ), + ); const splitWithSeedTx = StakeProgram.splitWithSeed( { @@ -2553,12 +3588,11 @@ describe('SolanaNativeStakingValidator via Shield', () => { }, 2282880, ); - transaction.add(...splitWithSeedTx.instructions); transaction.add( StakeProgram.deactivate({ stakePubkey: newStake, - authorizedPubkey: maliciousPubkey, // Wrong authority! + authorizedPubkey: userPubkey, }), ); @@ -2578,16 +3612,59 @@ describe('SolanaNativeStakingValidator via Shield', () => { expect(result.reason).toContain('No matching operation pattern found'); expect( result.details?.attempts?.some((attempt) => - attempt.reason?.includes('Deactivate authority is not user address'), + attempt.reason?.includes('Invalid instruction count for SPLIT'), ), ).toBe(true); }); - it('should accept valid SPLIT transaction', () => { + it('should detect Merge-only transaction as MERGE not SPLIT', () => { + const userPubkey = new PublicKey(userAddress); + const destinationStake = new PublicKey( + 'HzcH95P8DJnmjWfNLKeWYrNSYuMrbAGcp6MhXwWfeezk', + ); + const sourceStake = new PublicKey( + '9ZmDXFKKaLb5ct3cqbfqHzJPaag4KbZdk3HgAVfCWpMc', + ); + + const transaction = new Transaction(); + transaction.add( + ComputeBudgetProgram.setComputeUnitLimit({ units: 350000 }), + ); + transaction.add( + ComputeBudgetProgram.setComputeUnitPrice({ microLamports: 1 }), + ); + transaction.add( + StakeProgram.merge({ + stakePubkey: destinationStake, + sourceStakePubKey: sourceStake, + authorizedPubkey: userPubkey, + }), + ); + + transaction.recentBlockhash = '11111111111111111111111111111111'; + transaction.feePayer = userPubkey; + const txHex = transaction + .serialize({ requireAllSignatures: false, verifySignatures: false }) + .toString('hex'); + + const result = shield.validate({ + yieldId, + unsignedTransaction: txHex, + userAddress, + }); + + expect(result.isValid).toBe(true); + expect(result.detectedType).toBe(TransactionType.MERGE); + }); + + it('should not match SPLIT-with-merges as MERGE', () => { const userPubkey = new PublicKey(userAddress); const sourceStake = new PublicKey( 'HzcH95P8DJnmjWfNLKeWYrNSYuMrbAGcp6MhXwWfeezk', ); + const mergeSource = new PublicKey( + '2ejUissotvQJda8tnD9iqYbdSuz6Gv6dxDnZE8hEKwr5', + ); const newStake = new PublicKey( '9ZmDXFKKaLb5ct3cqbfqHzJPaag4KbZdk3HgAVfCWpMc', ); @@ -2599,6 +3676,13 @@ describe('SolanaNativeStakingValidator via Shield', () => { transaction.add( ComputeBudgetProgram.setComputeUnitPrice({ microLamports: 1 }), ); + transaction.add( + StakeProgram.merge({ + stakePubkey: sourceStake, + sourceStakePubKey: mergeSource, + authorizedPubkey: userPubkey, + }), + ); const splitWithSeedTx = StakeProgram.splitWithSeed( { @@ -2611,7 +3695,6 @@ describe('SolanaNativeStakingValidator via Shield', () => { }, 2282880, ); - transaction.add(...splitWithSeedTx.instructions); transaction.add( StakeProgram.deactivate({ @@ -2634,6 +3717,7 @@ describe('SolanaNativeStakingValidator via Shield', () => { expect(result.isValid).toBe(true); expect(result.detectedType).toBe(TransactionType.SPLIT); + expect(result.details?.matchedTypes).toBeUndefined(); }); it('should reject SPLIT with Transfer to wrong recipient', () => { diff --git a/src/validators/solana/native-staking/native-staking.validator.ts b/src/validators/solana/native-staking/native-staking.validator.ts index 6a172e7..bced0a6 100644 --- a/src/validators/solana/native-staking/native-staking.validator.ts +++ b/src/validators/solana/native-staking/native-staking.validator.ts @@ -29,6 +29,7 @@ export class SolanaNativeStakingValidator extends BaseValidator { TransactionType.WITHDRAW, TransactionType.WITHDRAW_ALL, TransactionType.SPLIT, + TransactionType.MERGE, ]; } validate( @@ -57,7 +58,9 @@ export class SolanaNativeStakingValidator extends BaseValidator { case TransactionType.WITHDRAW_ALL: return this.validateWithdrawAll(instructions, userAddress); case TransactionType.SPLIT: - return this.validateSplit(instructions, userAddress); + return this.validateSplit(instructions, userAddress, args); + case TransactionType.MERGE: + return this.validateMerge(instructions, userAddress, args); default: return this.blocked('Unsupported transaction type', { transactionType, @@ -209,6 +212,94 @@ export class SolanaNativeStakingValidator extends BaseValidator { return this.safe(); } + private validateMerge( + instructions: DecodedInstruction[], + userAddress: string, + args?: ActionArguments, + ): ValidationResult { + const minInstructions = 3; + const maxInstructions = 12; + + if ( + instructions.length < minInstructions || + instructions.length > maxInstructions + ) { + return this.blocked('Invalid instruction count for MERGE', { + expectedRange: `${minInstructions}-${maxInstructions}`, + actual: instructions.length, + }); + } + + if ( + !this.isComputeBudgetInstruction(instructions[0], 'SetComputeUnitLimit') + ) { + return this.blocked('Missing or invalid SetComputeUnitLimit'); + } + if ( + !this.isComputeBudgetInstruction(instructions[1], 'SetComputeUnitPrice') + ) { + return this.blocked('Missing or invalid SetComputeUnitPrice'); + } + + const stakeAccounts = args?.stakeAccounts; + const enforceStakeAccountsAllowlist = + !isNullOrUndefined(stakeAccounts) && stakeAccounts.length > 0; + + if ( + enforceStakeAccountsAllowlist && + !stakeAccounts.every(isNonEmptyString) + ) { + return this.blocked('Invalid stakeAccounts argument for MERGE'); + } + + const allowedStakeAccounts = enforceStakeAccountsAllowlist + ? new Set(stakeAccounts) + : undefined; + + for (let i = 2; i < instructions.length; i++) { + const merge = instructions[i]; + if (!this.isStakeInstruction(merge, 'Merge')) { + return this.blocked(`Instruction ${i} must be a Merge instruction`, { + actual: merge.instructionType, + }); + } + if (merge.accounts.at(4)?.pubkey.toBase58() !== userAddress) { + return this.blocked( + `Merge authority for instruction ${i} is not user address`, + ); + } + + if (!isNullOrUndefined(allowedStakeAccounts)) { + const destination = merge.accounts.at(0)?.pubkey.toBase58(); + if ( + isNullOrUndefined(destination) || + !allowedStakeAccounts.has(destination) + ) { + return this.blocked( + `Merge destination for instruction ${i} is not in stakeAccounts`, + { + expected: stakeAccounts, + actual: destination, + }, + ); + } + + const source = merge.accounts.at(1)?.pubkey.toBase58(); + if (isNullOrUndefined(source) || !allowedStakeAccounts.has(source)) { + return this.blocked( + `Merge source for instruction ${i} is not in stakeAccounts`, + { + expected: stakeAccounts, + actual: source, + }, + ); + } + } + } + + return this.safe(); + } + private validateWithdraw( instructions: DecodedInstruction[], userAddress: string, @@ -286,10 +377,19 @@ export class SolanaNativeStakingValidator extends BaseValidator { private validateSplit( instructions: DecodedInstruction[], userAddress: string, + args?: ActionArguments, ): ValidationResult { - if (instructions.length !== 6) { + const splitTailLength = 4; + const maxMerges = 10; + const minInstructions = 2 + splitTailLength; + const maxInstructions = 2 + maxMerges + splitTailLength; + + if ( + instructions.length < minInstructions || + instructions.length > maxInstructions + ) { return this.blocked('Invalid instruction count for SPLIT', { - expected: 6, + expectedRange: `${minInstructions}-${maxInstructions}`, actual: instructions.length, }); } @@ -305,8 +405,87 @@ export class SolanaNativeStakingValidator extends BaseValidator { return this.blocked('Missing or invalid SetComputeUnitPrice'); } - const allocateWithSeed = instructions[2]; - if (!this.isSystemInstruction(instructions[2], 'AllocateWithSeed')) { + const stakeAccounts = args?.stakeAccounts; + const enforceStakeAccountsAllowlist = + !isNullOrUndefined(stakeAccounts) && stakeAccounts.length > 0; + + if ( + enforceStakeAccountsAllowlist && + !stakeAccounts.every(isNonEmptyString) + ) { + return this.blocked('Invalid stakeAccounts argument for SPLIT'); + } + + const allowedStakeAccounts = enforceStakeAccountsAllowlist + ? new Set(stakeAccounts) + : undefined; + + let idx = 2; + const mergeDestinations: string[] = []; + + while (idx < instructions.length - splitTailLength) { + const merge = instructions[idx]; + if (!this.isStakeInstruction(merge, 'Merge')) { + break; + } + if (mergeDestinations.length >= maxMerges) { + return this.blocked('Too many Merge instructions before SPLIT', { + max: maxMerges, + }); + } + if (merge.accounts.at(4)?.pubkey.toBase58() !== userAddress) { + return this.blocked( + `Merge authority for instruction ${idx} is not user address`, + ); + } + + const destination = merge.accounts.at(0)?.pubkey.toBase58(); + const source = merge.accounts.at(1)?.pubkey.toBase58(); + + if (!isNullOrUndefined(allowedStakeAccounts)) { + if ( + isNullOrUndefined(destination) || + !allowedStakeAccounts.has(destination) + ) { + return this.blocked( + `Merge destination for instruction ${idx} is not in stakeAccounts`, + { + expected: stakeAccounts, + actual: destination, + }, + ); + } + if (isNullOrUndefined(source) || !allowedStakeAccounts.has(source)) { + return this.blocked( + `Merge source for instruction ${idx} is not in stakeAccounts`, + { + expected: stakeAccounts, + actual: source, + }, + ); + } + } + + if (isNullOrUndefined(destination)) { + return this.blocked(`Missing merge destination for instruction ${idx}`); + } + mergeDestinations.push(destination); + idx++; + } + + if (instructions.length - idx !== splitTailLength) { + return this.blocked( + 'Invalid SPLIT instruction layout after Merge prefix', + { + expectedRemaining: splitTailLength, + actualRemaining: instructions.length - idx, + mergeCount: mergeDestinations.length, + }, + ); + } + + const allocateWithSeed = instructions[idx++]; + if (!this.isSystemInstruction(allocateWithSeed, 'AllocateWithSeed')) { return this.blocked('Missing or invalid AllocateWithSeed'); } @@ -315,7 +494,7 @@ export class SolanaNativeStakingValidator extends BaseValidator { return this.blocked('AllocateWithSeed source is not user address'); } - const transfer = instructions[3]; + const transfer = instructions[idx++]; if (!this.isSystemInstruction(transfer, 'Transfer')) { return this.blocked('Missing or invalid Transfer'); } @@ -328,7 +507,7 @@ export class SolanaNativeStakingValidator extends BaseValidator { ); } - const split = instructions[4]; + const split = instructions[idx++]; if (!this.isStakeInstruction(split, 'Split')) { return this.blocked('Missing or invalid Split instruction'); } @@ -341,6 +520,19 @@ export class SolanaNativeStakingValidator extends BaseValidator { return this.blocked('Split authority is not user address'); } + const splitSource = split.accounts.at(0)?.pubkey.toBase58(); + for (const destination of mergeDestinations) { + if (destination !== splitSource) { + return this.blocked( + 'Merge destination does not match Split source stake account', + { + mergeDestination: destination, + splitSource, + }, + ); + } + } + const transferRecipient = transfer.accounts.at(1)?.pubkey; const splitNewStake = split.accounts.at(1)?.pubkey; if ( @@ -357,7 +549,7 @@ export class SolanaNativeStakingValidator extends BaseValidator { ); } - const deactivate = instructions[5]; + const deactivate = instructions[idx]; if (!this.isStakeInstruction(deactivate, 'Deactivate')) { return this.blocked('Missing or invalid Deactivate instruction'); @@ -445,6 +637,8 @@ export class SolanaNativeStakingValidator extends BaseValidator { return 'Withdraw'; case 5: return 'Deactivate'; + case 7: + return 'Merge'; case 10: return 'CreateAccountWithSeed'; default: