@@ -279,4 +279,42 @@ describe('Solana Paymaster Service — validateReimbursement', function () {
279279 / m u s t r e i m b u r s e p a y m a s t e r / ,
280280 ) ;
281281 } ) ;
282+
283+ it ( 'rejects a signer-borrow proposal (num_signers > 1) that would drain the paymaster' , function ( ) {
284+ // Pre-mainnet audit regression: at execute time is_signer is taken from
285+ // the proposal header, and the paymaster is the outer-tx fee payer (a
286+ // top-level signer). A proposal marking the paymaster as a second signer
287+ // (index 1 < num_signers = 2) could CPI SystemProgram::transfer FROM the
288+ // paymaster, borrowing its signature — draining it. The reimbursement to
289+ // the paymaster is included so the floor check would pass; the num_signers
290+ // guard must reject it first, before any funds move.
291+ const attacker = Keypair . generate ( ) . publicKey ;
292+ const message : TransactionMessage = {
293+ numSigners : 2 , // illegal: only the vault (index 0) may sign
294+ numWritableSigners : 2 ,
295+ numWritableNonSigners : 2 ,
296+ accountKeys : [ vault , paymaster , attacker , SystemProgram . programId ] ,
297+ instructions : [
298+ // reimbursement to the paymaster (satisfies the floor if reached)
299+ {
300+ programIdIndex : 3 ,
301+ accountIndexes : [ 0 , 1 ] ,
302+ data : encodeSystemTransferData ( MIN_LAMPORTS ) ,
303+ } ,
304+ // the drain: transfer FROM paymaster (index 1) TO attacker (index 2)
305+ {
306+ programIdIndex : 3 ,
307+ accountIndexes : [ 1 , 2 ] ,
308+ data : encodeSystemTransferData ( 9_000_000_000 ) ,
309+ } ,
310+ ] ,
311+ } ;
312+ const tx = makeOuterTx ( {
313+ paymaster,
314+ ixs : [ buildCreateTransactionIx ( message ) ] ,
315+ } ) ;
316+ expect ( ( ) => validateReimbursement ( tx , paymaster , MIN_LAMPORTS ) ) . to . throw (
317+ / n u m _ s i g n e r s m u s t b e 1 / ,
318+ ) ;
319+ } ) ;
282320} ) ;
0 commit comments