Skip to content

Commit dadf5f8

Browse files
builder-stake: size the deposit against the committed rate (#4285)
Implements [B2](malbeclabs/edge-builder#9). Stacked on #4284; review that first, this diff is the six files above the base. ## Summary - `ProgramConfig` gains a tier table: three 2Z amounts, one per RFC-28 rate tier, set by the admin through `ConfigureProgram`. - `InitializeBuilderStake` looks up the amount for the rate the builder commits to and pins it on the stake as `required_2z_amount`. - `BuilderStake::is_funded()` answers whether the stake holds what it owes. - A malformed table is refused: a zero amount, or a higher tier that costs less than a lower one. ## Decisions worth arguing with **A table, not a price feed.** RFC-28 quotes about \$100k / \$200k / \$500k but says the deposit is "denominated in 2Z, fixed at the price prevailing when the tier is set". So an admin writes three numbers and no oracle is involved. **The rate ceilings are compiled in, not configured.** 1 Gbps and 5 Gbps have to agree with `StakeTier::max_rate_bits_per_sec` in the serviceability program on the DZ ledger. If those two drift, a builder funds one tier here and its feed is checked against a different one there. Two configurable copies is exactly how that drift happens. **The requirement is pinned per stake, not read live.** Repricing a tier does not move what an existing stake owes, so a builder who fully funded a stake cannot wake up under-funded because the admin changed a number. This is the stronger reading of "fixed at the price prevailing when the tier is set", and it is one field. There is a test for it. **`Deposit` still accepts any positive amount, which is a departure from how issue #9 was written.** Requiring the full amount in one transfer would make "a stake exists implies it is funded" true, which is tempting. Against it: a builder may reasonably fund from more than one source or split a large transfer, and nothing bad happens with a short stake, because only a funded stake gets mirrored to the DZ ledger and so a short one backs no feed. The guard that actually matters is `Withdraw` in [B3](malbeclabs/edge-builder#10), which must not drop a stake below its requirement regardless. Over-funding is allowed too, since RFC-28 lets a builder withdraw the excess after the hold. **An unset table sizes nothing rather than sizing everything at zero.** `required_2z_amount` returns `None` for a zero entry, so a program unpaused before it was configured takes no stake instead of handing out free ones. ## Struct change `BuilderStake` grew `required_2z_amount`, so its size assertion moves from 136 to 144. Nothing is deployed, so there is no migration. I also dropped the `ProgramConfig` size assertion: it is allocated at 10kb, so a new setting grows into slack, which is the same reasoning `revenue-distribution` gives. ## Testing Verification `make test-sbf`, 16 tests (3 unit, 12 integration, plus the id check): - Every tier boundary, at and either side of each ceiling: 1 Gbps and one unit over, 5 Gbps and one unit over, and `u64::MAX`. - A stake starts unfunded, two part-deposits reach the requirement with the last unit tipping it over, and over-funding is accepted. - An unpaused program with no tier table refuses to take a stake at all. - Three malformed tables are refused (a hole at the first tier, 5 Gbps cheaper than 1 Gbps, unmetered cheaper than 5 Gbps), and a well-formed one lands and reads back. - Raising every tier tenfold leaves an existing funded stake funded and its requirement unchanged, while the next stake posted pays the new price. - The seven B1 tests still pass unchanged. ## Size About 90 non-test lines.
1 parent ac60ab1 commit dadf5f8

9 files changed

Lines changed: 565 additions & 14 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ All notable changes to this project will be documented in this file.
1919
- The TypeScript and Python `Feed` deserializers read the RFC-28 tail and synthesize `Active` for an account that carries no status byte, matching the Rust program. New `feed_legacy` fixture covers that path alongside the updated `feed` fixture.
2020
- Solana programs (`solana/`)
2121
- New `builder-stake` program at `dzbschFChpPoWihZFdnYjyzHJicZwPHb6QTntHjhLki`, holding the 2Z bond a builder posts before deploying a feed under RFC-28. A `BuilderStake` PDA, a 2Z token account owned by it, and `InitializeProgram`, `SetAdmin`, `ConfigureProgram`, `InitializeBuilderStake` and `PostBond`. A bond rather than a deposit: it is returnable after the hold and forfeitable by slashing, and `deposit` carries neither. The address is keyed on `(builder, stake_index)` rather than the builder alone, because RFC-28 collateralizes each feed on its own bond and a builder-only address would cap a builder at one stake for life. The program starts paused, so a deployment with no admin and no tier table holds nothing. No slash instruction yet: the burn authority is what makes this its own deployable, and writing it before the verdict signer is settled means writing it twice. Bond sizing and the six-month hold are not here either.
22+
- `builder-stake` sizes a bond against the rate its feed commits to. An admin sets three 2Z amounts, one per RFC-28 rate tier; RFC-28 quotes the tiers in dollars but fixes the bond in 2Z at the price prevailing when the tier is set, so this is a table rather than a price feed. The rate ceilings are compiled in, because they have to agree with `StakeTier` in the serviceability program. A stake's requirement follows the tier table while the stake is short and stops moving once it is funded, so repricing cannot under-fund a builder who already paid in full, and cannot be dodged by pre-creating stakes for the cost of rent and funding them after a rise. A table with a hole, or one where more rate costs less, is refused.
2223

2324
## [v0.39.0](https://github.com/malbeclabs/doublezero/compare/client/v0.38.0...client/v0.39.0) - 2026-09-04
2425

solana/programs/builder-stake/src/instruction/mod.rs

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,14 @@ use solana_pubkey::Pubkey;
44
#[derive(Debug, BorshDeserialize, BorshSerialize, Clone, PartialEq, Eq)]
55
pub enum ProgramConfiguration {
66
Flag(ProgramFlagConfiguration),
7+
8+
/// The 2Z a bond costs at each rate tier. All three must be non-zero and must not decrease
9+
/// as the rate rises.
10+
TierParameters {
11+
up_to_1gbps_2z_amount: u64,
12+
up_to_5gbps_2z_amount: u64,
13+
unmetered_2z_amount: u64,
14+
},
715
}
816

917
#[derive(Debug, BorshDeserialize, BorshSerialize, Clone, PartialEq, Eq)]
@@ -28,8 +36,8 @@ pub enum BuilderStakeInstructionData {
2836
///
2937
/// `stake_index` distinguishes a builder's stakes from each other and is part of the address,
3038
/// so creating the same index twice fails. `committed_rate_bits_per_sec` is the rate the feed
31-
/// backed by this stake may commit to. It is recorded here and nothing acts on it yet:
32-
/// `PostBond` takes any positive amount. Sizing the bond against the rate is B2.
39+
/// backed by this stake may commit to. It selects the tier, and the tier sets the bond this
40+
/// stake has to hold, pinned on the account as `required_2z_amount`.
3341
InitializeBuilderStake {
3442
stake_index: u64,
3543
committed_rate_bits_per_sec: u64,

solana/programs/builder-stake/src/processor.rs

Lines changed: 81 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,15 @@ use spl_token_interface::instruction as token_instruction;
2121

2222
use crate::{
2323
instruction::{BuilderStakeInstructionData, ProgramConfiguration, ProgramFlagConfiguration},
24-
state::{self, BuilderStake, ProgramConfig},
24+
state::{self, BuilderStake, ProgramConfig, TierParameters},
2525
DOUBLEZERO_MINT_KEY, ID,
2626
};
2727

2828
// A change to either size means every deployed account of that type has to be migrated, so make
29-
// the change deliberate rather than incidental.
30-
const _: () = assert!(size_of::<BuilderStake>() == 136);
29+
// the change deliberate rather than incidental. The program config is allocated at 10kb and so
30+
// never needs a realloc, but its check is what catches a new setting that overruns the storage gap
31+
// instead of coming out of it.
32+
const _: () = assert!(size_of::<BuilderStake>() == 144);
3133
const _: () = assert!(size_of::<ProgramConfig>() == 176);
3234

3335
solana_program_entrypoint::entrypoint!(try_process_instruction);
@@ -160,6 +162,37 @@ fn try_configure_program(accounts: &[AccountInfo], setting: ProgramConfiguration
160162
msg!("is_paused: {}", paused);
161163
program_config.set_is_paused(paused);
162164
}
165+
ProgramConfiguration::TierParameters {
166+
up_to_1gbps_2z_amount,
167+
up_to_5gbps_2z_amount,
168+
unmetered_2z_amount,
169+
} => {
170+
let tier_parameters = TierParameters::new(
171+
up_to_1gbps_2z_amount,
172+
up_to_5gbps_2z_amount,
173+
unmetered_2z_amount,
174+
);
175+
176+
// A table with a hole or a cheaper high tier is a mistake that would let a builder
177+
// deploy a fast feed against a small bond, so it never reaches the account.
178+
if !tier_parameters.is_well_formed() {
179+
msg!(
180+
"Tier amounts must be non-zero and must not decrease: {}, {}, {}",
181+
up_to_1gbps_2z_amount,
182+
up_to_5gbps_2z_amount,
183+
unmetered_2z_amount
184+
);
185+
return Err(ProgramError::InvalidInstructionData);
186+
}
187+
188+
msg!(
189+
"tier_parameters: {}, {}, {}",
190+
up_to_1gbps_2z_amount,
191+
up_to_5gbps_2z_amount,
192+
unmetered_2z_amount
193+
);
194+
program_config.tier_parameters = tier_parameters;
195+
}
163196
}
164197

165198
Ok(())
@@ -193,6 +226,19 @@ fn try_initialize_builder_stake(
193226
ZeroCopyAccount::<ProgramConfig>::try_next_accounts(&mut accounts_iter, Some(&ID))?;
194227
program_config.try_require_unpaused()?;
195228

229+
// Size the bond before creating anything. An unset tier table sizes nothing, so a program
230+
// that was unpaused before it was configured takes no stake rather than a free one.
231+
let required_2z_amount = program_config
232+
.tier_parameters
233+
.required_2z_amount(committed_rate_bits_per_sec)
234+
.ok_or_else(|| {
235+
msg!(
236+
"No tier amount configured for {} bits/sec",
237+
committed_rate_bits_per_sec
238+
);
239+
ProgramError::InvalidAccountData
240+
})?;
241+
196242
// Account 1 funds both new accounts and is the builder the stake belongs to. The
197243
// create-account workflow requires it to be a writable signer.
198244
let (account_index, builder_info) =
@@ -270,14 +316,16 @@ fn try_initialize_builder_stake(
270316
builder_stake.builder = *builder_info.key;
271317
builder_stake.stake_index = stake_index;
272318
builder_stake.committed_rate_bits_per_sec = committed_rate_bits_per_sec;
319+
builder_stake.required_2z_amount = required_2z_amount;
273320
builder_stake.bump_seed = builder_stake_bump;
274321
builder_stake.token_account_bump_seed = token_account_bump;
275322

276323
msg!(
277-
"Builder {} stake {} committed to {} bits/sec",
324+
"Builder {} stake {} committed to {} bits/sec, requires {} 2Z",
278325
builder_info.key,
279326
stake_index,
280-
committed_rate_bits_per_sec
327+
committed_rate_bits_per_sec,
328+
required_2z_amount
281329
);
282330

283331
Ok(())
@@ -323,6 +371,25 @@ fn try_post_bond(accounts: &[AccountInfo], amount: u64) -> ProgramResult {
323371
return Err(ProgramError::IncorrectAuthority);
324372
}
325373

374+
// A stake that is still short is held to the current tier table, not the one that was live
375+
// when it was created. Creating a stake is permissionless and costs only rent, so pinning the
376+
// requirement at creation would let a builder bank today's price in bulk and fund years later
377+
// at a price a repricing was meant to replace. Once a stake is funded the requirement stops
378+
// moving, so a builder who paid in full cannot be made short by a later change.
379+
if !builder_stake.is_funded() {
380+
let committed_rate_bits_per_sec = builder_stake.committed_rate_bits_per_sec;
381+
builder_stake.required_2z_amount = program_config
382+
.tier_parameters
383+
.required_2z_amount(committed_rate_bits_per_sec)
384+
.ok_or_else(|| {
385+
msg!(
386+
"No tier amount configured for {} bits/sec",
387+
committed_rate_bits_per_sec
388+
);
389+
ProgramError::InvalidAccountData
390+
})?;
391+
}
392+
326393
// Account 3 must be this stake's 2Z token account. Checked against the cached bump so a
327394
// caller cannot redirect the bond to another account.
328395
let (_, stake_token_account_info, _) = try_next_2z_token_pda_info(
@@ -356,10 +423,17 @@ fn try_post_bond(accounts: &[AccountInfo], amount: u64) -> ProgramResult {
356423
// otherwise leave the two disagreeing forever.
357424
builder_stake.bonded_2z_amount = try_token_account_amount(stake_token_account_info)?;
358425

426+
// Bonds accumulate rather than having to arrive in one transfer, so a stake can be short
427+
// of its requirement. Nothing here refuses that: `Withdraw` is what must not drop a stake
428+
// below its requirement, and only a funded stake is mirrored to the DZ ledger, so a short one
429+
// backs no feed.
430+
359431
msg!(
360-
"Posted {} 2Z, stake now holds {}",
432+
"Posted {} 2Z, stake now holds {} of {} required (funded: {})",
361433
amount,
362-
builder_stake.bonded_2z_amount
434+
builder_stake.bonded_2z_amount,
435+
builder_stake.required_2z_amount,
436+
builder_stake.is_funded()
363437
);
364438

365439
Ok(())

solana/programs/builder-stake/src/state/builder_stake.rs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,15 @@ pub struct BuilderStake {
2727
/// 2Z held in this stake's token account, in the mint's smallest unit.
2828
pub bonded_2z_amount: u64,
2929

30+
/// What this stake has to hold for its committed rate.
31+
///
32+
/// Follows the tier table while the stake is short, and stops moving once the stake is funded.
33+
/// Both halves matter. Freezing it at creation would let a builder pre-create stakes for the
34+
/// cost of rent and fund them after a repricing at the old price. Never freezing it would let
35+
/// a repricing make a builder short after it had already paid in full, which RFC-28's "fixed
36+
/// at the price prevailing when the tier is set" rules out.
37+
pub required_2z_amount: u64,
38+
3039
/// The rate the feed backed by this stake may commit to, in bits per second. `u64::MAX` is the
3140
/// unmetered tier. Bits per second, not basis points: `bps` means basis points elsewhere in
3241
/// DoubleZero.
@@ -68,6 +77,12 @@ impl BuilderStake {
6877
)
6978
}
7079

80+
/// Whether this stake holds what its committed rate requires. A stake that is not funded backs
81+
/// no feed: nothing mirrors it to the DZ ledger, so no feed can be created against it.
82+
pub fn is_funded(&self) -> bool {
83+
self.bonded_2z_amount >= self.required_2z_amount
84+
}
85+
7186
pub fn checked_address(builder: &Pubkey, stake_index: u64, bump_seed: u8) -> Option<Pubkey> {
7287
Pubkey::create_program_address(
7388
&[

solana/programs/builder-stake/src/state/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
mod builder_stake;
22
mod program_config;
3+
mod tier_parameters;
34

45
pub use builder_stake::*;
56
pub use program_config::*;
7+
pub use tier_parameters::*;
68

79
//
810

solana/programs/builder-stake/src/state/program_config.rs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ use solana_msg::msg;
77
use solana_program_error::{ProgramError, ProgramResult};
88
use solana_pubkey::Pubkey;
99

10+
use super::TierParameters;
11+
1012
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Pod, Zeroable)]
1113
#[repr(C, align(8))]
1214
pub struct ProgramConfig {
@@ -19,7 +21,11 @@ pub struct ProgramConfig {
1921

2022
_padding: [u8; 7],
2123

22-
_storage_gap: StorageGap<4>,
24+
/// What a bond costs at each rate tier. Zero until an admin sets it, which is why a fresh
25+
/// deployment starts paused: it can size no bond.
26+
pub tier_parameters: TierParameters,
27+
28+
_storage_gap: StorageGap<3>,
2329
}
2430

2531
impl PrecomputedDiscriminator for ProgramConfig {
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
use bytemuck::{Pod, Zeroable};
2+
3+
/// What a bond costs at each RFC-28 rate tier, in the 2Z mint's smallest unit.
4+
///
5+
/// RFC-28 quotes the tiers in dollars (about $100k, $200k and $500k) but fixes the bond "in 2Z,
6+
/// at the price prevailing when the tier is set". So these are 2Z amounts an admin sets, not a
7+
/// price feed this program reads.
8+
///
9+
/// The rate ceilings are compiled in rather than configured, because they have to agree with
10+
/// `StakeTier::max_rate_bits_per_sec` in the serviceability program on the DZ ledger. If those two
11+
/// disagree, a builder funds one tier here and a feed is checked against another there.
12+
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Pod, Zeroable)]
13+
#[repr(C, align(8))]
14+
pub struct TierParameters {
15+
pub up_to_1gbps_2z_amount: u64,
16+
pub up_to_5gbps_2z_amount: u64,
17+
pub unmetered_2z_amount: u64,
18+
19+
_padding: [u8; 8],
20+
}
21+
22+
impl TierParameters {
23+
/// Decimal Gbps, as everywhere a link rate is quoted.
24+
pub const UP_TO_1GBPS_BITS_PER_SEC: u64 = 1_000_000_000;
25+
pub const UP_TO_5GBPS_BITS_PER_SEC: u64 = 5_000_000_000;
26+
27+
pub fn new(
28+
up_to_1gbps_2z_amount: u64,
29+
up_to_5gbps_2z_amount: u64,
30+
unmetered_2z_amount: u64,
31+
) -> Self {
32+
Self {
33+
up_to_1gbps_2z_amount,
34+
up_to_5gbps_2z_amount,
35+
unmetered_2z_amount,
36+
_padding: Default::default(),
37+
}
38+
}
39+
40+
/// The bond a feed committing to `rate_bits_per_sec` must post.
41+
///
42+
/// `None` when this table has no amount for that tier. That is an unset table, not a free
43+
/// tier: a zero requirement would let a builder deploy a feed against nothing.
44+
pub fn required_2z_amount(&self, rate_bits_per_sec: u64) -> Option<u64> {
45+
let amount = if rate_bits_per_sec <= Self::UP_TO_1GBPS_BITS_PER_SEC {
46+
self.up_to_1gbps_2z_amount
47+
} else if rate_bits_per_sec <= Self::UP_TO_5GBPS_BITS_PER_SEC {
48+
self.up_to_5gbps_2z_amount
49+
} else {
50+
self.unmetered_2z_amount
51+
};
52+
53+
(amount != 0).then_some(amount)
54+
}
55+
56+
/// Whether this table can size every tier, and costs more for more rate.
57+
///
58+
/// A cheaper higher tier is always a mistake: every builder would buy the cheap tier and
59+
/// commit to the higher rate.
60+
pub fn is_well_formed(&self) -> bool {
61+
self.up_to_1gbps_2z_amount != 0
62+
&& self.up_to_1gbps_2z_amount <= self.up_to_5gbps_2z_amount
63+
&& self.up_to_5gbps_2z_amount <= self.unmetered_2z_amount
64+
}
65+
}
66+
67+
#[cfg(test)]
68+
mod tests {
69+
use super::*;
70+
71+
/// The tier a rate falls into, at and either side of each ceiling.
72+
#[test]
73+
fn test_rate_selects_its_tier() {
74+
let tiers = TierParameters::new(100, 200, 500);
75+
76+
assert_eq!(tiers.required_2z_amount(1), Some(100));
77+
assert_eq!(tiers.required_2z_amount(1_000_000_000), Some(100));
78+
assert_eq!(tiers.required_2z_amount(1_000_000_001), Some(200));
79+
assert_eq!(tiers.required_2z_amount(5_000_000_000), Some(200));
80+
assert_eq!(tiers.required_2z_amount(5_000_000_001), Some(500));
81+
assert_eq!(tiers.required_2z_amount(u64::MAX), Some(500));
82+
}
83+
84+
/// An unset table sizes nothing, rather than sizing everything at zero.
85+
#[test]
86+
fn test_unset_table_sizes_nothing() {
87+
let tiers = TierParameters::default();
88+
89+
assert_eq!(tiers.required_2z_amount(1), None);
90+
assert_eq!(tiers.required_2z_amount(u64::MAX), None);
91+
assert!(!tiers.is_well_formed());
92+
}
93+
94+
#[test]
95+
fn test_well_formed_requires_non_decreasing_amounts() {
96+
assert!(TierParameters::new(100, 200, 500).is_well_formed());
97+
// Equal amounts are odd but not wrong; the tiers just cost the same.
98+
assert!(TierParameters::new(100, 100, 100).is_well_formed());
99+
100+
assert!(!TierParameters::new(0, 200, 500).is_well_formed());
101+
assert!(!TierParameters::new(300, 200, 500).is_well_formed());
102+
assert!(!TierParameters::new(100, 600, 500).is_well_formed());
103+
}
104+
}

0 commit comments

Comments
 (0)