Skip to content
This repository was archived by the owner on Sep 8, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/local-validator.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ jobs:
- name: Start Solana mainnet-beta fork in background
run: |
MANAGER_PUBKEY=$(solana address -k manager_keypair.json)
cargo run --bin doublezero-solana-fork -- -um --reset --synthetic-vcr-manager "$MANAGER_PUBKEY" > /dev/null 2>&1 &
cargo run --bin doublezero-solana-fork -- -um --reset --synthetic-validator-client-rewards-manager "$MANAGER_PUBKEY" > /dev/null 2>&1 &
- name: Build `doublezero-solana`
run: cargo build --bin doublezero-solana
- name: Run `doublezero-solana` tests
Expand Down
6 changes: 6 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

- Always write "onchain", never "on-chain".
- **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.
- **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.
- **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.
- **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.

Expand Down Expand Up @@ -44,3 +45,8 @@
### Tests

- **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.

### Zero-copy account reads

- **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.
- **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.
1 change: 1 addition & 0 deletions crates/solana-cli/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

- `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
- `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)
- `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)
- `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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ use anyhow::{Context, Result, bail, ensure};
use clap::Args;
use doublezero_cli_core::CliContext;
use doublezero_solana_client_tools::{
account::zero_copy::ZeroCopyAccountOwnedData,
payer::{TransactionOutcome, Wallet},
rpc::try_fetch_multiple_accounts,
};
Expand All @@ -15,9 +16,8 @@ use doublezero_solana_sdk::{
account::ClaimValidatorClientRewardsAccounts,
},
state::{
find_claim_holding_address, find_program_config_address,
ValidatorClientRewards, find_claim_holding_address, find_program_config_address,
find_validator_client_rewards_address, parse_program_config_shred_oracle_key,
parse_validator_client_rewards,
},
},
try_build_instruction,
Expand Down Expand Up @@ -129,13 +129,14 @@ impl ClaimCommand {
self.client_id
)
})?;
let validator_client_rewards_info = parse_validator_client_rewards(
&validator_client_rewards_account.data,
)
.with_context(|| {
format!("failed to parse ValidatorClientRewards at {validator_client_rewards_key}")
})?;
validate_manager(&wallet_key, &validator_client_rewards_info.manager_key)?;
let validator_client_rewards =
ZeroCopyAccountOwnedData::<ValidatorClientRewards>::from_account(
validator_client_rewards_account,
)
.with_context(|| {
format!("failed to decode ValidatorClientRewards at {validator_client_rewards_key}")
})?;
validate_manager(&wallet_key, &validator_client_rewards.manager_key)?;

let config_account = accounts
.get(1)
Expand All @@ -147,7 +148,7 @@ impl ClaimCommand {
// Resolve the set of holdings to claim: explicit epochs (validated), or
// every outstanding holding discovered on chain.
let holdings = if self.subscription_epochs.is_empty() {
let target = validator_client_rewards_info.claim_holding_count as usize;
let target = validator_client_rewards.claim_holding_count as usize;
if target == 0 {
writeln!(
out,
Expand Down Expand Up @@ -354,28 +355,25 @@ impl ClaimCommand {

// Re-fetch the validator client rewards account to report the
// post-tx claim_holding_count.
let post_count = match wallet
match wallet
.connection
.get_account_with_commitment(
.try_fetch_zero_copy_data_with_commitment::<ValidatorClientRewards>(
&validator_client_rewards_key,
CommitmentConfig::confirmed(),
)
.await
{
Ok(response) => response.value.and_then(|account| {
parse_validator_client_rewards(&account.data)
.map(|info| info.claim_holding_count)
}),
Ok(refetched) => writeln!(
out,
"Remaining claim holding count: {}",
refetched.claim_holding_count
)?,
Err(err) => {
eprintln!(
"warning: post-claim validator client rewards re-fetch failed: {err}"
);
None
writeln!(out, "Remaining claim holding count: (unavailable)")?;
}
};
match post_count {
Some(count) => writeln!(out, "Remaining claim holding count: {count}")?,
None => writeln!(out, "Remaining claim holding count: (unavailable)")?,
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ use doublezero_solana_sdk::{
ID,
instruction::{ShredSubscriptionInstructionData, account::InitializeClaimHoldingAccounts},
state::{
find_claim_holding_address, find_validator_client_rewards_address,
parse_validator_client_rewards,
ValidatorClientRewards, find_claim_holding_address,
find_validator_client_rewards_address,
},
},
try_build_instruction,
Expand Down Expand Up @@ -60,42 +60,42 @@ impl InitHoldingCommand {
let wallet = crate::command::build_wallet(ctx, self.write_opts)?;
let wallet_key = wallet.pubkey();

let vcr_key = find_validator_client_rewards_address(self.client_id).0;
let validator_client_rewards_key = find_validator_client_rewards_address(self.client_id).0;

// Verify VCR exists and has the right discriminator.
let vcr_account = wallet
wallet
.connection
.get_account_with_commitment(&vcr_key, CommitmentConfig::confirmed())
.try_fetch_zero_copy_data_with_commitment::<ValidatorClientRewards>(
&validator_client_rewards_key,
CommitmentConfig::confirmed(),
)
.await
.with_context(|| format!("fetching VCR PDA {vcr_key}"))?
.value;
let vcr_data = match vcr_account {
Some(acct) => acct.data,
None => bail!(
"validator client rewards not initialized for client-id {} (PDA {})",
self.client_id,
vcr_key
),
};
if parse_validator_client_rewards(&vcr_data).is_none() {
bail!(
"account at {vcr_key} is not a ValidatorClientRewards (unexpected discriminator or data layout)"
);
}
.with_context(|| {
format!(
"failed to read validator client rewards for client-id {} (PDA {validator_client_rewards_key})",
self.client_id
)
})?;

// Pre-flight: filter epochs whose holding account already exists.
let holding_keys: Vec<Pubkey> = self
let holding_keys = self
.subscription_epochs
.iter()
.map(|epoch| find_claim_holding_address(&vcr_key, *epoch, &self.rewards_token_mint).0)
.collect();
.map(|epoch| {
find_claim_holding_address(
&validator_client_rewards_key,
*epoch,
&self.rewards_token_mint,
)
.0
})
.collect::<Vec<_>>();
let holding_accounts = wallet
.connection
.get_multiple_accounts(&holding_keys)
.await
.with_context(|| "fetching claim holding accounts")?;

let mut to_init: Vec<(u64, Pubkey)> = Vec::new();
let mut to_init = Vec::new();
for ((epoch, key), maybe_acct) in self
.subscription_epochs
.iter()
Expand Down
Loading
Loading