Skip to main content

hashiverse_lib/protocol/posting/
amplification.rs

1//! # Post PoW amplification rules
2//!
3//! A single function — [`get_minimum_post_pow`] — that decides how many bits of
4//! proof-of-work a post requires before a server will accept it. The base level comes
5//! from [`crate::tools::config::POW_MINIMUM_PER_POST`], on top of which the
6//! amplification is:
7//!
8//! - **Size**: log-scale above 1024 bytes. Longer posts cost more — fair for storage,
9//!   punishes spam-generated wall-of-text content.
10//! - **Fan-out**: posts that link a large number of `base_id`s (hashtags, mentions,
11//!   replies) scale quadratically. Main lever against hashtag spam farms that link
12//!   hundreds of trending tags from one post.
13//! - **Bucket granularity**: submissions into tighter (shorter-duration) buckets need
14//!   more PoW than broader ones. Hot, fine-grained buckets are the most valuable
15//!   attack surface, so they get the most expensive admission control. Durations
16//!   coarser than a month (the year bucket used by Sequels) are clamped to the month
17//!   amplification so the bucket factor never drops below 1.
18//!
19//! `duration` must be one of [`crate::tools::buckets::BUCKET_DURATIONS`]; anything
20//! else is rejected with an error (this is reachable from untrusted network input,
21//! so it must never panic).
22
23use crate::tools::buckets::BUCKET_DURATIONS;
24use crate::tools::config;
25use crate::tools::time::{DurationMillis, MILLIS_IN_MONTH};
26use crate::tools::types::Pow;
27
28pub fn get_minimum_post_pow(post_length: usize, linked_base_ids_len: usize, duration: DurationMillis) -> anyhow::Result<Pow> {
29    if !BUCKET_DURATIONS.contains(&duration) {
30        anyhow::bail!("Unsupported bucket duration {}", duration);
31    }
32
33    let pow_amplification_post_length = if post_length <= 1024 { 1.0f64 } else { 1.0f64 + 1.75 * (post_length as f64 / 1024f64).log2() };
34    let pow_amplification_linked_base_ids = if linked_base_ids_len < 2 {1.0f64} else { 1.0f64 + 0.2f64 * (linked_base_ids_len as f64).powi(2) };
35    let pow_amplification_bucket = (1.0f64 + (MILLIS_IN_MONTH.0 as f64 / duration.0 as f64).log2()).max(1.0f64);
36
37    let pow_amplification = pow_amplification_post_length * pow_amplification_linked_base_ids * pow_amplification_bucket;
38    let additional_pow_bits = pow_amplification.log2().ceil();
39    Ok(Pow(config::POW_MINIMUM_PER_POST.0 + additional_pow_bits as u8))
40}
41
42
43#[cfg(test)]
44mod tests {
45    use super::*;
46    use crate::tools::config;
47    use crate::tools::time::{MILLIS_IN_DAY, MILLIS_IN_HOUR, MILLIS_IN_MINUTE, MILLIS_IN_MONTH, MILLIS_IN_SECOND, MILLIS_IN_YEAR};
48    use crate::tools::types::Pow;
49
50    // baseline: small post, client_id only (< 2 links → no extra amp), month → total=1.0, ceil to +0 bits
51    #[test]
52    fn baseline() {
53        let pow = get_minimum_post_pow(512, 1, MILLIS_IN_MONTH).unwrap();
54        assert_eq!(pow, config::POW_MINIMUM_PER_POST);
55    }
56
57    // day bucket: bucket_amp≈5.81, linked_amp=1.0 → total=5.81, log2≈2.54 → ceil to +3 bits
58    #[test]
59    fn day_bucket() {
60        let pow = get_minimum_post_pow(512, 1, MILLIS_IN_DAY).unwrap();
61        assert_eq!(pow, Pow(config::POW_MINIMUM_PER_POST.0 + 3));
62    }
63
64    // hour bucket: bucket_amp≈10.39, linked_amp=1.0 → total=10.39, log2≈3.38 → ceil to +4 bits
65    #[test]
66    fn hour_bucket() {
67        let pow = get_minimum_post_pow(512, 1, MILLIS_IN_HOUR).unwrap();
68        assert_eq!(pow, Pow(config::POW_MINIMUM_PER_POST.0 + 4));
69    }
70
71    // linked ids (3): amplification kicks in at ≥ 2 links; linked_amp = 1 + 0.2*9 = 2.8, log2≈1.49 → ceil to +2 bits
72    #[test]
73    fn linked_ids() {
74        let pow = get_minimum_post_pow(512, 3, MILLIS_IN_MONTH).unwrap();
75        assert_eq!(pow, Pow(config::POW_MINIMUM_PER_POST.0 + 2));
76    }
77
78    // combined: post_amp=4.5, linked_amp=2.8, bucket_amp≈5.81 → product≈73.2, log2≈6.19 → ceil to +7 bits
79    #[test]
80    fn combined() {
81        let pow = get_minimum_post_pow(4096, 3, MILLIS_IN_DAY).unwrap();
82        assert_eq!(pow, Pow(config::POW_MINIMUM_PER_POST.0 + 7));
83    }
84
85    // medium post (4 KB): post_amp=4.5, linked_amp=1.0 → total=4.5, log2≈2.17 → ceil to +3 bits
86    #[test]
87    fn medium_post_4kb() {
88        let pow = get_minimum_post_pow(4 * 1024, 1, MILLIS_IN_MONTH).unwrap();
89        assert_eq!(pow, Pow(config::POW_MINIMUM_PER_POST.0 + 3));
90    }
91
92    // large post (64 KB): post_amp=11.5, linked_amp=1.0 → total=11.5, log2≈3.52 → ceil to +4 bits
93    #[test]
94    fn large_post_64kb() {
95        let pow = get_minimum_post_pow(64 * 1024, 1, MILLIS_IN_MONTH).unwrap();
96        assert_eq!(pow, Pow(config::POW_MINIMUM_PER_POST.0 + 4));
97    }
98
99    // large post (512 KB): post_amp=16.75, linked_amp=1.0 → total=16.75, log2≈4.07 → ceil to +5 bits
100    #[test]
101    fn large_post_512kb() {
102        let pow = get_minimum_post_pow(512 * 1024, 1, MILLIS_IN_MONTH).unwrap();
103        assert_eq!(pow, Pow(config::POW_MINIMUM_PER_POST.0 + 5));
104    }
105
106    // minute-granularity bucket (5 min): bucket_amp≈13.977, linked_amp=1.0 → total=13.977, log2≈3.80 → ceil to +4 bits
107    #[test]
108    fn minute_granularity_5min() {
109        let pow = get_minimum_post_pow(512, 1, MILLIS_IN_MINUTE.const_mul(5)).unwrap();
110        assert_eq!(pow, Pow(config::POW_MINIMUM_PER_POST.0 + 4));
111    }
112
113    // many linked ids (10): linked_amp = 1 + 0.2*100 = 21.0, total log2 ≈ 4.39 → ceil to +5 bits
114    #[test]
115    fn many_linked_ids_10() {
116        let pow = get_minimum_post_pow(512, 10, MILLIS_IN_MONTH).unwrap();
117        assert_eq!(pow, Pow(config::POW_MINIMUM_PER_POST.0 + 5));
118    }
119
120    // shorter duration → higher or equal pow
121    #[test]
122    fn monotone_duration() {
123        let pow_month = get_minimum_post_pow(512, 1, MILLIS_IN_MONTH).unwrap();
124        let pow_day = get_minimum_post_pow(512, 1, MILLIS_IN_DAY).unwrap();
125        let pow_hour = get_minimum_post_pow(512, 1, MILLIS_IN_HOUR).unwrap();
126        assert!(pow_month <= pow_day);
127        assert!(pow_day <= pow_hour);
128        assert!(pow_month < pow_hour);
129    }
130
131    // more linked ids → higher or equal pow (minimum real-world value is 1 — the client_id)
132    #[test]
133    fn monotone_linked_ids() {
134        let pow_1 = get_minimum_post_pow(512, 1, MILLIS_IN_DAY).unwrap();
135        let pow_2 = get_minimum_post_pow(512, 2, MILLIS_IN_DAY).unwrap();
136        let pow_3 = get_minimum_post_pow(512, 3, MILLIS_IN_DAY).unwrap();
137        let pow_4 = get_minimum_post_pow(512, 4, MILLIS_IN_DAY).unwrap();
138        assert!(pow_1 <= pow_2);
139        assert!(pow_2 <= pow_3);
140        assert!(pow_3 <= pow_4);
141        assert!(pow_1 < pow_4);
142    }
143
144    // larger posts → higher or equal pow (only kicks in above 1024 bytes)
145    #[test]
146    fn monotone_post_length() {
147        let pow_small = get_minimum_post_pow(1024, 1, MILLIS_IN_DAY).unwrap();
148        let pow_medium = get_minimum_post_pow(8192, 1, MILLIS_IN_DAY).unwrap();
149        let pow_large = get_minimum_post_pow(1024 * 1024, 1, MILLIS_IN_DAY).unwrap();
150        assert!(pow_small <= pow_medium);
151        assert!(pow_medium <= pow_large);
152        assert!(pow_small < pow_large);
153    }
154
155    // every combination on a sample grid must be at or above the minimum
156    #[test]
157    fn never_below_minimum() {
158        let lengths = [1, 512, 1024, 4096, 64 * 1024];
159        let links = [1, 2, 5, 10];
160        for post_length in lengths {
161            for linked_base_ids_len in links {
162                for duration in BUCKET_DURATIONS {
163                    let pow = get_minimum_post_pow(post_length, linked_base_ids_len, duration).unwrap();
164                    assert!(
165                        pow >= config::POW_MINIMUM_PER_POST,
166                        "pow={:?} below minimum for post_length={} links={} duration={:?}",
167                        pow, post_length, linked_base_ids_len, duration
168                    );
169                }
170            }
171        }
172    }
173
174    // 1-minute bucket is the finest entry in BUCKET_DURATIONS and must be accepted (it used to panic and take down the server).
175    // bucket_amp = 1 + log2(month/minute) ≈ 16.4, log2 ≈ 4.04 → ceil to +5 bits
176    #[test]
177    fn one_minute_bucket() {
178        let pow = get_minimum_post_pow(512, 1, MILLIS_IN_MINUTE).unwrap();
179        assert_eq!(pow, Pow(config::POW_MINIMUM_PER_POST.0 + 5));
180    }
181
182    #[test]
183    fn every_bucket_duration_is_accepted() {
184        for bucket_duration in BUCKET_DURATIONS {
185            let result = get_minimum_post_pow(512, 1, bucket_duration);
186            assert!(result.is_ok(), "bucket_duration={} rejected: {:?}", bucket_duration, result.err());
187        }
188    }
189
190    // durations outside BUCKET_DURATIONS come from untrusted input and must be an error, never a panic
191    #[test]
192    fn rejects_unsupported_duration() {
193        let unsupported_durations = [DurationMillis(0), MILLIS_IN_SECOND.const_mul(30), MILLIS_IN_MINUTE.const_mul(2), DurationMillis(-1), -MILLIS_IN_MONTH];
194        for unsupported_duration in unsupported_durations {
195            assert!(get_minimum_post_pow(512, 1, unsupported_duration).is_err(), "duration={} should be rejected", unsupported_duration);
196        }
197    }
198
199    // year buckets (Sequels) used to produce a negative bucket amplification → NaN → zero extra bits,
200    // so size and fan-out amplification were silently dropped. Year is now clamped to month.
201    #[test]
202    fn year_bucket_matches_month_bucket() {
203        let cases = [(512, 1), (64 * 1024, 1), (512, 10), (4096, 3)];
204        for (post_length, linked_base_ids_len) in cases {
205            let pow_year = get_minimum_post_pow(post_length, linked_base_ids_len, MILLIS_IN_YEAR).unwrap();
206            let pow_month = get_minimum_post_pow(post_length, linked_base_ids_len, MILLIS_IN_MONTH).unwrap();
207            assert_eq!(pow_year, pow_month, "post_length={} links={}", post_length, linked_base_ids_len);
208        }
209        assert!(get_minimum_post_pow(64 * 1024, 10, MILLIS_IN_YEAR).unwrap() > config::POW_MINIMUM_PER_POST);
210    }
211}