diff --git a/basics/realloc/native/program/src/instructions/reallocate.rs b/basics/realloc/native/program/src/instructions/reallocate.rs index 556eac00c..ef46a28dd 100644 --- a/basics/realloc/native/program/src/instructions/reallocate.rs +++ b/basics/realloc/native/program/src/instructions/reallocate.rs @@ -21,11 +21,13 @@ pub fn reallocate_without_zero_init(accounts: &[AccountInfo], args: EnhancedAddr let account_span = borsh::to_vec(&enhanced_address_info_data)?.len(); let lamports_required = (Rent::get()?).minimum_balance(account_span); - let diff = lamports_required - target_account.lamports(); - invoke( - &solana_system_interface::instruction::transfer(payer.key, target_account.key, diff), - &[payer.clone(), target_account.clone(), system_program.clone()], - )?; + let diff = lamports_required.saturating_sub(target_account.lamports()); + if diff > 0 { + invoke( + &solana_system_interface::instruction::transfer(payer.key, target_account.key, diff), + &[payer.clone(), target_account.clone(), system_program.clone()], + )?; + } target_account.resize(account_span)?; diff --git a/basics/realloc/native/tests/realloc.test.ts b/basics/realloc/native/tests/realloc.test.ts index 4f73711fe..c5b00b6ef 100644 --- a/basics/realloc/native/tests/realloc.test.ts +++ b/basics/realloc/native/tests/realloc.test.ts @@ -10,6 +10,7 @@ import { setTransactionMessageFeePayerSigner, signTransactionMessageWithSigners, } from '@solana/kit'; +import { getTransferSolInstruction } from '@solana-program/system'; import { assert } from 'chai'; import { FailedTransactionMetadata, LiteSVM } from 'litesvm'; import { @@ -21,6 +22,11 @@ import { workInfoDecoder, } from '../ts'; +// Serialized `EnhancedAddressInfo` for the values used below (borsh: 4-byte length-prefixed strings). +const enhancedAddressInfoLength = 4 + 5 + 1 + 4 + 8 + 4 + 7 + 4 + 8 + 4; +// LiteSVM's default fee for a single-signature transaction. +const TRANSACTION_FEE = 5_000n; + describe('Realloc!', () => { const svm = new LiteSVM(); let programId: Address; @@ -80,6 +86,52 @@ describe('Realloc!', () => { printWorkInfo(testAccount.address); }); + it('Reallocate WITHOUT zero init when the account already holds more than the new rent-exempt minimum', async () => { + const overfundedAccount = await generateKeyPairSigner(); + await sendTransaction( + createCreateInstruction(overfundedAccount, payer, programId, 'Jacob', 123, 'Main St.', 'Chicago'), + ); + + // Anyone can push the account above the enlarged rent-exempt minimum with a plain system transfer. + const enlargedMinimum = svm.minimumBalanceForRentExemption(BigInt(enhancedAddressInfoLength)); + await sendTransaction( + getTransferSolInstruction({ + amount: lamports(enlargedMinimum), + destination: overfundedAccount.address, + source: payer, + }), + ); + + const targetLamportsBefore = svm.getBalance(overfundedAccount.address); + const payerLamportsBefore = svm.getBalance(payer.address); + assert(targetLamportsBefore !== null && payerLamportsBefore !== null); + assert(targetLamportsBefore > enlargedMinimum); + + const ix = createReallocateWithoutZeroInitInstruction( + overfundedAccount.address, + payer, + programId, + 'Illinois', + 12345, + ); + await sendTransaction(ix); + + const account = svm.getAccount(overfundedAccount.address); + assert(account.exists, 'test account not found'); + assert.strictEqual(account.data.length, enhancedAddressInfoLength); + const enhancedAddressInfo = enhancedAddressInfoDecoder.decode(account.data); + assert.strictEqual(enhancedAddressInfo.name, 'Jacob'); + assert.strictEqual(enhancedAddressInfo.house_number, 123); + assert.strictEqual(enhancedAddressInfo.street, 'Main St.'); + assert.strictEqual(enhancedAddressInfo.city, 'Chicago'); + assert.strictEqual(enhancedAddressInfo.state, 'Illinois'); + assert.strictEqual(enhancedAddressInfo.zip, 12345); + + // No top-up was needed: the target keeps its balance and the payer only pays the transaction fee. + assert.strictEqual(svm.getBalance(overfundedAccount.address), targetLamportsBefore); + assert.strictEqual(payerLamportsBefore - svm.getBalance(payer.address)!, TRANSACTION_FEE); + }); + function printAddressInfo(address: Address): void { const account = svm.getAccount(address); if (account.exists) { diff --git a/basics/realloc/pinocchio/program/src/instructions/reallocate.rs b/basics/realloc/pinocchio/program/src/instructions/reallocate.rs index 8119311cc..2465ce740 100644 --- a/basics/realloc/pinocchio/program/src/instructions/reallocate.rs +++ b/basics/realloc/pinocchio/program/src/instructions/reallocate.rs @@ -16,9 +16,10 @@ pub fn reallocate_without_zero_init(accounts: &mut [AccountView], instruction_da let account_span = EnhancedAddressInfo::LEN; let lamports_required = (Rent::get()?).try_minimum_balance(account_span)?; - let diff = lamports_required - target_account.lamports(); - - Transfer { from: payer, to: target_account, lamports: diff }.invoke()?; + let diff = lamports_required.saturating_sub(target_account.lamports()); + if diff > 0 { + Transfer { from: payer, to: target_account, lamports: diff }.invoke()?; + } target_account.resize(account_span)?; diff --git a/basics/realloc/pinocchio/tests/realloc.test.ts b/basics/realloc/pinocchio/tests/realloc.test.ts index a205013fe..6173348e8 100644 --- a/basics/realloc/pinocchio/tests/realloc.test.ts +++ b/basics/realloc/pinocchio/tests/realloc.test.ts @@ -21,7 +21,7 @@ import { setTransactionMessageFeePayerSigner, signTransactionMessageWithSigners, } from '@solana/kit'; -import { SYSTEM_PROGRAM_ADDRESS } from '@solana-program/system'; +import { getTransferSolInstruction, SYSTEM_PROGRAM_ADDRESS } from '@solana-program/system'; import { assert } from 'chai'; import { FailedTransactionMetadata, LiteSVM } from 'litesvm'; @@ -73,6 +73,10 @@ const workInfoDecoder = getStructDecoder([ ['yearsEmployed', getU8Decoder()], ]); +const ENHANCED_ADDRESS_INFO_LEN = 37; +// LiteSVM's default fee for a single-signature transaction. +const TRANSACTION_FEE = 5_000n; + describe('Realloc!', () => { const svm = new LiteSVM(); let programId: Address; @@ -182,4 +186,66 @@ describe('Realloc!', () => { assert.strictEqual(workInfo.company, 'Anza'); assert.strictEqual(workInfo.yearsEmployed, 2); }); + it('Reallocate WITHOUT zero init when the account already holds more than the new rent-exempt minimum', async () => { + const overfundedAccount = await generateKeyPairSigner(); + const createIx = { + programAddress: programId, + accounts: [ + { address: overfundedAccount.address, role: AccountRole.WRITABLE_SIGNER, signer: overfundedAccount }, + { address: payer.address, role: AccountRole.WRITABLE_SIGNER, signer: payer }, + { address: SYSTEM_PROGRAM_ADDRESS, role: AccountRole.READONLY }, + ], + data: createEncoder.encode({ + discriminator: 0, + name: 'Jacob', + houseNumber: 123, + street: 'Main St.', + city: 'Chicago', + }), + }; + await sendInstruction(createIx); + + // Anyone can push the account above the enlarged rent-exempt minimum with a plain system transfer. + const enlargedMinimum = svm.minimumBalanceForRentExemption(BigInt(ENHANCED_ADDRESS_INFO_LEN)); + await sendInstruction( + getTransferSolInstruction({ + amount: lamports(enlargedMinimum), + destination: overfundedAccount.address, + source: payer, + }), + ); + + const targetLamportsBefore = svm.getBalance(overfundedAccount.address); + const payerLamportsBefore = svm.getBalance(payer.address); + assert(targetLamportsBefore !== null && payerLamportsBefore !== null); + assert(targetLamportsBefore > enlargedMinimum); + + const reallocIx = { + programAddress: programId, + accounts: [ + { address: overfundedAccount.address, role: AccountRole.WRITABLE }, + { address: payer.address, role: AccountRole.WRITABLE_SIGNER, signer: payer }, + { address: SYSTEM_PROGRAM_ADDRESS, role: AccountRole.READONLY }, + ], + data: reallocWithoutZeroInitEncoder.encode({ discriminator: 1, state: 'Illinois', zip: 12345 }), + }; + await sendInstruction(reallocIx); + + const account = svm.getAccount(overfundedAccount.address); + assert(account.exists, 'test account not found'); + const data = new Uint8Array(account.data); + assert.strictEqual(data.length, ENHANCED_ADDRESS_INFO_LEN); + + const addressInfo = enhancedAddressInfoDecoder.decode(data); + assert.strictEqual(addressInfo.name, 'Jacob'); + assert.strictEqual(addressInfo.houseNumber, 123); + assert.strictEqual(addressInfo.street, 'Main St.'); + assert.strictEqual(addressInfo.city, 'Chicago'); + assert.strictEqual(addressInfo.state, 'Illinois'); + assert.strictEqual(addressInfo.zip, 12345); + + // No top-up was needed: the target keeps its balance and the payer only pays the transaction fee. + assert.strictEqual(svm.getBalance(overfundedAccount.address), targetLamportsBefore); + assert.strictEqual(payerLamportsBefore - svm.getBalance(payer.address)!, TRANSACTION_FEE); + }); });