Quasar
Features

Remaining Accounts

Accessing dynamically-sized remaining accounts in instructions.

Some instructions need a variable number of accounts -- a list of signers for a multisig, a set of token accounts for a batch transfer, or extra accounts forwarded to a CPI. Remaining accounts are accounts passed after the declared (typed) accounts in the instruction.

Ctx vs CtxWithRemaining

  • Ctx<T> -- parsed and validated accounts only. For instructions with a fixed set of accounts.
  • CtxWithRemaining<T> -- also captures remaining accounts. For instructions that need additional accounts.

Choose at the instruction signature:

// Fixed accounts only
#[instruction(discriminator = 0)]
pub fn initialize(ctx: Ctx<Initialize>) -> Result<(), ProgramError> {
    ctx.accounts.handler(&ctx.bumps)
}

// With remaining accounts
#[instruction(discriminator = 1)]
pub fn create_multisig(
    ctx: CtxWithRemaining<Create>,
    threshold: u8,
) -> Result<(), ProgramError> {
    ctx.accounts.create_multisig(threshold, &ctx.bumps, ctx.remaining_accounts())
}

The #[program] macro detects which context type you use and generates the appropriate dispatch code.

The RemainingAccounts struct

RemainingAccounts is a zero-allocation accessor over the SVM input buffer. It provides bounds-checked access and automatic duplicate resolution against the declared accounts.

Checking for remaining accounts

let remaining = ctx.remaining_accounts();
if remaining.is_empty() {
    return Err(ProgramError::NotEnoughAccountKeys);
}

Random access

get(index) walks from the buffer start to the requested index (O(n) per call):

let first = remaining.get(0);  // Option<AccountView>
let third = remaining.get(2);  // walks past 0, 1, returns 2

Iteration

iter() returns a RemainingIter that yields accounts lazily:

for account in remaining.iter() {
    let account = account?;  // Result<AccountView, ProgramError>
    // use account...
}

The iterator caches accounts as it walks, enabling O(1) duplicate resolution. Prefer it over get().

Real example: multisig creation

#[derive(Accounts)]
pub struct Create<'info> {
    pub creator: &'info mut Signer,
    #[account(init, mut, payer = creator, seeds = [b"multisig", creator], bump)]
    pub config: Account<MultisigConfig<'info>>,
    pub rent: &'info Sysvar<Rent>,
    pub system_program: &'info Program<System>,
}

impl<'info> Create<'info> {
    pub fn create_multisig(
        &mut self,
        threshold: u8,
        bumps: &CreateBumps,
        remaining: RemainingAccounts,
    ) -> Result<(), ProgramError> {
        let mut addrs = core::mem::MaybeUninit::<[Address; 10]>::uninit();
        let addrs_ptr = addrs.as_mut_ptr() as *mut Address;
        let mut count = 0usize;

        for account in remaining.iter() {
            let account = account?;
            if count >= 10 {
                return Err(ProgramError::InvalidArgument);
            }
            if !account.is_signer() {
                return Err(ProgramError::MissingRequiredSignature);
            }
            unsafe { core::ptr::write(addrs_ptr.add(count), *account.address()) };
            count = count.wrapping_add(1);
        }

        if threshold == 0 || threshold as usize > count {
            return Err(ProgramError::InvalidArgument);
        }

        let signers = unsafe { core::slice::from_raw_parts(addrs_ptr, count) };

        self.config.set_inner(
            *self.creator.address(),
            threshold,
            bumps.config,
            "",
            signers,
            self.creator.to_account_view(),
            Some(&**self.rent),
        )
    }
}

Remaining accounts are unchecked by default -- the handler explicitly verifies is_signer() and bounds the count.

Real example: multisig execution

#[derive(Accounts)]
pub struct ExecuteTransfer<'info> {
    #[account(
        has_one = creator,
        seeds = [b"multisig", creator],
        bump = config.bump
    )]
    pub config: Account<MultisigConfig<'info>>,
    pub creator: &'info UncheckedAccount,
    #[account(mut, seeds = [b"vault", config], bump)]
    pub vault: &'info mut UncheckedAccount,
    pub recipient: &'info mut UncheckedAccount,
    pub system_program: &'info Program<System>,
}

impl<'info> ExecuteTransfer<'info> {
    pub fn verify_and_transfer(
        &self,
        amount: u64,
        bumps: &ExecuteTransferBumps,
        remaining: RemainingAccounts,
    ) -> Result<(), ProgramError> {
        let stored_signers = self.config.signers();
        let threshold = self.config.threshold;

        let mut approvals = 0u32;
        for account in remaining.iter() {
            let account = account?;
            if !account.is_signer() {
                continue;
            }
            let addr = account.address();
            for stored in stored_signers {
                if addr == stored {
                    approvals = approvals.wrapping_add(1);
                    break;
                }
            }
        }

        if approvals < threshold as u32 {
            return Err(ProgramError::MissingRequiredSignature);
        }

        let seeds = bumps.vault_seeds();
        self.system_program
            .transfer(self.vault, self.recipient, amount)
            .invoke_signed(&seeds)
    }
}

Non-signer accounts are silently skipped (with continue), allowing clients to pass additional accounts without breaking the instruction.

Duplicate account handling

The SVM uses a compact encoding for duplicate accounts -- a duplicate entry references the original by index instead of repeating the full data. The iterator handles this transparently: duplicates resolve to the same AccountView as the original, whether it was a declared account or a previously yielded remaining account.

The iterator cache is bounded at 64 entries. Exceeding this returns Err(QuasarError::RemainingAccountsOverflow).

Performance characteristics

OperationCost
ctx.remaining_accounts()O(1) -- returns pre-computed pointers
remaining.is_empty()O(1) -- pointer comparison
remaining.get(n)O(n) -- walks buffer from start
remaining.iter().next()O(1) amortized -- advances one entry
Duplicate resolution (iterator)O(1) -- cache lookup
Duplicate resolution (get)O(n) -- re-walks buffer with 2-hop limit

Prefer iter() over repeated get() calls when processing all remaining accounts.

Guidelines

  1. Use CtxWithRemaining<T> only when needed. If your instruction does not access remaining accounts, use Ctx<T>.

  2. Prefer iter() over get(). The iterator caches for O(1) duplicate resolution. Repeated get() calls re-walk the buffer each time.

  3. Validate remaining accounts explicitly. They are unchecked by design. Check is_signer(), is_writable(), owner, and any other constraints in your handler.

  4. Stay under 64 remaining accounts. The iterator cache is bounded at 64 entries.

  5. Use stack-allocated collection buffers. The multisig example demonstrates the pattern: a MaybeUninit array on the stack, filled by the iterator, with a bounded count.

On this page