Repository: solana-labs/solana-program-library
Pull request #6063 (2024-01-05 02:42:14)

[spl-record] Add `Reallocate` instruction
#### Problem
The record program is useful when storing arbitrary length data on accounts. However, if the data is too long to fit inside an account, it is not possible to record the data without creating a new account with enough space. It would be nice to be able to reallocate more space to the existing account to store longer record data.

#### Summary of changes
Add a reallocate instruction. I followed the main logic of the reallocate instruction in the token-2022 program.

This PR builds on top of https://github.com/solana-labs/solana-program-library/pull/6062. Once https://github.com/solana-labs/solana-program-library/pull/6062 is merged, I will rebase to master to remove the first few commits.


Patch:
diff --git a/record/program/src/instruction.rs b/record/program/src/instruction.rs
index 0c2df24cd..93d4f3ea9 100644
--- a/record/program/src/instruction.rs
+++ b/record/program/src/instruction.rs
@@ -52,19 +52,35 @@ pub enum RecordInstruction<'a> {
     /// 1. `[signer]` Record authority
     /// 2. `[]` Receiver of account lamports
     CloseAccount,
+
+    /// Reallocate additional space in a record account
+    ///
+    /// If the record account already has enough space to hold the specified
+    /// data length, then the instruction does nothing.
+    ///
+    /// Accounts expected by this instruction:
+    ///
+    /// 0. `[writable]` The record account to reallocate
+    /// 1. `[signer]` The account's owner
+    Reallocate {
+        /// The length of the data to hold in the record account excluding meta
+        /// data
+        data_length: u64,
+    },
 }
 
 impl<'a> RecordInstruction<'a> {
     /// Unpacks a byte buffer into a [RecordInstruction].
     pub fn unpack(input: &'a [u8]) -> Result<Self, ProgramError> {
+        const U32_BYTES: usize = 4;
+        const U64_BYTES: usize = 8;
+
         let (&tag, rest) = input
             .split_first()
             .ok_or(ProgramError::InvalidInstructionData)?;
         Ok(match tag {
             0 => Self::Initialize,
             1 => {
-                const U32_BYTES: usize = 4;
-                const U64_BYTES: usize = 8;
                 let offset = rest
                     .get(..U64_BYTES)
                     .and_then(|slice| slice.try_into().ok())
@@ -84,6 +100,15 @@ impl<'a> RecordInstruction<'a> {
             }
             2 => Self::SetAuthority,
             3 => Self::CloseAccount,
+            4 => {
+                let data_length = rest
+                    .get(..U64_BYTES)
+                    .and_then(|slice| slice.try_into().ok())
+                    .map(u64::from_le_bytes)
+                    .ok_or(ProgramError::InvalidInstructionData)?;
+
+                Self::Reallocate { data_length }
+            }
             _ => return Err(ProgramError::InvalidInstructionData),
         })
     }
@@ -101,6 +126,10 @@ impl<'a> RecordInstruction<'a> {
             }
             Self::SetAuthority => buf.push(2),
             Self::CloseAccount => buf.push(3),
+            Self::Reallocate { data_length } => {
+                buf.push(4);
+                buf.extend_from_slice(&data_length.to_le_bytes());
+            }
         };
         buf
     }
@@ -160,6 +189,18 @@ pub fn close_account(record_account: &Pubkey, signer: &Pubkey, receiver: &Pubkey
     }
 }
 
+/// Create a `RecordInstruction::Reallocate` instruction
+pub fn reallocate(record_account: &Pubkey, signer: &Pubkey, data_length: u64) -> Instruction {
+    Instruction {
+        program_id: id(),
+        accounts: vec![
+            AccountMeta::new(*record_account, false),
+            AccountMeta::new_readonly(*signer, true),
+        ],
+        data: RecordInstruction::Reallocate { data_length }.pack(),
+    }
+}
+
 #[cfg(test)]
 mod tests {
     use {super::*, crate::state::tests::TEST_BYTES, solana_program::program_error::ProgramError};
@@ -201,6 +242,16 @@ mod tests {
         assert_eq!(RecordInstruction::unpack(&expected).unwrap(), instruction);
     }
 
+    #[test]
+    fn serialize_reallocate() {
+        let data_length = 16u64;
+        let instruction = RecordInstruction::Reallocate { data_length };
+        let mut expected = vec![4];
+        expected.extend_from_slice(&data_length.to_le_bytes());
+        assert_eq!(instruction.pack(), expected);
+        assert_eq!(RecordInstruction::unpack(&expected).unwrap(), instruction);
+    }
+
     #[test]
     fn deserialize_invalid_instruction() {
         let mut expected = vec![12];
diff --git a/record/program/src/processor.rs b/record/program/src/processor.rs
index 8c6c55939..4d626cc7b 100644
--- a/record/program/src/processor.rs
+++ b/record/program/src/processor.rs
@@ -10,7 +10,7 @@ use {
         program_pack::IsInitialized,
         pubkey::Pubkey,
     },
-    spl_pod::bytemuck::{pod_from_bytes, pod_from_bytes_mut},
+    spl_pod::bytemuck::{pod_from_bytes, pod_from_bytes_mut, pod_get_packed_len},
 };
 
 fn check_authority(authority_info: &AccountInfo, expected_authority: &Pubkey) -> ProgramResult {
@@ -132,5 +132,48 @@ pub fn process_instruction(
                 .ok_or(RecordError::Overflow)?;
             Ok(())
         }
+
+        RecordInstruction::Reallocate { data_length } => {
+            msg!("RecordInstruction::Reallocate");
+            let data_info = next_account_info(account_info_iter)?;
+            let authority_info = next_account_info(account_info_iter)?;
+
+            {
+                let raw_data = &mut data_info.data.borrow_mut();
+                if raw_data.len() < RecordData::WRITABLE_START_INDEX {
+                    return Err(ProgramError::InvalidAccountData);
+                }
+                let account_data = pod_from_bytes_mut::<RecordData>(
+                    &mut raw_data[..RecordData::WRITABLE_START_INDEX],
+                )?;
+                if !account_data.is_initialized() {
+                    msg!("Record not initialized");
+                    return Err(ProgramError::UninitializedAccount);
+                }
+                check_authority(authority_info, &account_data.authority)?;
+            }
+
+            // needed account length is the sum of the meta data length and the specified
+            // data length
+            let needed_account_length = pod_get_packed_len::<RecordData>()
+                .checked_add(
+                    usize::try_from(data_length).map_err(|_| ProgramError::InvalidArgument)?,
+                )
+                .unwrap();
+
+            // reallocate
+            if data_info.data_len() >= needed_account_length {
+                msg!("no additional reallocation needed");
+                return Ok(());
+            }
+            msg!(
+                "reallocating +{:?} bytes",
+                needed_account_length
+                    .checked_sub(data_info.data_len())
+                    .unwrap(),
+            );
+            data_info.realloc(needed_account_length, false)?;
+            Ok(())
+        }
     }
 }


Test patch:
diff --git a/record/program/tests/functional.rs b/record/program/tests/functional.rs
index 6204c5073..4f4d325f2 100644
--- a/record/program/tests/functional.rs
+++ b/record/program/tests/functional.rs
@@ -518,3 +518,179 @@ async fn set_authority_fail_unsigned() {
         TransactionError::InstructionError(0, InstructionError::MissingRequiredSignature)
     );
 }
+
+#[tokio::test]
+async fn reallocate_success() {
+    let mut context = program_test().start_with_context().await;
+
+    let authority = Keypair::new();
+    let account = Keypair::new();
+    let data = &[222u8; 8];
+    initialize_storage_account(&mut context, &authority, &account, data).await;
+
+    let new_data_length = 16u64;
+    let expected_account_data_length = RecordData::WRITABLE_START_INDEX
+        .checked_add(new_data_length as usize)
+        .unwrap();
+
+    let delta_account_data_length = new_data_length.saturating_sub(data.len() as u64);
+    let additional_lamports_needed =
+        Rent::default().minimum_balance(delta_account_data_length as usize);
+
+    let transaction = Transaction::new_signed_with_payer(
+        &[
+            instruction::reallocate(&account.pubkey(), &authority.pubkey(), new_data_length),
+            system_instruction::transfer(
+                &context.payer.pubkey(),
+                &account.pubkey(),
+                additional_lamports_needed,
+            ),
+        ],
+        Some(&context.payer.pubkey()),
+        &[&context.payer, &authority],
+        context.last_blockhash,
+    );
+    context
+        .banks_client
+        .process_transaction(transaction)
+        .await
+        .unwrap();
+
+    let account_handle = context
+        .banks_client
+        .get_account(account.pubkey())
+        .await
+        .unwrap()
+        .unwrap();
+
+    assert_eq!(account_handle.data.len(), expected_account_data_length);
+
+    // reallocate to a smaller length
+    let old_data_length = 8u64;
+    let transaction = Transaction::new_signed_with_payer(
+        &[instruction::reallocate(
+            &account.pubkey(),
+            &authority.pubkey(),
+            old_data_length,
+        )],
+        Some(&context.payer.pubkey()),
+        &[&context.payer, &authority],
+        context.last_blockhash,
+    );
+    context
+        .banks_client
+        .process_transaction(transaction)
+        .await
+        .unwrap();
+
+    let account = context
+        .banks_client
+        .get_account(account.pubkey())
+        .await
+        .unwrap()
+        .unwrap();
+
+    assert_eq!(account.data.len(), expected_account_data_length);
+}
+
+#[tokio::test]
+async fn reallocate_fail_wrong_authority() {
+    let mut context = program_test().start_with_context().await;
+
+    let authority = Keypair::new();
+    let account = Keypair::new();
+    let data = &[222u8; 8];
+    initialize_storage_account(&mut context, &authority, &account, data).await;
+
+    let new_data_length = 16u64;
+    let delta_account_data_length = new_data_length.saturating_sub(data.len() as u64);
+    let additional_lamports_needed =
+        Rent::default().minimum_balance(delta_account_data_length as usize);
+
+    let wrong_authority = Keypair::new();
+    let transaction = Transaction::new_signed_with_payer(
+        &[
+            Instruction {
+                program_id: id(),
+                accounts: vec![
+                    AccountMeta::new(account.pubkey(), false),
+                    AccountMeta::new(wrong_authority.pubkey(), true),
+                ],
+                data: instruction::RecordInstruction::Reallocate {
+                    data_length: new_data_length,
+                }
+                .pack(),
+            },
+            system_instruction::transfer(
+                &context.payer.pubkey(),
+                &account.pubkey(),
+                additional_lamports_needed,
+            ),
+        ],
+        Some(&context.payer.pubkey()),
+        &[&context.payer, &wrong_authority],
+        context.last_blockhash,
+    );
+
+    assert_eq!(
+        context
+            .banks_client
+            .process_transaction(transaction)
+            .await
+            .unwrap_err()
+            .unwrap(),
+        TransactionError::InstructionError(
+            0,
+            InstructionError::Custom(RecordError::IncorrectAuthority as u32)
+        )
+    );
+}
+
+#[tokio::test]
+async fn reallocate_fail_unsigned() {
+    let mut context = program_test().start_with_context().await;
+
+    let authority = Keypair::new();
+    let account = Keypair::new();
+    let data = &[222u8; 8];
+    initialize_storage_account(&mut context, &authority, &account, data).await;
+
+    let new_data_length = 16u64;
+    let delta_account_data_length = new_data_length.saturating_sub(data.len() as u64);
+    let additional_lamports_needed =
+        Rent::default().minimum_balance(delta_account_data_length as usize);
+
+    let transaction = Transaction::new_signed_with_payer(
+        &[
+            Instruction {
+                program_id: id(),
+                accounts: vec![
+                    AccountMeta::new(account.pubkey(), false),
+                    AccountMeta::new(authority.pubkey(), false),
+                ],
+                data: instruction::RecordInstruction::Reallocate {
+                    data_length: new_data_length,
+                }
+                .pack(),
+            },
+            system_instruction::transfer(
+                &context.payer.pubkey(),
+                &account.pubkey(),
+                additional_lamports_needed,
+            ),
+        ],
+        Some(&context.payer.pubkey()),
+        &[&context.payer],
+        context.last_blockhash,
+    );
+
+    assert_eq!(
+        context
+            .banks_client
+            .process_transaction(transaction)
+            .await
+            .unwrap_err()
+            .unwrap(),
+        TransactionError::InstructionError(0, InstructionError::MissingRequiredSignature)
+    );
+}