Skip to content

Add account groups and typed remaining accounts#218

Merged
L0STE merged 9 commits into
masterfrom
feat/account-groups-api-clean
May 17, 2026
Merged

Add account groups and typed remaining accounts#218
L0STE merged 9 commits into
masterfrom
feat/account-groups-api-clean

Conversation

@L0STE
Copy link
Copy Markdown
Contributor

@L0STE L0STE commented May 11, 2026

Summary

This PR tightens Quasar's account composition model and adds bounded typed remaining-account parsing. The goal is to make repeated or trailing account sets ergonomic without moving validation logic into handlers or forcing users back to raw RemainingAccounts iteration.

At a high level this adds:

  • fixed byte-array PDA seed parameters, shared by account seed specs and standalone #[derive(Seeds)]
  • account-owned PDA signer helpers for signed CPI
  • AccountsArray<T, N> for fixed-size repeated account groups
  • Remaining<T, N> for bounded typed remaining-account tails
  • lazy rent resolution for lifecycle operations
  • rent-source-aware dynamic account writes
  • removal of #[account(custom)]
  • multisig example coverage using Remaining<Signer, 10>

The branch intentionally does not preserve backwards compatibility for superseded escape hatches. The remaining escape hatches are explicit: raw remaining accounts still exist for unbounded/forwarded tails, and fully custom account wrappers can implement the relevant traits directly.

Account-Owned PDA Signers

PDA signer construction now lives with the account group that owns the PDA instead of being materialized eagerly on the context.

Generated account structs with PDA-addressed fields expose signer helpers like:

let escrow_signer = self.escrow_signer(bumps);
call.invoke_signed(&escrow_signer)?;

This keeps the runtime shape lean:

  • contexts carry validated accounts and bump data, not eager seed bundles
  • signer seeds are built only at signed CPI call sites
  • generated helpers are tied to the account field whose address expression defines the PDA
  • instruction-argument-dependent signer helpers re-run only the needed instruction arg extraction locally

The CPI API now accepts a CpiSignerSeeds source for single PDA signers. Existing direct multi-signer calls remain available through invoke_with_signers.

quasar-metadata was updated for this API boundary: signed metadata helper paths now build explicit Signer::from(seeds) values and call invoke_with_signers, instead of relying on Seed being re-exported from prelude.

Typed PDA Seed Parameters

Seed parameter parsing is shared between account-level #[seeds(...)] and standalone #[derive(Seeds)].

Supported typed seed params now include:

  • Address
  • u8
  • u16
  • u32
  • u64
  • [u8; N] where N <= 32

The byte-array support is useful for fixed identifiers that should not be forced through scalar encoding or ad hoc handler logic. Prefixes and byte-array params are bounded to Solana's 32-byte seed limit at macro time.

Fixed Account Arrays

AccountsArray<T, N> adds a typed repeated-account group:

pub pairs: AccountsArray<SignerPair, 2>

It consumes exactly N * T::COUNT accounts and preserves the normal account-group behavior:

  • typed parsing
  • bump aggregation
  • epilogue forwarding
  • event-CPI propagation
  • nested raw SVM-buffer parsing through ParseAccountsRaw

This replaces handler-side loops for fixed repeated groups with a declarative account type. The derive was updated so AccountsArray is treated as a composite account group and so nested group parsing writes into the declared account-view buffer without extra intermediate copying.

Typed Remaining Accounts

Remaining<T, N> parses a remaining-account tail into a bounded typed slice:

let signers = ctx.remaining_accounts().parse::<Signer, 10>()?;

It supports two cases:

  • single account wrappers, where one item consumes one raw remaining account
  • derived account groups, where one item consumes T::COUNT raw remaining accounts

The parser enforces capacity and chunk completeness. For account wrappers that can expose account data or unchecked views, typed remaining parsing rejects aliases to declared accounts and duplicate remaining aliases. That keeps typed tails from accidentally introducing aliasing hazards.

Signer is intentionally different: Remaining<Signer, N> does not pay duplicate uniqueness scans because signer wrappers do not expose mutable account data. This is account-type-owned behavior, not a handler special case. In the multisig example, duplicate signer metas still do not create extra approvals because approvals are counted against the stored signer set by key.

The typed remaining path has a direct single-account fast path. It avoids routing through the public remaining iterator and avoids the iterator's 64-slot duplicate-resolution cache when the item type does not need that machinery.

Multisig Example

The multisig example now exercises the typed API directly:

let signers = ctx.remaining_accounts().parse::<Signer, 10>()?;
ctx.accounts.verify_and_transfer(amount, &ctx.bumps, signers)

The instruction handlers no longer manually check signer flags or enforce max count. That work belongs to Remaining<Signer, 10> and the Signer account wrapper.

This makes the example a better demonstration of the intended API:

  • bounded tail at the instruction boundary
  • typed signer validation before business logic
  • no raw remaining-account iteration in the handler
  • no duplicated max-count checks

Lazy Rent Resolution

Lifecycle operations now receive an OpCtx<R> where R: RentAccess instead of always receiving a pre-fetched &Rent.

This allows three rent sources:

  • borrowed Rent
  • borrowed &Rent
  • parsed Sysvar<Rent> account
  • lazy RentResolver::fetch_once() when no rent account exists

The derive emits a lazy resolver only when it has to fetch rent itself. If an instruction provides a rent sysvar account, the generated code borrows that account instead. Idempotent/no-op lifecycle paths no longer pay a rent syscall before they know one is needed.

AccountInit now accepts InitCtx<'a, R> where R: RentAccess, and SPL plus metadata init implementations were updated to that contract.

Dynamic Account Writes

Dynamic account write paths now accept rent sources through the same RentAccess abstraction. This keeps realloc/write behavior aligned with init behavior and avoids a separate pre-fetched-rent path just for dynamic account updates.

The branch adds compile coverage for dynamic set_inner with rent access so this remains wired through the derive surface.

Removed #[account(custom)]

#[account(custom)] is removed.

The old mode was an early escape hatch that bypassed normal generated validation and lifecycle behavior. With the newer trait boundaries, the cleaner replacement is explicit: implement AsAccountView, AccountLoad, and any lifecycle traits directly for fully custom wrappers.

The macro now emits a compile-time error for #[account(custom)], and the old compile-pass custom-account test was removed.

Internal Traits

This adds a small set of doc-hidden internal traits to support composition without making the public API noisy:

  • AccountBumps: associates generated account groups with their bump bundle
  • AccountGroup: marker for fixed account groups that can be composed or parsed as typed remaining chunks
  • ParseAccountsRaw: raw SVM-buffer parse path used by nested account groups and AccountsArray
  • RemainingItem: typed remaining-account item contract

These are internal plumbing for the macro and account containers. The public surface remains centered around account types, AccountsArray<T, N>, and Remaining<T, N>.

Performance

Tracked benchmark deltas against origin/master:

VAULT_DEPOSIT_CU                  +0
VAULT_WITHDRAW_CU                 +0
ESCROW_MAKE_CU                    +1
ESCROW_TAKE_CU                    +39
ESCROW_REFUND_CU                  +28
MULTISIG_CREATE_CU                +83
MULTISIG_DEPOSIT_CU               +2
MULTISIG_SET_LABEL_CU             +1
MULTISIG_EXECUTE_TRANSFER_CU      +51
VAULT_SIZE                        +0
ESCROW_SIZE                       +184
MULTISIG_SIZE                     +488

The typed multisig path initially cost substantially more. The final version cuts that down by making typed remaining parsing direct, account-type-owned, and inline-friendly. The remaining multisig delta is the cost of the typed bounded API and additional generated account-group machinery.

Commit Structure

The branch is organized as feature commits:

  1. fixed byte seed parameters
  2. account-owned PDA signer helpers
  3. fixed-size account arrays
  4. bounded typed remaining accounts
  5. lazy rent resolution
  6. removal of custom account mode
  7. rent-source support for dynamic account writes
  8. account-group and typed-remaining refinement
  9. multisig example migration to typed remaining signers

Validation

Local validation run on the final branch:

cargo fmt --check && git diff --check
cargo check -p quasar-metadata -p quasar-multisig -p quasar-lang
cargo test -p quasar-lang --tests
cargo test -p quasar-multisig
cargo test -p quasar-derive --test compile_pass
scripts/bench-tracked-programs.sh compare origin/master

The pre-push hook also passed its full fmt and Clippy gate before the branch was pushed.

L0STE added 9 commits May 11, 2026 16:11
Share typed seed parsing across account and standalone seed specs, including bounded fixed byte arrays for PDA seed inputs.
Move PDA signer construction onto the account group that owns the PDA, while keeping contexts as validated accounts plus bumps.
Introduce AccountsArray<T, N> for bounded repeated account groups that preserve typed parsing, bumps, epilogues, and nested account behavior.
Let handlers parse remaining-account tails into capped typed slices with duplicate and declared-account rejection before use.
Defer rent sysvar fetching until init or realloc actually needs the value, while keeping direct borrowed-rent codegen for explicit rent accounts.
Make account-group composition explicit and let typed remaining tails parse account wrappers or derived account groups through the same internal contract.
Parse bounded signer tails at the instruction boundary so the example exercises the new Remaining API instead of raw remaining-account iteration.
@github-actions
Copy link
Copy Markdown

Benchmark

Vault

Instruction Base PR Delta
Deposit 1,556 1,556 0 ⚪
Withdraw 393 393 0 ⚪
Binary size 5,608 5,608 0 ⚪ bytes

Escrow

Instruction Base PR Delta
Make 21,028 21,029 +1 🔴
Take 29,251 29,290 +39 🔴
Refund 16,939 16,967 +28 🔴
Binary size 44,312 44,496 +184 🔴 bytes

Multisig

Instruction Base PR Delta
Create 3,309 3,392 +83 🔴
Deposit 2,269 2,271 +2 🔴
Set label 2,239 2,240 +1 🔴
Execute transfer 2,691 2,742 +51 🔴
Binary size 25,936 26,424 +488 🔴 bytes

@L0STE L0STE merged commit 39d25cc into master May 17, 2026
13 of 14 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant