Skip to content

Commit 70a36ce

Browse files
authored
solana-sdk: mirror ValidatorClientRewards as a zero-copy struct (malbeclabs/doublezero-offchain#410)
Closes #4183 ## Summary #### solana-sdk - Add `ValidatorClientRewards` as a `Pod` mirror of the onchain struct, with a `PrecomputedDiscriminator` impl, a `Default` impl matching the program's, a `checked_short_description` accessor, and a compile-time assertion pinning the account at 184 bytes - Delete the five `VCR_*_OFFSET` constants, `VCR_SHORT_DESCRIPTION_LEN`, `VCR_ACCOUNT_DATA_LEN`, the standalone discriminator constant, `parse_validator_client_rewards`, and `ValidatorClientRewardsInfo`. None of them shipped, so the changelog bullet that introduced them is rewritten rather than paired with a removal entry - Fold the new mirror into the existing mirrored-layout section alongside `ShredRewardToken` and `ValidatorPublisherRewards` rather than opening a second one #### solana-cli - Read the account through the mirror in `show`, `claim`, and `init-holding`. All three decode the whole struct, so they require at least the 184 bytes the program allocates - `claim` takes its post-transaction count from `SolanaConnection::try_fetch_zero_copy_data_with_commitment`, which warns on a missing or undecodable account where the previous code printed `(unavailable)` silently #### solana-fork - Build the synthetic account from `ValidatorClientRewards::default()` plus field assignment instead of copying bytes to hand-written offsets, and size the rent exemption from the built buffer. The parse-back round trip and its three `ensure!` checks go with them, being tautological against a struct the same function builds - Rename `--synthetic-vcr-manager` to `--synthetic-validator-client-rewards-manager`, along with the fork test script and the local-validator workflow #### repo - Record the conventions this change was written under in `CLAUDE.md`: the `_key` suffix for pubkey bindings, and which helper to reach for when reading a zero-copy account ## Testing - Unit tests across the three crates, including three cases for `checked_short_description` and an exact-match assertion pinning the rendered `show` summary byte for byte, which is what guards the format string's line continuations against a dropped escape - The `local-validator` workflow drives `show`, `init-holding`, and `claim` against a synthetic account baked into the fork at genesis, covering the renamed flag and the mirror's field offsets end to end
1 parent e293786 commit 70a36ce

11 files changed

Lines changed: 235 additions & 296 deletions

File tree

offchain/.github/workflows/local-validator.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ jobs:
3939
- name: Start Solana mainnet-beta fork in background
4040
run: |
4141
MANAGER_PUBKEY=$(solana address -k manager_keypair.json)
42-
cargo run --bin doublezero-solana-fork -- -um --reset --synthetic-vcr-manager "$MANAGER_PUBKEY" > /dev/null 2>&1 &
42+
cargo run --bin doublezero-solana-fork -- -um --reset --synthetic-validator-client-rewards-manager "$MANAGER_PUBKEY" > /dev/null 2>&1 &
4343
- name: Build `doublezero-solana`
4444
run: cargo build --bin doublezero-solana
4545
- name: Run `doublezero-solana` tests

offchain/CLAUDE.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
- Always write "onchain", never "on-chain".
88
- **No abbreviations for account or variable names — anywhere.** Spell out `validator_client_rewards`, not `vcr`. `validator_publisher_rewards`, not `vpr`. `shred_distribution`, not `sd`. This applies to source variable names, test bindings, code comments, program logs, PR titles, branch names, commit subjects, and any prose in this codebase. Acronyms that are universally known in the Solana/SPL ecosystem are fine — `tx`, `PDA`, `ATA`, `CPI`, `SPL`, `SVM`, `IDL`. Project-specific shorthand is not — write out the full name even if it appears many times in a single function.
9+
- **Bindings that hold a `Pubkey` end in `_key`.** `manager_ata_key`, `program_config_key`, `validator_client_rewards_key`, and `holding_keys` for a collection. Covers locals, function parameters, and struct fields. An address and the account it names are usually both in scope, and a bare `destination` leaves the reader to work out which one they have. `node_id` keeps its spelling, since the name already denotes an address.
910
- **The `try_` prefix is reserved for functions returning `Result`.** A fallible-looking function returning `bool` or `Option` takes a plain name (`add_requested_feeds(...) -> bool`, not `try_add_requested_feeds`). Giving an existing function a `Result` return means renaming it to `try_`, and its callers with it. Where an existing `try_`-named function returns something else, treat it as a straggler rather than a pattern to copy.
1011
- **Prose in this repo uses no contractions, no emdashes, and no sentence-chaining semicolons, and American spelling throughout.** Write "do not" rather than "don't", a comma or a period or parentheses rather than an emdash, two sentences rather than one joined by a semicolon, and "behavior" rather than "behaviour". This covers code comments, docstrings, READMEs, CHANGELOG entries, commit subjects, and PR and issue bodies. Semicolons inside a list are fine. The rule is for text you write or substantively change. Existing text, including the older parts of this file, is not worth a cleanup pass.
1112

@@ -44,3 +45,8 @@
4445
### Tests
4546

4647
- **Every test function name starts with `test_`.** Write `#[test] fn test_<what_it_checks>()`, not `#[test] fn <what_it_checks>()`. Applies to `#[test]`, `#[tokio::test]`, unit tests, and integration tests alike.
48+
49+
### Zero-copy account reads
50+
51+
- **Read an account through `SolanaConnection::try_fetch_zero_copy_data_with_commitment::<T>`.** It fetches, checks the discriminator, and checks the layout in one call. Reach for `ZeroCopyAccountOwnedData::from_account` only when the `Account` is already in hand, such as one element of a batched `try_fetch_multiple_accounts`, or when an absent account is an outcome the command reports itself rather than an error, since the helper folds absence into `Err`. `checked_from_bytes_with_discriminator` is for the case where only the bytes are in hand. Do not hand-roll the sequence of `get_account_with_commitment`, `.value`, and a discriminator check.
52+
- **A `.with_context` message must be true for every error its call can return.** `try_fetch_zero_copy_data_with_commitment` returns `Err` for a transport failure, an absent account, and undecodable data alike, so a context reading "not initialized" states a diagnosis the code never established, and it is wrong whenever the RPC is unreachable. Name the read that failed and let the cause chain carry which failure it was.

offchain/crates/solana-cli/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
- `shreds validator-client-rewards`: read the `ValidatorClientRewards` account through the SDK's zero-copy mirror instead of the hand-written byte-offset parser. Every subcommand now requires at least 184 bytes of account data rather than 116
1011
- `shreds pay`: print the fastshreds.com transition notice, and require a y/N confirmation when the operator can actually answer it. The notice fires before the wallet is built and before the first RPC call, so declining needs neither a loadable keypair nor a reachable cluster and signs nothing. The prompt requires *both* stdin and stdout to be a terminal: the prompt is written to stdout, so under a redirect (`> pay.log`, `| tee`) the question would land in the file while `read_line` blocked on a terminal showing nothing. Dry runs print the notice without prompting, matching the epoch-remaining prompt, because a simulation signs and sends nothing. A non-terminal stdin also prints without prompting — `read_line` there returns EOF, which the helper reads as "no", and keypair-from-stdin requires a non-terminal stdin, so a prompt would consume the keypair bytes. `--accept-deprecation-notice` skips the prompt for interactive batch and multi-seat workflows; the notice still prints. Declining exits non-zero, matching the epoch-remaining prompt, so a `shreds pay ... && ...` chain does not treat a decline as a completed payment (malbeclabs/infra#2164)
1112
- `shreds pay`: check the `--amount` floor against the price the program actually charges. A new instant seat allocation is priced from the metro/device ring entries at the execution controller's `last_settled_epoch` (the seat covers the remainder of the epoch currently being served), not from the newest entries — those two agree only while prices are static, so the epoch a metro repriced the preflight passed an underfunded amount through to an opaque `invalid account data for instruction`. A pure escrow top-up for an already-active seat submits no `RequestInstantSeatAllocation` and keeps its floor at the newest entry's price. When either ring has no entry for `last_settled_epoch` the command refuses to submit instead of falling back to the newest entry, mirroring the program's own error. The rejection message now names both prices and both epochs (#405)
1213
- `shreds price`: new `Instant Price (USDC)` column (`instant_allocation_price` in `--json`) with the amount `shreds pay` charges right now, alongside the existing `Epoch Price` (unchanged: what the next settlement charges). The two differ for one epoch after a metro reprices. Empty when the execution controller or a ring entry for that epoch is missing; the rest of the listing still prints (#405)

offchain/crates/solana-cli/src/command/shreds/validator_client_rewards/claim.rs

Lines changed: 19 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ use anyhow::{Context, Result, bail, ensure};
44
use clap::Args;
55
use doublezero_cli_core::CliContext;
66
use doublezero_solana_client_tools::{
7+
account::zero_copy::ZeroCopyAccountOwnedData,
78
payer::{TransactionOutcome, Wallet},
89
rpc::try_fetch_multiple_accounts,
910
};
@@ -15,9 +16,8 @@ use doublezero_solana_sdk::{
1516
account::ClaimValidatorClientRewardsAccounts,
1617
},
1718
state::{
18-
find_claim_holding_address, find_program_config_address,
19+
ValidatorClientRewards, find_claim_holding_address, find_program_config_address,
1920
find_validator_client_rewards_address, parse_program_config_shred_oracle_key,
20-
parse_validator_client_rewards,
2121
},
2222
},
2323
try_build_instruction,
@@ -129,13 +129,14 @@ impl ClaimCommand {
129129
self.client_id
130130
)
131131
})?;
132-
let validator_client_rewards_info = parse_validator_client_rewards(
133-
&validator_client_rewards_account.data,
134-
)
135-
.with_context(|| {
136-
format!("failed to parse ValidatorClientRewards at {validator_client_rewards_key}")
137-
})?;
138-
validate_manager(&wallet_key, &validator_client_rewards_info.manager_key)?;
132+
let validator_client_rewards =
133+
ZeroCopyAccountOwnedData::<ValidatorClientRewards>::from_account(
134+
validator_client_rewards_account,
135+
)
136+
.with_context(|| {
137+
format!("failed to decode ValidatorClientRewards at {validator_client_rewards_key}")
138+
})?;
139+
validate_manager(&wallet_key, &validator_client_rewards.manager_key)?;
139140

140141
let config_account = accounts
141142
.get(1)
@@ -147,7 +148,7 @@ impl ClaimCommand {
147148
// Resolve the set of holdings to claim: explicit epochs (validated), or
148149
// every outstanding holding discovered on chain.
149150
let holdings = if self.subscription_epochs.is_empty() {
150-
let target = validator_client_rewards_info.claim_holding_count as usize;
151+
let target = validator_client_rewards.claim_holding_count as usize;
151152
if target == 0 {
152153
writeln!(
153154
out,
@@ -354,28 +355,25 @@ impl ClaimCommand {
354355

355356
// Re-fetch the validator client rewards account to report the
356357
// post-tx claim_holding_count.
357-
let post_count = match wallet
358+
match wallet
358359
.connection
359-
.get_account_with_commitment(
360+
.try_fetch_zero_copy_data_with_commitment::<ValidatorClientRewards>(
360361
&validator_client_rewards_key,
361362
CommitmentConfig::confirmed(),
362363
)
363364
.await
364365
{
365-
Ok(response) => response.value.and_then(|account| {
366-
parse_validator_client_rewards(&account.data)
367-
.map(|info| info.claim_holding_count)
368-
}),
366+
Ok(refetched) => writeln!(
367+
out,
368+
"Remaining claim holding count: {}",
369+
refetched.claim_holding_count
370+
)?,
369371
Err(err) => {
370372
eprintln!(
371373
"warning: post-claim validator client rewards re-fetch failed: {err}"
372374
);
373-
None
375+
writeln!(out, "Remaining claim holding count: (unavailable)")?;
374376
}
375-
};
376-
match post_count {
377-
Some(count) => writeln!(out, "Remaining claim holding count: {count}")?,
378-
None => writeln!(out, "Remaining claim holding count: (unavailable)")?,
379377
}
380378
}
381379

offchain/crates/solana-cli/src/command/shreds/validator_client_rewards/init_holding.rs

Lines changed: 25 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@ use doublezero_solana_sdk::{
99
ID,
1010
instruction::{ShredSubscriptionInstructionData, account::InitializeClaimHoldingAccounts},
1111
state::{
12-
find_claim_holding_address, find_validator_client_rewards_address,
13-
parse_validator_client_rewards,
12+
ValidatorClientRewards, find_claim_holding_address,
13+
find_validator_client_rewards_address,
1414
},
1515
},
1616
try_build_instruction,
@@ -60,42 +60,42 @@ impl InitHoldingCommand {
6060
let wallet = crate::command::build_wallet(ctx, self.write_opts)?;
6161
let wallet_key = wallet.pubkey();
6262

63-
let vcr_key = find_validator_client_rewards_address(self.client_id).0;
63+
let validator_client_rewards_key = find_validator_client_rewards_address(self.client_id).0;
6464

65-
// Verify VCR exists and has the right discriminator.
66-
let vcr_account = wallet
65+
wallet
6766
.connection
68-
.get_account_with_commitment(&vcr_key, CommitmentConfig::confirmed())
67+
.try_fetch_zero_copy_data_with_commitment::<ValidatorClientRewards>(
68+
&validator_client_rewards_key,
69+
CommitmentConfig::confirmed(),
70+
)
6971
.await
70-
.with_context(|| format!("fetching VCR PDA {vcr_key}"))?
71-
.value;
72-
let vcr_data = match vcr_account {
73-
Some(acct) => acct.data,
74-
None => bail!(
75-
"validator client rewards not initialized for client-id {} (PDA {})",
76-
self.client_id,
77-
vcr_key
78-
),
79-
};
80-
if parse_validator_client_rewards(&vcr_data).is_none() {
81-
bail!(
82-
"account at {vcr_key} is not a ValidatorClientRewards (unexpected discriminator or data layout)"
83-
);
84-
}
72+
.with_context(|| {
73+
format!(
74+
"failed to read validator client rewards for client-id {} (PDA {validator_client_rewards_key})",
75+
self.client_id
76+
)
77+
})?;
8578

8679
// Pre-flight: filter epochs whose holding account already exists.
87-
let holding_keys: Vec<Pubkey> = self
80+
let holding_keys = self
8881
.subscription_epochs
8982
.iter()
90-
.map(|epoch| find_claim_holding_address(&vcr_key, *epoch, &self.rewards_token_mint).0)
91-
.collect();
83+
.map(|epoch| {
84+
find_claim_holding_address(
85+
&validator_client_rewards_key,
86+
*epoch,
87+
&self.rewards_token_mint,
88+
)
89+
.0
90+
})
91+
.collect::<Vec<_>>();
9292
let holding_accounts = wallet
9393
.connection
9494
.get_multiple_accounts(&holding_keys)
9595
.await
9696
.with_context(|| "fetching claim holding accounts")?;
9797

98-
let mut to_init: Vec<(u64, Pubkey)> = Vec::new();
98+
let mut to_init = Vec::new();
9999
for ((epoch, key), maybe_acct) in self
100100
.subscription_epochs
101101
.iter()

0 commit comments

Comments
 (0)