Dynamic fields from Quasar
Bounded strings and vectors with Hopper layout fingerprints and migration-aware capacities.
This page is a code-facing migration note for teams moving bounded dynamic account data into Hopper. It compares the authoring shape only. Benchmark and market claims stay in the benchmark docs.
Side-by-side shape
Quasar-style dynamic fields keep the account source compact:
#[account]
pub struct Multisig<'a> {
pub threshold: u64,
pub label: String<'a, 32>,
pub signers: Vec<'a, Pubkey, 10>,
}
Hopper keeps the same first-touch shape while lowering it into a fixed body plus a compact bounded tail:
#[hopper::account(discriminator = 7, version = 1)]
pub struct Multisig<'a> {
pub threshold: u64,
pub label: String<'a, 32>,
pub signers: Vec<'a, Address, 10>,
}
The generated Hopper layout preserves a zero-copy fixed body and stores variable bytes in the account tail. That lets hot fixed fields stay predictable while string and vector payloads remain bounded, validated, and schema-visible.
Deliberate compact-tail difference
Hopper's current dynamic tail is deliberately bounded and compact: it stores the payload bytes described by the account schema instead of wrapping every dynamic value in an extra runtime object header. The account header, layout fingerprint, field metadata, and dynamic-tail schema hash are the contract. The tail itself stays compact.
That gives Hopper three properties that matter for stateful programs:
- fixed fields remain zero-copy and stable across reads,
- dynamic fields are bounded by the source type and checked by generated helpers,
- schema and compatibility tools can verify the tail contract without inventing a second serialization model.
Hopper also supports named bare final tails when a protocol really wants remaining-bytes semantics:
#[hopper::account(discriminator = 9, version = 1)]
pub struct Note<'a> {
pub author: Address,
pub content: TailStr<'a>,
}
TailStr<'a> and TailBytes<'a> are explicit opt-ins. They must be the last account field, they are fingerprinted as tail_str or tail_bytes, and they consume the remaining tail payload without an inner field-level length prefix. The Hopper account still carries the outer dynamic-tail u32 payload length so tools can bound the region. TailStr validates UTF-8 when read as text; TailBytes stays binary-safe.
Unlike a fully implicit remaining slice, Hopper lets a raw final tail sit behind bounded compact fields while preserving the account contract:
#[hopper::account(discriminator = 10, version = 1)]
pub struct Note<'a> {
pub author: Address,
pub label: String<'a, 32>,
pub content: TailStr<'a>,
}
The label remains bounded and schema-visible. The content consumes the remaining
payload bytes and is still recorded in the layout fingerprint as tail_str.
Migration pattern
- Keep fixed, hot fields first.
- Use
String<'a, N>andVec<'a, T, N>for bounded dynamic fields. - Pick caps that are protocol rules, not UI guesses.
- Use
TailStr<'a>orTailBytes<'a>only for deliberate final raw tails. - Use generated setters and push helpers so bounds and tail offsets stay checked.
- Export schema during review so clients and migrations see the same tail contract.
impl<'info> RenameMultisig<'info> {
pub fn rename(&self, label: &str) -> ProgramResult {
self.multisig.set_label(label)
}
}
impl<'info> AddSigner<'info> {
pub fn add_signer(&self, signer: Address) -> ProgramResult {
self.multisig.push_unique_signer(signer).map(|_| ())
}
}
The complete working example is ../examples/quasar-port-20-min.
Token-2022 wedge
Dynamic fields often sit beside Token-2022 vault metadata. Hopper keeps those checks in the same zero-copy authoring model: use account constraints or hopper_token_2022 helpers to require or forbid TLV extensions before mutating state. See TOKEN_2022_GUIDE.md for extension policy examples.
