From 7944ed44ca1a9dc31d69fe9f260bdb9d94799504 Mon Sep 17 00:00:00 2001 From: Swinecoder101 Date: Wed, 26 Aug 2026 12:58:23 +0100 Subject: [PATCH 1/2] test(pda-rent-payer): prove create_new_account pays vault lamports into unsigned/existing wallets and non-canonical vaults native and pinocchio: an existing funded wallet passed as new_account, with or without its signature, receives rent-vault lamports instead of being rejected. pinocchio additionally accepts a non-canonical rent_vault bump, letting an alternate PDA act as the rent payer. The happy-path test now asserts the created account's lamports, owner, data length and the exact vault delta. --- basics/pda-rent-payer/native/tests/test.ts | 96 +++++++++++--- basics/pda-rent-payer/pinocchio/tests/test.ts | 123 +++++++++++++++--- 2 files changed, 182 insertions(+), 37 deletions(-) diff --git a/basics/pda-rent-payer/native/tests/test.ts b/basics/pda-rent-payer/native/tests/test.ts index 42eaf6dfa..36f9b8c63 100644 --- a/basics/pda-rent-payer/native/tests/test.ts +++ b/basics/pda-rent-payer/native/tests/test.ts @@ -1,43 +1,54 @@ import { + AccountRole, type Address, appendTransactionMessageInstruction, createTransactionMessage, generateKeyPairSigner, getProgramDerivedAddress, + type Instruction, type KeyPairSigner, lamports, pipe, setTransactionMessageFeePayerSigner, signTransactionMessageWithSigners, } from '@solana/kit'; +import { SYSTEM_PROGRAM_ADDRESS } from '@solana-program/system'; import { assert } from 'chai'; import { FailedTransactionMetadata, LiteSVM } from 'litesvm'; -import { createCreateNewAccountInstruction, createInitRentVaultInstruction } from '../ts'; +import { + createCreateNewAccountInstruction, + createInitRentVaultInstruction, + createNewAccountEncoder, + MyInstruction, +} from '../ts'; describe('PDA Rent-Payer', () => { const svm = new LiteSVM(); let programId: Address; let payer: KeyPairSigner; + let rentVaultPda: Address; + let rentExemptBalance: bigint; before(async () => { programId = (await generateKeyPairSigner()).address; svm.addProgramFromFile(programId, 'tests/fixtures/pda_rent_payer_program.so'); payer = await generateKeyPairSigner(); svm.airdrop(payer.address, lamports(2_000_000_000n)); - }); - async function deriveRentVaultPda() { - const [pda] = await getProgramDerivedAddress({ + [rentVaultPda] = await getProgramDerivedAddress({ programAddress: programId, seeds: ['rent_vault'], }); - return pda; - } + rentExemptBalance = svm.getRent().minimumBalance(0n); + }); - it('Initialize the Rent Vault', async () => { - const rentVaultPda = await deriveRentVaultPda(); - const ix = createInitRentVaultInstruction(rentVaultPda, payer, programId, 1_000_000_000n); + function balance(address: Address): bigint { + const value = svm.getBalance(address); + assert(value !== null, `expected ${address} to exist`); + return value; + } + async function sendInstruction(ix: Instruction) { const transactionMessage = pipe( createTransactionMessage({ version: 0 }), m => setTransactionMessageFeePayerSigner(payer, m), @@ -45,25 +56,70 @@ describe('PDA Rent-Payer', () => { m => appendTransactionMessageInstruction(ix, m), ); const signedTx = await signTransactionMessageWithSigners(transactionMessage); + return svm.sendTransaction(signedTx); + } - const result = svm.sendTransaction(signedTx); + // Same layout as `createCreateNewAccountInstruction`, but with `new_account` passed as a plain writable account. + function createNewAccountWithoutSignature(newAccount: Address): Instruction { + return { + programAddress: programId, + accounts: [ + { address: newAccount, role: AccountRole.WRITABLE }, + { address: rentVaultPda, role: AccountRole.WRITABLE }, + { address: SYSTEM_PROGRAM_ADDRESS, role: AccountRole.READONLY }, + ], + data: createNewAccountEncoder.encode({ instruction: MyInstruction.CreateNewAccount }), + }; + } + + it('Initialize the Rent Vault', async () => { + const ix = createInitRentVaultInstruction(rentVaultPda, payer, programId, 1_000_000_000n); + + const result = await sendInstruction(ix); assert(!(result instanceof FailedTransactionMetadata), `transaction failed: ${result.toString()}`); + + assert.equal(balance(rentVaultPda), rentExemptBalance + 1_000_000_000n); }); it('Create a new account using the Rent Vault', async () => { const newAccount = await generateKeyPairSigner(); - const rentVaultPda = await deriveRentVaultPda(); + const vaultBalanceBefore = balance(rentVaultPda); const ix = createCreateNewAccountInstruction(newAccount, rentVaultPda, programId); - const transactionMessage = pipe( - createTransactionMessage({ version: 0 }), - m => setTransactionMessageFeePayerSigner(payer, m), - m => svm.setTransactionMessageLifetimeUsingLatestBlockhash(m), - m => appendTransactionMessageInstruction(ix, m), - ); - const signedTx = await signTransactionMessageWithSigners(transactionMessage); - - const result = svm.sendTransaction(signedTx); + const result = await sendInstruction(ix); assert(!(result instanceof FailedTransactionMetadata), `transaction failed: ${result.toString()}`); + + const created = svm.getAccount(newAccount.address); + assert(created.exists, 'new account was not created'); + assert.equal(created.lamports, rentExemptBalance); + assert.equal(created.programAddress, SYSTEM_PROGRAM_ADDRESS); + assert.equal(created.data.length, 0); + assert.equal(balance(rentVaultPda), vaultBalanceBefore - rentExemptBalance); + }); + + it('Rejects paying vault lamports into an existing wallet that does not sign', async () => { + const wallet = await generateKeyPairSigner(); + svm.airdrop(wallet.address, lamports(500_000_000n)); + const walletBalanceBefore = balance(wallet.address); + const vaultBalanceBefore = balance(rentVaultPda); + + const result = await sendInstruction(createNewAccountWithoutSignature(wallet.address)); + assert(result instanceof FailedTransactionMetadata, 'unsigned new_account must be rejected'); + + assert.equal(balance(wallet.address), walletBalanceBefore); + assert.equal(balance(rentVaultPda), vaultBalanceBefore); + }); + + it('Rejects paying vault lamports into an existing wallet even when it signs', async () => { + const wallet = await generateKeyPairSigner(); + svm.airdrop(wallet.address, lamports(500_000_000n)); + const walletBalanceBefore = balance(wallet.address); + const vaultBalanceBefore = balance(rentVaultPda); + + const result = await sendInstruction(createCreateNewAccountInstruction(wallet, rentVaultPda, programId)); + assert(result instanceof FailedTransactionMetadata, 'an already existing new_account must be rejected'); + + assert.equal(balance(wallet.address), walletBalanceBefore); + assert.equal(balance(rentVaultPda), vaultBalanceBefore); }); }); diff --git a/basics/pda-rent-payer/pinocchio/tests/test.ts b/basics/pda-rent-payer/pinocchio/tests/test.ts index 397df571c..ef4e247e6 100644 --- a/basics/pda-rent-payer/pinocchio/tests/test.ts +++ b/basics/pda-rent-payer/pinocchio/tests/test.ts @@ -1,14 +1,19 @@ +import { createHash } from 'node:crypto'; import { + type AccountMeta, AccountRole, + type AccountSignerMeta, type Address, appendTransactionMessageInstruction, createTransactionMessage, generateKeyPairSigner, + getAddressCodec, getProgramDerivedAddress, getStructEncoder, getU8Encoder, getU64Encoder, type Instruction, + isOffCurveAddress, type KeyPairSigner, lamports, pipe, @@ -68,22 +73,61 @@ describe('PDA Rent-Payer', () => { return svm.sendTransaction(signedTx); } - it('Initialize the Rent Vault', async () => { - const ix = { + function initRentVaultInstruction(vault: Address, vaultBump: number) { + return { programAddress: programId, accounts: [ - { address: rentVaultPda, role: AccountRole.WRITABLE }, + { address: vault, role: AccountRole.WRITABLE }, { address: payer.address, role: AccountRole.WRITABLE_SIGNER, signer: payer }, { address: SYSTEM_PROGRAM_ADDRESS, role: AccountRole.READONLY }, ], data: initRentVaultEncoder.encode({ discriminator: INIT_RENT_VAULT_DISCRIMINATOR, - bump, + bump: vaultBump, lamports: FUND_LAMPORTS, }), }; + } + + function createNewAccountInstruction( + newAccount: { address: Address; signer?: KeyPairSigner }, + vault: Address, + vaultBump: number, + ) { + const newAccountMeta: AccountMeta | AccountSignerMeta = newAccount.signer + ? { address: newAccount.address, role: AccountRole.WRITABLE_SIGNER, signer: newAccount.signer } + : { address: newAccount.address, role: AccountRole.WRITABLE }; + return { + programAddress: programId, + accounts: [ + newAccountMeta, + { address: vault, role: AccountRole.WRITABLE }, + { address: SYSTEM_PROGRAM_ADDRESS, role: AccountRole.READONLY }, + ], + data: new Uint8Array([CREATE_NEW_ACCOUNT_DISCRIMINATOR, vaultBump]), + }; + } - const result = await sendInstruction(ix); + // Highest bump below the canonical one that still lands off-curve: a valid but non-canonical rent vault PDA. + function findNonCanonicalRentVault(): [Address, number] { + const addressCodec = getAddressCodec(); + for (let candidate = bump - 1; candidate >= 0; candidate--) { + const hash = createHash('sha256') + .update('rent_vault') + .update(new Uint8Array([candidate])) + .update(new Uint8Array(addressCodec.encode(programId))) + .update('ProgramDerivedAddress') + .digest(); + const address = addressCodec.decode(hash); + if (isOffCurveAddress(address)) { + return [address, candidate]; + } + } + assert.fail('no non-canonical bump found'); + } + + it('Initialize the Rent Vault', async () => { + const result = await sendInstruction(initRentVaultInstruction(rentVaultPda, bump)); assert(!(result instanceof FailedTransactionMetadata), `transaction failed: ${result.toString()}`); assert.equal(balance(rentVaultPda), rentExemptBalance + FUND_LAMPORTS); @@ -93,20 +137,65 @@ describe('PDA Rent-Payer', () => { const newAccount = await generateKeyPairSigner(); const vaultBalanceBefore = balance(rentVaultPda); - const ix = { - programAddress: programId, - accounts: [ - { address: newAccount.address, role: AccountRole.WRITABLE_SIGNER, signer: newAccount }, - { address: rentVaultPda, role: AccountRole.WRITABLE }, - { address: SYSTEM_PROGRAM_ADDRESS, role: AccountRole.READONLY }, - ], - data: new Uint8Array([CREATE_NEW_ACCOUNT_DISCRIMINATOR, bump]), - }; - - const result = await sendInstruction(ix); + const result = await sendInstruction( + createNewAccountInstruction({ address: newAccount.address, signer: newAccount }, rentVaultPda, bump), + ); assert(!(result instanceof FailedTransactionMetadata), `transaction failed: ${result.toString()}`); - assert.equal(balance(newAccount.address), rentExemptBalance); + const created = svm.getAccount(newAccount.address); + assert(created.exists, 'new account was not created'); + assert.equal(created.lamports, rentExemptBalance); + assert.equal(created.programAddress, SYSTEM_PROGRAM_ADDRESS); + assert.equal(created.data.length, 0); assert.equal(balance(rentVaultPda), vaultBalanceBefore - rentExemptBalance); }); + + it('Rejects paying vault lamports into an existing wallet that does not sign', async () => { + const wallet = await generateKeyPairSigner(); + svm.airdrop(wallet.address, lamports(500_000_000n)); + const walletBalanceBefore = balance(wallet.address); + const vaultBalanceBefore = balance(rentVaultPda); + + const result = await sendInstruction( + createNewAccountInstruction({ address: wallet.address }, rentVaultPda, bump), + ); + assert(result instanceof FailedTransactionMetadata, 'unsigned new_account must be rejected'); + + assert.equal(balance(wallet.address), walletBalanceBefore); + assert.equal(balance(rentVaultPda), vaultBalanceBefore); + }); + + it('Rejects paying vault lamports into an existing wallet even when it signs', async () => { + const wallet = await generateKeyPairSigner(); + svm.airdrop(wallet.address, lamports(500_000_000n)); + const walletBalanceBefore = balance(wallet.address); + const vaultBalanceBefore = balance(rentVaultPda); + + const result = await sendInstruction( + createNewAccountInstruction({ address: wallet.address, signer: wallet }, rentVaultPda, bump), + ); + assert(result instanceof FailedTransactionMetadata, 'an already existing new_account must be rejected'); + + assert.equal(balance(wallet.address), walletBalanceBefore); + assert.equal(balance(rentVaultPda), vaultBalanceBefore); + }); + + it('Rejects a non-canonical rent vault bump', async () => { + const [altVault, altBump] = findNonCanonicalRentVault(); + assert.notEqual(altVault, rentVaultPda); + + // Vaults at alternate bumps can still be initialized; they must not be usable as the rent payer. + const initResult = await sendInstruction(initRentVaultInstruction(altVault, altBump)); + assert(!(initResult instanceof FailedTransactionMetadata), `transaction failed: ${initResult.toString()}`); + const altVaultBalanceBefore = balance(altVault); + + const newAccount = await generateKeyPairSigner(); + const result = await sendInstruction( + createNewAccountInstruction({ address: newAccount.address, signer: newAccount }, altVault, altBump), + ); + assert(result instanceof FailedTransactionMetadata, 'non-canonical bump must be rejected'); + + assert.equal(balance(altVault), altVaultBalanceBefore); + assert.isNull(svm.getBalance(newAccount.address)); + }); }); From d97641b8f3cb7ac4f042114dd99cf31b177a431b Mon Sep 17 00:00:00 2001 From: Swinecoder101 Date: Wed, 26 Aug 2026 13:01:05 +0100 Subject: [PATCH 2/2] fix(pda-rent-payer): make native/pinocchio create_new_account require a signer and create the account via CPI Match the anchor reference: new_account must sign and is created with a system_program create_account CPI (space 0, system-owned) funded by the rent-vault PDA, so vault lamports can no longer be paid into an existing wallet. The vault is now created as a system-owned account so it can fund the CPI. pinocchio derives the vault with find_program_address and rejects non-canonical bumps. Tests assert the vault owner. The unauthenticated-drain design limitation shared by all variants is out of scope and left as-is. --- .../src/instructions/create_new_account.rs | 24 +++++++++++--- .../src/instructions/init_rent_vault.rs | 2 +- basics/pda-rent-payer/native/tests/test.ts | 3 ++ .../src/instructions/create_new_account.rs | 33 +++++++++++++++---- .../src/instructions/init_rent_vault.rs | 2 +- basics/pda-rent-payer/pinocchio/tests/test.ts | 3 ++ 6 files changed, 55 insertions(+), 12 deletions(-) diff --git a/basics/pda-rent-payer/native/program/src/instructions/create_new_account.rs b/basics/pda-rent-payer/native/program/src/instructions/create_new_account.rs index 071b8127e..0b94f241c 100644 --- a/basics/pda-rent-payer/native/program/src/instructions/create_new_account.rs +++ b/basics/pda-rent-payer/native/program/src/instructions/create_new_account.rs @@ -1,6 +1,8 @@ use solana_program::{ account_info::{next_account_info, AccountInfo}, entrypoint::ProgramResult, + program::invoke_signed, + program_error::ProgramError, pubkey::Pubkey, rent::Rent, sysvar::Sysvar, @@ -12,9 +14,13 @@ pub fn create_new_account(program_id: &Pubkey, accounts: &[AccountInfo]) -> Prog let accounts_iter = &mut accounts.iter(); let new_account = next_account_info(accounts_iter)?; let rent_vault = next_account_info(accounts_iter)?; - let _system_program = next_account_info(accounts_iter)?; + let system_program = next_account_info(accounts_iter)?; - let (rent_vault_pda, _rent_vault_bump) = + if !new_account.is_signer { + return Err(ProgramError::MissingRequiredSignature); + } + + let (rent_vault_pda, rent_vault_bump) = Pubkey::find_program_address(&[RentVault::SEED_PREFIX.as_bytes()], program_id); assert!(rent_vault.key.eq(&rent_vault_pda)); @@ -22,8 +28,18 @@ pub fn create_new_account(program_id: &Pubkey, accounts: &[AccountInfo]) -> Prog // let lamports_required_for_rent = (Rent::get()?).minimum_balance(0); - **rent_vault.lamports.borrow_mut() -= lamports_required_for_rent; - **new_account.lamports.borrow_mut() += lamports_required_for_rent; + // Create the new account, transferring lamports from the rent vault to the new account + invoke_signed( + &solana_system_interface::instruction::create_account( + rent_vault.key, + new_account.key, + lamports_required_for_rent, + 0, + &solana_system_interface::program::ID, + ), + &[rent_vault.clone(), new_account.clone(), system_program.clone()], + &[&[RentVault::SEED_PREFIX.as_bytes(), &[rent_vault_bump]]], + )?; Ok(()) } diff --git a/basics/pda-rent-payer/native/program/src/instructions/init_rent_vault.rs b/basics/pda-rent-payer/native/program/src/instructions/init_rent_vault.rs index 3ee4bb84c..5be54d2a5 100644 --- a/basics/pda-rent-payer/native/program/src/instructions/init_rent_vault.rs +++ b/basics/pda-rent-payer/native/program/src/instructions/init_rent_vault.rs @@ -35,7 +35,7 @@ pub fn init_rent_vault(program_id: &Pubkey, accounts: &[AccountInfo], args: Init rent_vault.key, lamports_required, 0, - program_id, + &solana_system_interface::program::ID, ), &[payer.clone(), rent_vault.clone(), system_program.clone()], &[&[RentVault::SEED_PREFIX.as_bytes(), &[rent_vault_bump]]], diff --git a/basics/pda-rent-payer/native/tests/test.ts b/basics/pda-rent-payer/native/tests/test.ts index 36f9b8c63..ba5085877 100644 --- a/basics/pda-rent-payer/native/tests/test.ts +++ b/basics/pda-rent-payer/native/tests/test.ts @@ -79,6 +79,9 @@ describe('PDA Rent-Payer', () => { assert(!(result instanceof FailedTransactionMetadata), `transaction failed: ${result.toString()}`); assert.equal(balance(rentVaultPda), rentExemptBalance + 1_000_000_000n); + const vault = svm.getAccount(rentVaultPda); + assert(vault.exists, 'rent vault was not created'); + assert.equal(vault.programAddress, SYSTEM_PROGRAM_ADDRESS); }); it('Create a new account using the Rent Vault', async () => { diff --git a/basics/pda-rent-payer/pinocchio/program/src/instructions/create_new_account.rs b/basics/pda-rent-payer/pinocchio/program/src/instructions/create_new_account.rs index 7b65e3661..cd2a33626 100644 --- a/basics/pda-rent-payer/pinocchio/program/src/instructions/create_new_account.rs +++ b/basics/pda-rent-payer/pinocchio/program/src/instructions/create_new_account.rs @@ -1,8 +1,10 @@ use pinocchio::{ + cpi::{Seed, Signer}, error::ProgramError, sysvars::{rent::Rent, Sysvar}, AccountView, Address, ProgramResult, }; +use pinocchio_system::instructions::CreateAccount; use crate::state::RentVault; @@ -15,19 +17,38 @@ pub fn create_new_account( return Err(ProgramError::NotEnoughAccountKeys); }; - let bump = instruction_data[0]; + if !new_account.is_signer() { + return Err(ProgramError::MissingRequiredSignature); + } - let rent_vault_pda = Address::create_program_address(&[RentVault::SEED_PREFIX.as_bytes(), &[bump]], program_id) - .map_err(|_| ProgramError::InvalidSeeds)?; + let bump = *instruction_data.first().ok_or(ProgramError::InvalidInstructionData)?; - assert!(rent_vault.address().eq(&rent_vault_pda)); + // Only the canonical bump is accepted, so a client cannot point at an alternate vault PDA. + let (rent_vault_pda, canonical_bump) = + Address::find_program_address(&[RentVault::SEED_PREFIX.as_bytes()], program_id); + if bump != canonical_bump || rent_vault.address() != &rent_vault_pda { + return Err(ProgramError::InvalidSeeds); + } // Assuming this account has no inner data (size 0) // let lamports_required_for_rent = (Rent::get()?).try_minimum_balance(0)?; - rent_vault.set_lamports(rent_vault.lamports() - lamports_required_for_rent); - new_account.set_lamports(new_account.lamports() + lamports_required_for_rent); + let bump_bytes = bump.to_le_bytes(); + + let seeds = [Seed::from(RentVault::SEED_PREFIX.as_bytes()), Seed::from(&bump_bytes)]; + + let signer_seed = Signer::from(&seeds); + + // Create the new account, transferring lamports from the rent vault to the new account + CreateAccount { + from: rent_vault, + to: new_account, + lamports: lamports_required_for_rent, + space: 0, + owner: &pinocchio_system::ID, + } + .invoke_signed(&[signer_seed])?; Ok(()) } diff --git a/basics/pda-rent-payer/pinocchio/program/src/instructions/init_rent_vault.rs b/basics/pda-rent-payer/pinocchio/program/src/instructions/init_rent_vault.rs index f8edd3bcf..1ac9b463c 100644 --- a/basics/pda-rent-payer/pinocchio/program/src/instructions/init_rent_vault.rs +++ b/basics/pda-rent-payer/pinocchio/program/src/instructions/init_rent_vault.rs @@ -36,7 +36,7 @@ pub fn init_rent_vault(program_id: &Address, accounts: &mut [AccountView], instr let signer_seed = Signer::from(&seeds); - CreateAccount { from: payer, to: rent_vault, lamports: lamports_required, space: 0, owner: program_id } + CreateAccount { from: payer, to: rent_vault, lamports: lamports_required, space: 0, owner: &pinocchio_system::ID } .invoke_signed(&[signer_seed])?; Ok(()) diff --git a/basics/pda-rent-payer/pinocchio/tests/test.ts b/basics/pda-rent-payer/pinocchio/tests/test.ts index ef4e247e6..c60601b73 100644 --- a/basics/pda-rent-payer/pinocchio/tests/test.ts +++ b/basics/pda-rent-payer/pinocchio/tests/test.ts @@ -131,6 +131,9 @@ describe('PDA Rent-Payer', () => { assert(!(result instanceof FailedTransactionMetadata), `transaction failed: ${result.toString()}`); assert.equal(balance(rentVaultPda), rentExemptBalance + FUND_LAMPORTS); + const vault = svm.getAccount(rentVaultPda); + assert(vault.exists, 'rent vault was not created'); + assert.equal(vault.programAddress, SYSTEM_PROGRAM_ADDRESS); }); it('Create a new account using the Rent Vault', async () => {