diff --git a/prime/README.md b/prime/README.md index a667f34..1e0fb44 100644 --- a/prime/README.md +++ b/prime/README.md @@ -472,6 +472,33 @@ the key does not apply to), `class_budget_replies` the coinbasers held to it, an appears in the stats of a Prime that has never had the key; one that had it and no longer does keeps reporting `carry_reserved` while the blocks it recorded are in memory. +### Template budgets + +A gateway on a current CONVOY build (v0.5.0beta) fits every size class to its template +(`datum_stratum_coinbase_fit_to_template`). While the mempool fills blocks to the chain's +800 000 weight units, all a template leaves is the node's coinbase reserve, about 2 000 bytes, +and every class keeps the first 50 or so payees of a list of over 100. Blocks 977088, 977570, +977906, 977949 and 977993 were each Partial that way, and in the week to 2026-10-19 so were 7 of +33 blocks from v0.5.0beta gateways and 65 of 179 from v0.4.1-beta ones. Each owes the rest +through a make-good that waits 6 480 blocks, and the fee wallet's offender rule charged the +gateway's own address the custody fee for a block that was only full. + +`template-budget = true` holds a session's coinbasers to the payee bytes the fullest template its +shares cited in the last minute leaves every class: `(template-weight-limit - txn_total_weight - +712) / 4`, less the class's fixed part (124 + 85 + 10 bytes and the pool's script), the list's +own pool output and a 128-byte margin. On the five blocks above that is 189 to 208 bytes under +what CONVOY kept, so a list held to it is kept whole. Where it is under the pool's own budget it +is the reply's budget, or the smaller of it and a learned class budget; otherwise nothing +changes. Whoever does not fit is `OverBudget` and waits in carry, largest balances placed first, +exactly as under a class budget. + +It is a class budget in every other respect: the same sessions (CONVOY-generation, not the house +gateway, lazarus-gateway, lazarus-split, ratum or a held-split build), the carry ceiling, +`carry_reserved_sats` on capped blocks, the `class_budget_replies` counters, and it needs +`class-budget-fee-wallet-reserves = true` (the live fee wallet holds `carry_reserved_sats` back). +`template-weight-limit` (default 800 000) is the `weightlimit` the gateways' nodes report; the +class-budget check that a template left room uses it too, and the 164-byte BLAKE2b header. + ### Restarts used to throw away a burst of good work A reconnecting gateway is still serving jobs whose coinbases carry the split from coinbaser ids diff --git a/prime/primed/src/config.rs b/prime/primed/src/config.rs index a75e6ac..8f958ce 100644 --- a/prime/primed/src/config.rs +++ b/prime/primed/src/config.rs @@ -105,6 +105,22 @@ pub struct Config { /// meanwhile are Partial and owe as they do without `class-budget`. Default 5 XBT. #[serde(default = "d_class_budget_carry_ceiling")] pub class_budget_carry_ceiling: u64, + /// Hold each coinbaser to what the gateway's recent templates leave room for. CONVOY fits + /// every size class to the template (`datum_stratum_coinbase_fit_to_template`): when the + /// mempool fills a block to `template-weight-limit`, only the node's coinbase reserve is left, + /// about 2 000 bytes, and the class keeps the first 50 or so of a list of over 100. Every such + /// block was Partial: 7 of 33 from v0.5.0beta gateways and 65 of 179 from v0.4.1-beta in the + /// week to 2026-10-19, each owing the rest through a make-good that waits 6 480 blocks. Held to + /// that room, the list fits, the block is a full split, and whoever does not fit waits in + /// carry for a later coinbase instead. Same sessions, guards and accounting as `class-budget` + /// (`carry_reserved_sats`, the carry ceiling); ignored, and said so at startup, unless + /// `class-budget-fee-wallet-reserves` is set too. Off (default): replies are as they were. + #[serde(default)] + pub template_budget: bool, + /// The block weight a gateway's template is filled to: the `weightlimit` its node's + /// `getblocktemplate` reports (800 000 on this chain). + #[serde(default = "d_template_weight_limit")] + pub template_weight_limit: u32, /// Test hook: sleep this long before answering a coinbaser, so a stock gateway's /// 5 s fetch times out. 0 (default) is production. #[serde(default)] @@ -254,6 +270,9 @@ pub struct Config { /// `class-budget = true` without `class-budget-fee-wallet-reserves`, turned off by `load`. #[serde(skip)] class_budget_unreserved: bool, + /// The same for `template-budget = true`. + #[serde(skip)] + template_budget_unreserved: bool, } fn d_listen() -> SocketAddr { @@ -344,6 +363,10 @@ fn d_solo_tag() -> String { /// simulation of a 17-output cap put the added float at: a guard at the level it was expected /// to settle, so that a float that keeps growing (CONVOY hashrate leaving, the pool shrinking) /// stops growing there, not one that trims the expected case. +fn d_template_weight_limit() -> u32 { + 800_000 +} + fn d_class_budget_carry_ceiling() -> u64 { 500_000_000 } @@ -472,6 +495,13 @@ impl Config { c.class_budget = false; c.class_budget_unreserved = true; } + if c.template_budget && !c.class_budget_fee_wallet_reserves { + c.template_budget = false; + c.template_budget_unreserved = true; + } + if c.template_weight_limit < 100_000 { + return Err(format!("template-weight-limit {} is not a block's weight", c.template_weight_limit)); + } if c.key_file.is_none() { // A data dir left by lazarus-prime keeps its identity: same key file, same pubkey. let ours = c.data_dir.join("prime.key"); @@ -513,6 +543,14 @@ impl Config { .into(), ); } + if self.template_budget_unreserved { + v.push( + "template-budget = true is ignored until class-budget-fee-wallet-reserves = true says the fee wallet \ + holds back carry_reserved_sats: a template-capped block is a split owing nothing whose pool output \ + holds the earnings its budget deferred to carry (README, \"Class budgets\")" + .into(), + ); + } if self.activation_height.is_some() { v.push("activation-height is ignored: every share is verified as BLAKE2b header v2; SHA256d shares are rejected as bad-version".into()); } @@ -792,4 +830,31 @@ require-split-gateway = true assert!(c.class_budget && notes(&c).is_empty()); std::fs::remove_dir_all(&dir).unwrap(); } + + /// `template-budget` takes the same word from the operator as `class-budget`, and alone is + /// off; the weight limit defaults to this chain's. + #[test] + fn template_budget_is_off_until_the_fee_wallet_is_said_to_hold_back_its_carry() { + let dir = std::env::temp_dir().join(format!("primed-cfg-tb-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let p = dir.join("prime.toml"); + let base = LEGACY.replace("/home/umbrel/blake2b/lazarus-prime", dir.to_str().unwrap()); + let load = |extra: &str| { + std::fs::write(&p, format!("{base}{extra}")).unwrap(); + Config::load(&p) + }; + let notes = |c: &Config| -> Vec { + c.legacy_notes().into_iter().filter(|n| n.contains("template-budget")).collect() + }; + let c = load("").unwrap(); + assert!(!c.template_budget && c.template_weight_limit == 800_000 && notes(&c).is_empty()); + let c = load("template-budget = true\n").unwrap(); + assert!(!c.template_budget && notes(&c).len() == 1); + let c = load("template-budget = true\nclass-budget-fee-wallet-reserves = true\n").unwrap(); + assert!(c.template_budget && !c.class_budget && notes(&c).is_empty()); + let c = load("template-weight-limit = 4000000\n").unwrap(); + assert_eq!(c.template_weight_limit, 4_000_000); + assert!(load("template-weight-limit = 4000\n").is_err()); + std::fs::remove_dir_all(&dir).unwrap(); + } } diff --git a/prime/primed/src/session.rs b/prime/primed/src/session.rs index fff75fd..aa021df 100644 --- a/prime/primed/src/session.rs +++ b/prime/primed/src/session.rs @@ -169,23 +169,70 @@ fn kept_payee_bytes(issued: &[Output], pool_script: &[u8], cb: &coinbase::Coinba (kept > 0 && skipped).then_some(kept) } -/// The block weight CONVOY fits a coinbase class into (`datum_stratum_coinbase_fit_to_template` -/// in `datum_coinbaser.c`): the consensus limit less the header and coinbase frame it counts. A -/// template of `txn_total_weight` leaves a class `(this - txn_total_weight) / 4` bytes. -const TEMPLATE_WEIGHT_LIMIT: u32 = 4_000_000 - 340 - 36; +/// The weight CONVOY counts for a block besides its coinbase and transactions +/// (`datum_stratum_coinbase_fit_to_template` in `datum_coinbaser.c` at v0.5.0beta): four units a +/// byte for the BLAKE2b header (`DATUM_BLAKE2B_BLOCK_HEADER_SIZE`, 164 bytes, `datum_pow.h`) and +/// the transaction count (at most 5 bytes), and the coinbase's 36 bytes of witness. A template +/// of `txn_total_weight` under a limit of `weight_limit` leaves a class +/// `(weight_limit - txn_total_weight - this) / 4` bytes. +const TEMPLATE_FRAME_WEIGHT: u32 = ((164 + 5) << 2) + 36; +/// The most a CONVOY size class takes of its room besides the payee outputs it is handed: 124 +/// static bytes, the scriptSig data (the extranonce sits in it only up to 85 bytes) and the +/// 10-byte OP_RETURN extranonce (`cb_req_sz`, `datum_coinbaser.c:756` at v0.5.0beta), plus its +/// own pool output's script, which `template_payee_room` adds. +const CONVOY_CLASS_FIXED_BYTES: usize = 124 + 85 + 10; +/// Held back from what a template left: the next one is built off a mempool that has grown. +const TEMPLATE_BUDGET_MARGIN_BYTES: usize = 128; +/// A session's template budget is set by the fullest template its shares cited this recently. +/// Long enough to span the gateway's template refreshes (about every ten seconds) and the +/// coinbaser asked for at a new height, before any share on the new template has arrived. +const TEMPLATE_BUDGET_WINDOW: Duration = Duration::from_secs(60); +/// Template weights a session keeps for its budget, newest last. +const TEMPLATE_BUDGET_SEEN: usize = 32; + +/// Payee bytes, measured as `SplitParams::output_budget_bytes` measures them (`8 + 1 + script`), +/// that every CONVOY size class keeps whole under a template of `txn_total_weight`: the room +/// [`TEMPLATE_FRAME_WEIGHT`] leaves, less the class's fixed part with the gateway's pool output, +/// the list's own last output to the pool, and [`TEMPLATE_BUDGET_MARGIN_BYTES`]. When the mempool +/// fills a template to `template-weight-limit` this is the node's coinbase reserve, about 2 000 +/// bytes less those: the room a full block left for the 50 to 66 payees CONVOY kept of lists of +/// over 100 (blocks 977088, 977570, 977906, 977949, 977993). +fn template_payee_room(weight_limit: u32, txn_total_weight: u32, pool_script_len: usize) -> usize { + let room = (weight_limit.saturating_sub(txn_total_weight).saturating_sub(TEMPLATE_FRAME_WEIGHT) / 4) as usize; + room.saturating_sub( + CONVOY_CLASS_FIXED_BYTES + pool_script_len + (8 + 1 + pool_script_len) + TEMPLATE_BUDGET_MARGIN_BYTES, + ) +} /// Room a template must leave beyond the payee bytes a section kept for the cut to have been its /// class's and not the template's: a class's fixed part (the coinbase frame, scriptSig and pool /// output, about 250 bytes on a live CONVOY gateway) and one more output, with margin. const CLASS_BUDGET_TEMPLATE_SLACK: usize = 512; +/// [`Session::template_budget`] for the template weights `seen`, newest last. +fn template_budget_of( + seen: &VecDeque<(Instant, u32)>, + now: Instant, + weight_limit: u32, + pool_script_len: usize, + pool_budget: usize, +) -> Option { + let fullest = seen + .iter() + .filter(|(t, _)| now.saturating_duration_since(*t) < TEMPLATE_BUDGET_WINDOW) + .map(|(_, w)| *w) + .max()?; + let room = template_payee_room(weight_limit, fullest, pool_script_len); + (room < pool_budget).then_some(room) +} + /// Whether a template of `txn_total_weight` left room enough that a section keeping `kept` payee /// bytes was cut by its size class, not by the template. Bitcoin Core's default template (4 000 /// weight units kept for the coinbase) leaves about 1 900 bytes, more than a class of 17 outputs /// needs; one packed to the last few thousand weight units does not, and says nothing about the /// class. The weight is the gateway's word: a gateway that lies about it can only stop itself /// teaching a budget, or teach itself a smaller one, as it could with the shares it sends anyway. -fn template_left_room(kept: usize, txn_total_weight: u32) -> bool { - let left = (TEMPLATE_WEIGHT_LIMIT.saturating_sub(txn_total_weight) / 4) as usize; +fn template_left_room(kept: usize, txn_total_weight: u32, weight_limit: u32) -> bool { + let left = (weight_limit.saturating_sub(txn_total_weight).saturating_sub(TEMPLATE_FRAME_WEIGHT) / 4) as usize; left >= kept + CLASS_BUDGET_TEMPLATE_SLACK } @@ -742,6 +789,9 @@ struct Session { /// key, and for a session it does not apply to (`class_budget_applies`, the house gateway, /// a held-split build). class_budget: Option, + /// `template-budget`: when this session's shares cited each template weight, newest last, up + /// to [`TEMPLATE_BUDGET_SEEN`]; `None` without the key and wherever `class_budget` would be. + template_weights: Option>, /// `prev_hash` of the last flushed coinbaser (hex), i.e. the tip that split is for. last_split_prev: Option, /// Convoy configure v3 resume token; reused for every mid-session configure so the @@ -903,6 +953,11 @@ pub async fn run(shared: Arc, mut stream: TcpStream, remote: SocketAddr) && held_build.is_none() && class_budget_applies(hello.generation, &hello.user_agent)) .then(ClassBudget::default); + let template_weights = (shared.cfg.template_budget + && !house + && held_build.is_none() + && class_budget_applies(hello.generation, &hello.user_agent)) + .then(VecDeque::new); // Shown as a client once it has proved it holds the session key (`Session::establish`). A // hello can be replayed by anyone who saw one, and re-sealed to us by any other pool its // gateway connects to; whoever does that cannot read our reply or send a frame, but would @@ -969,6 +1024,7 @@ pub async fn run(shared: Arc, mut stream: TcpStream, remote: SocketAddr) pins_script, refused_coinbase_warned: None, class_budget, + template_weights, last_split_prev: None, resume_token: { let mut t = [0u8; mining::RESUME_TOKEN_LEN]; @@ -1208,9 +1264,48 @@ impl Session { /// pool's output as the one that does not fit, and the gateway pays the remainder to the /// script it is configured with: on the pool's, that is the same output, and on its own it /// makes the coinbase the gateway's solo work. + /// + /// With `template-budget`, the smaller of that and [`Session::template_budget`]. fn reply_class_budget(&self) -> Option { let on_own_script = self.held_script().is_some() || self.stay_on_gateway(); - self.class_budget.as_ref()?.bytes.filter(|_| !on_own_script) + if on_own_script { + return None; + } + let class = self.class_budget.as_ref().and_then(|b| b.bytes); + match (class, self.template_budget(Instant::now())) { + (Some(c), Some(t)) => Some(c.min(t)), + (c, t) => c.or(t), + } + } + + /// `template-budget`: the payee bytes the fullest template this session's shares cited in the + /// last [`TEMPLATE_BUDGET_WINDOW`] leaves every CONVOY class ([`template_payee_room`]), where + /// that is under the pool's own budget; `None` while its templates leave more, or it has cited + /// none lately. + fn template_budget(&self, now: Instant) -> Option { + template_budget_of( + self.template_weights.as_ref()?, + now, + self.shared.cfg.template_weight_limit, + self.shared.pool_script.len(), + self.shared.split_params.output_budget_bytes, + ) + } + + /// Note the template weight a share's job cited, for [`Session::template_budget`]. + fn note_template_weight(&mut self, txn_total_weight: u32) { + let Some(seen) = self.template_weights.as_mut() else { return }; + let now = Instant::now(); + if let Some(last) = seen.back_mut() { + if last.1 == txn_total_weight { + last.0 = now; + return; + } + } + if seen.len() >= TEMPLATE_BUDGET_SEEN { + seen.pop_front(); + } + seen.push_back((now, txn_total_weight)); } /// The script a held-split gateway is left paying itself with: its own payout, while its @@ -1757,6 +1852,7 @@ impl Session { Some(j) => (j.height, j.coinbaser_id, j.prev_hash, j.nbits_u32(), j.txn_total_weight), None => return self.reject(&s, mining::REJECT_BAD_JOB_ID).await, }; + self.note_template_weight(txn_total_weight); // The job section is the gateway's account of the chain; the node's is the one that // counts. Height, parent and target are all held to it before any work is credited. // Until the node has answered once there is no chain to hold a job to. That is this @@ -2302,7 +2398,7 @@ impl Session { return; } let Some(kept) = kept_payee_bytes(issued, &self.shared.pool_script, cb) else { return }; - if !template_left_room(kept, txn_total_weight) { + if !template_left_room(kept, txn_total_weight, self.shared.cfg.template_weight_limit) { return; } self.expire_class_budget(); @@ -3704,17 +3800,52 @@ mod tests { assert_eq!(kept_payee_bytes(&list, &pool, &paying(&[], value)), None); } + /// Full blocks from v0.5.0beta gateways, held to [`template_payee_room`] of the templates they + /// were found on (weight of their transactions, from the node): each budget fits the payee + /// bytes the class kept, so a list held to it is paid whole, and gives up no more than seven + /// P2WPKH payees (217 bytes) of it to the margins. + #[test] + fn a_template_budget_fits_what_full_blocks_kept() { + // blocks 977088, 977570, 977906, 977949, 977993: (template weight, payee bytes kept) + for (w, kept) in [(791_969, 1_618), (791_564, 1_739), (789_989, 2_126), (791_811, 1_680), (791_761, 1_677)] { + let b = template_payee_room(800_000, w, 22); + assert!(b <= kept && kept - b <= 7 * 31, "{w}: budget {b}, kept {kept}"); + } + // over the limit, or exactly at it: no room, and everyone waits in carry + assert_eq!(template_payee_room(800_000, 800_000, 22), 0); + assert_eq!(template_payee_room(800_000, 900_000, 22), 0); + } + + /// The budget is set by the fullest template cited in the last minute, and only where it is + /// under the pool's own budget. + #[test] + fn a_template_budget_is_the_fullest_recent_template_s() { + let now = Instant::now(); + let ago = |s: u64| now.checked_sub(Duration::from_secs(s)).unwrap(); + let pool = 14_000 - 9 - 64; + let seen = VecDeque::from([(ago(90), 799_000), (ago(30), 791_000), (ago(5), 600_000)]); + assert_eq!(template_budget_of(&seen, now, 800_000, 22, pool), Some(template_payee_room(800_000, 791_000, 22))); + // the full one has aged out, and the rest leave more than the pool's own budget + assert_eq!( + template_budget_of(&VecDeque::from([(ago(61), 799_000), (ago(5), 600_000)]), now, 800_000, 22, pool), + None + ); + assert_eq!(template_budget_of(&VecDeque::new(), now, 800_000, 22, pool), None); + // Bitcoin's limit leaves a template of this weight a whole class's room + assert_eq!(template_budget_of(&VecDeque::from([(ago(5), 791_000)]), now, 4_000_000, 22, pool), None); + } + /// A section is taken for its class only where the template left it more room than it kept. #[test] fn a_section_cut_by_a_full_template_is_not_taken_for_its_class() { // what a node's default template leaves (4 000 weight units kept for the coinbase) - assert!(template_left_room(527, 3_992_000)); - assert!(template_left_room(310, 3_992_000) && template_left_room(1_300, 3_992_000)); - assert!(template_left_room(527, 0)); + assert!(template_left_room(527, 3_992_000, 4_000_000)); + assert!(template_left_room(310, 3_992_000, 4_000_000) && template_left_room(1_300, 3_992_000, 4_000_000)); + assert!(template_left_room(527, 0, 4_000_000)); // packed to the last few thousand, or past the limit - assert!(!template_left_room(527, 3_996_000)); - assert!(!template_left_room(1_500, 3_992_000)); - assert!(!template_left_room(31, 4_100_000)); + assert!(!template_left_room(527, 3_996_000, 4_000_000)); + assert!(!template_left_room(1_500, 3_992_000, 4_000_000)); + assert!(!template_left_room(31, 4_100_000, 4_000_000)); } /// CONVOY C gateways only. iohzrd's `7491a50` is one, and never learns a budget: every diff --git a/prime/primed/src/stats.rs b/prime/primed/src/stats.rs index 567ba02..cf12cbf 100644 --- a/prime/primed/src/stats.rs +++ b/prime/primed/src/stats.rs @@ -364,9 +364,10 @@ pub fn build(shared: &Shared) -> Value { "template_faults": shared.faults.all().into_iter().map(|(k, f)| serde_json::json!({"gateway": &k[..16.min(k.len())], "fault": f})).collect::>(), "blocks": blocks, }); - // Only with `class-budget`, or once a block carries a class-budget reserve, so a Prime that - // has never had the key writes the document it always has. - if shared.cfg.class_budget || carry_reserved > 0 { + // Only with `class-budget` or `template-budget`, or once a block carries a reserve, so a + // Prime that has never had either key writes the document it always has. Replies held to a + // template budget count as class-budget replies: one budget, the smaller of the two. + if shared.cfg.class_budget || shared.cfg.template_budget || carry_reserved > 0 { if let Some(o) = doc["totals"].as_object_mut() { // coinbasers held to a session's class budget, and ones a budget would have held // but for carry being over `class-budget-carry-ceiling` (`class_budget_held_off`) diff --git a/prime/primed/tests/class_budget_e2e.rs b/prime/primed/tests/class_budget_e2e.rs index bf5fe68..aca52f7 100644 --- a/prime/primed/tests/class_budget_e2e.rs +++ b/prime/primed/tests/class_budget_e2e.rs @@ -262,3 +262,40 @@ fn a_class_limited_session_learns_its_budget_and_its_next_list_is_mined_whole() let st = settled_stats(&primed); assert_eq!((row(&st, &gw)["accepted"].as_u64(), row(&st, &gw)["rejected"].as_u64()), (Some(4), Some(0))); } + +/// `template-budget`: a share citing a template filled to 795 664 of the 800 000 weight units +/// leaves every CONVOY class room for 16 of the 40 payees. The next list names those 16 and the +/// pool, in the order the split placed them, so whichever class mines it keeps it whole; the +/// rest wait in carry. Without the key, or with it but not the fee wallet's, the list is whole. +#[test] +fn a_full_template_holds_the_next_list_to_the_room_it_leaves() { + // (800 000 - 795 664 - 712) / 4 = 906 bytes of class; less 124 + 85 + 10 and the pool's + // script (22), the list's pool output (31) and the 128 margin: 506, 16 P2WPKH payees. + const FULL: u32 = 795_664; + let node = node(); + for (key, held) in [ + ("template-budget = true\nclass-budget-fee-wallet-reserves = true", true), + ("template-budget = true", false), + ("", false), + ] { + let gw = Identity::generate(); + let pool = Identity::generate(); + let (primed, port) = start(&node, &pool, key); + let (mut g, _) = Gateway::connect_convoy_as(port, &pool, &gw, CONVOY_UA); + let (_, _, list) = g.request_coinbaser_seeing(VALUE, &TIP); + assert_eq!(list.len(), usize::from(MINERS) + 1, "nothing cited yet: the whole list"); + // The gateway's word on its template, carried by any share, ground or not. + let _ = g.submit(&pool_only_share(3, HEIGHT, TIP, TIP_BITS, FULL, unix_now())); + let (_, _, next) = g.request_coinbaser_seeing(VALUE + 1, &TIP); + if held { + assert_eq!(next.len(), 17, "{key}: 16 payees, then the pool"); + assert_eq!(next[..16], list[..16], "the head of the list, in its order"); + assert_eq!(hex::encode(&next[16].script), POOL_SCRIPT); + assert_eq!(next.iter().map(|o| o.sats).sum::(), VALUE + 1); + let st = settled_stats(&primed); + assert_eq!(st["totals"]["class_budget_replies"], 1, "{}", st["totals"]); + } else { + assert_eq!(next.len(), usize::from(MINERS) + 1, "{key}: the whole list"); + } + } +}