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..669cb84 100644 --- a/src/validators/solana/native-staking/native-staking.validator.test.ts +++ b/src/validators/solana/native-staking/native-staking.validator.test.ts @@ -1006,6 +1006,333 @@ describe('SolanaNativeStakingValidator via Shield', () => { }); }); + describe('MERGE validation', () => { + const destinationStake = new PublicKey( + 'HzcH95P8DJnmjWfNLKeWYrNSYuMrbAGcp6MhXwWfeezk', + ); + const sourceStakes = [ + new PublicKey('9ZmDXFKKaLb5ct3cqbfqHzJPaag4KbZdk3HgAVfCWpMc'), + new PublicKey('2ejUissotvQJda8tnD9iqYbdSuz6Gv6dxDnZE8hEKwr5'), + new PublicKey('HaAebbtwqajTNEBJ2ys3yxWJXk6fi7tk7WXpvj6hMEXZ'), + ]; + + it('should reject MERGE with too few instructions', () => { + const userPubkey = new PublicKey(userAddress); + + const transaction = new Transaction(); + transaction.add( + ComputeBudgetProgram.setComputeUnitLimit({ units: 350000 }), + ); + transaction.add( + ComputeBudgetProgram.setComputeUnitPrice({ microLamports: 1 }), + ); + + 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 MERGE'), + ), + ).toBe(true); + }); + + it('should reject MERGE with too many instructions', () => { + const userPubkey = new PublicKey(userAddress); + + 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, + }); + + 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(); + transaction.add( + ComputeBudgetProgram.setComputeUnitPrice({ microLamports: 1 }), + ); + transaction.add( + StakeProgram.merge({ + stakePubkey: destinationStake, + sourceStakePubKey: sourceStakes[0], + authorizedPubkey: userPubkey, + }), + StakeProgram.merge({ + stakePubkey: destinationStake, + sourceStakePubKey: sourceStakes[1], + 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 MERGE with missing SetComputeUnitPrice', () => { + const userPubkey = new PublicKey(userAddress); + + const transaction = new Transaction(); + transaction.add( + ComputeBudgetProgram.setComputeUnitLimit({ units: 350000 }), + ); + transaction.add( + StakeProgram.merge({ + stakePubkey: destinationStake, + sourceStakePubKey: sourceStakes[0], + authorizedPubkey: userPubkey, + }), + StakeProgram.merge({ + stakePubkey: destinationStake, + sourceStakePubKey: sourceStakes[1], + 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 MERGE with non-Merge instruction in loop', () => { + const userPubkey = new PublicKey(userAddress); + + const transaction = new Transaction(); + transaction.add( + ComputeBudgetProgram.setComputeUnitLimit({ units: 350000 }), + ); + transaction.add( + ComputeBudgetProgram.setComputeUnitPrice({ microLamports: 1 }), + ); + transaction.add( + StakeProgram.merge({ + stakePubkey: destinationStake, + sourceStakePubKey: sourceStakes[0], + authorizedPubkey: userPubkey, + }), + ); + 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('Instruction 3 must be a Merge instruction'), + ), + ).toBe(true); + }); + + it('should accept valid single MERGE transaction', () => { + const userPubkey = new PublicKey(userAddress); + + const transaction = new Transaction(); + transaction.add( + ComputeBudgetProgram.setComputeUnitLimit({ units: 350000 }), + ); + transaction.add( + ComputeBudgetProgram.setComputeUnitPrice({ microLamports: 1 }), + ); + transaction.add( + StakeProgram.merge({ + stakePubkey: destinationStake, + sourceStakePubKey: sourceStakes[0], + 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 accept valid batch MERGE transaction', () => { + const userPubkey = new PublicKey(userAddress); + + const transaction = new Transaction(); + transaction.add( + ComputeBudgetProgram.setComputeUnitLimit({ units: 350000 }), + ); + transaction.add( + ComputeBudgetProgram.setComputeUnitPrice({ microLamports: 1 }), + ); + Array.from({ length: sourceStakes.length }, (_, i) => + transaction.add( + StakeProgram.merge({ + stakePubkey: destinationStake, + sourceStakePubKey: sourceStakes[i], + 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 reject MERGE with wrong authority', () => { + const userPubkey = new PublicKey(userAddress); + const maliciousPubkey = new PublicKey( + '2ejUissotvQJda8tnD9iqYbdSuz6Gv6dxDnZE8hEKwr5', + ); + + const transaction = new Transaction(); + transaction.add( + ComputeBudgetProgram.setComputeUnitLimit({ units: 350000 }), + ); + transaction.add( + ComputeBudgetProgram.setComputeUnitPrice({ microLamports: 1 }), + ); + transaction.add( + StakeProgram.merge({ + stakePubkey: destinationStake, + sourceStakePubKey: sourceStakes[0], + authorizedPubkey: maliciousPubkey, + }), + ); + + 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( + 'Merge authority for instruction 2 is not user address', + ), + ), + ).toBe(true); + }); + }); + describe('WITHDRAW validation', () => { it('should reject WITHDRAW with wrong instruction count', () => { const userPubkey = new PublicKey(userAddress); diff --git a/src/validators/solana/native-staking/native-staking.validator.ts b/src/validators/solana/native-staking/native-staking.validator.ts index 6a172e7..ea96f95 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( @@ -58,6 +59,8 @@ export class SolanaNativeStakingValidator extends BaseValidator { return this.validateWithdrawAll(instructions, userAddress); case TransactionType.SPLIT: return this.validateSplit(instructions, userAddress); + case TransactionType.MERGE: + return this.validateMerge(instructions, userAddress); default: return this.blocked('Unsupported transaction type', { transactionType, @@ -209,6 +212,51 @@ export class SolanaNativeStakingValidator extends BaseValidator { return this.safe(); } + private validateMerge( + instructions: DecodedInstruction[], + userAddress: string, + ): 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'); + } + + 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`, + ); + } + } + + return this.safe(); + } + private validateWithdraw( instructions: DecodedInstruction[], userAddress: string, @@ -445,6 +493,8 @@ export class SolanaNativeStakingValidator extends BaseValidator { return 'Withdraw'; case 5: return 'Deactivate'; + case 7: + return 'Merge'; case 10: return 'CreateAccountWithSeed'; default: