Fleet Group: solo mining condiviso da un gruppo

Tutto quello che un gestore e un miner ospitato devono sapere su Fleet Group: come si divide un blocco, le regole fissate alla creazione, i passi in console e sul link di invito, cosa mostra la pagina del cliente, rimozione e scioglimento, la ricevuta e i file CSV, e la modifica che abbiamo fatto al software del pool, pubblicata per intero.

Fleet Group è solo mining condiviso da un gruppo chiuso: i tuoi clienti minano con il proprio indirizzo Bitcoin su un server principale, e ogni blocco trovato lì è diviso tra loro per le share mandate dall’ultimo blocco, con l’1% a noi e la tua quota fissa a te, tutto in coinbase. Crei il gruppo dalla console con una firma; ogni cliente entra da un link con una firma. Le regole si scrivono alla creazione e non cambiano più.

Versione 1.0 · 25 ottobre 2026 · Bitcoin (BTC). Per il confronto con Fleet Split e Fleet Pool vedi SoloFury Fleet: Split, Group e Pool. Fleet Group è disponibile per i gestori che usano già Fleet Split; scrivi a [email protected].

In sintesi

  • Un gruppo ha un server principale, scelto da te, ed è copiato sugli altri server Fleet come riserva. Ogni server tiene il proprio round: un blocco trovato su un server è diviso tra i membri che hanno mandato share a quel server dall’ultimo blocco trovato lì.
  • La coinbase di un blocco del gruppo ha 1% a noi, la tua quota al tuo indirizzo, il resto ai membri in proporzione alle share. La parte di un membro sotto 5.000 satoshi non viene pagata e resta nella nostra uscita della fee. Nessun bonus a chi trova il blocco.
  • La tua quota va dall’1% al 49% del blocco intero, un solo valore per gruppo, fisso alla creazione. Massimo 52 membri.
  • Le credenziali del server principale sono disponibili subito dopo la firma del cliente; non c’è periodo di conferma.
  • Un membro può essere rimosso solo dopo 14 giorni senza share su tutti i server, con un motivo che il cliente legge. Un gruppo si può sciogliere solo quando nessun membro ha mandato share negli ultimi 14 giorni e nessun blocco del gruppo è ancora non registrato.
  • Ogni cliente ha una pagina con la sua parte del round in corso, «Verifica il pagamento ora» sul template reale del prossimo blocco, i blocchi del gruppo e una ricevuta PDF. Tu hai la console con membri, server, round, blocchi, file CSV e ricevute.
  • Il gruppo gira su una versione del software del pool che manteniamo noi, con una modifica di 43 righe che paga la tua quota. La modifica è pubblicata qui sotto, con il log dei test.

Come funziona Fleet Group

Fleet Split dà a ogni cliente un blocco tutto suo. Fleet Group mette insieme un insieme chiuso di clienti: il gruppo trova blocchi molto più spesso di qualsiasi membro da solo, e ogni blocco è diviso tra i membri per il lavoro fatto. Resta solo mining: non si paga niente finché il gruppo non trova un blocco.

La coinbase di un blocco del gruppo

Uscita della coinbaseRiceveImporto
Fee del poolSoloFury1% del blocco, fisso per tutti, più le parti dei membri sotto 5.000 sat
GestoreIl tuo indirizzo per questo gruppoLa tua quota, dall’1% al 49% del blocco intero, fissa alla creazione
MembriUn’uscita per ogni membro pagatoIl resto, diviso in proporzione alle share che ogni membro ha mandato a quel server nel round

Esempio sul sussidio di 3,125 BTC con la tua quota al 5%: l’uscita della fee è 3.125.000 sat, la tua 15.625.000 sat, e 293.750.000 sat si dividono tra i membri. Un membro con il 4% delle share del round riceve 11.750.000 sat, circa 0,1175 BTC. Le commissioni di rete si aggiungono al sussidio, quindi un blocco reale vale di più.

Un server principale, un round per server

Ogni server Fleet tiene il proprio conteggio delle share del gruppo. Quando un server trova un blocco per il gruppo, il round di quel server viene diviso e riparte da zero; i round degli altri server non vengono toccati. Un round finisce solo con un blocco, mai a orario: non c’è un azzeramento giornaliero o settimanale. Le share non passano mai dal round di un server a quello di un altro, e un round ancora aperto quando il gruppo viene sciolto non viene pagato. Le macchine dei tuoi clienti minano sul server principale che scegli alla creazione, quindi in condizioni normali tutte partecipano a ogni blocco del gruppo. Il gruppo è copiato sugli altri server Fleet per il failover: una macchina che passa al pool 3 durante un guasto continua a minare per il gruppo, ma le sue share contano nel round di quell’altro server, e solo un blocco trovato lì le paga.

La console mostra, per ogni server, se il gruppo è presente e allineato, il round in corso (da quale blocco e da quando), le share e i membri attivi. La pagina del cliente mostra lo stesso per le share del cliente. In pratica: se le macchine di un cliente restano in failover per tre giorni, quelle share pagano solo se quell’altro server trova un blocco, cosa rara; la pagina del cliente mostra un avviso rosso e la console elenca il membro tra quelli con più del 5% delle share fuori dal server principale. Quando compare, controlla l’ordine dei pool sulla macchina e che il server principale sia raggiungibile dalla farm.

La soglia di 5.000 satoshi

Un’uscita della coinbase sotto il limite dust non si può creare, quindi il software ha un pagamento minimo, fissato a 5.000 sat per ogni Fleet Group. La parte di un membro sotto la soglia non viene pagata su quel blocco e resta nella nostra uscita della fee; la pagina del cliente e le tabelle dei blocchi la mostrano come «sotto soglia» con l’importo. Non c’è un credito riportato al blocco successivo: quello è il modello di Fleet Pool, non di Fleet Group. In pratica la soglia riguarda i miner molto piccoli nei gruppi grandi: un miner da 1 TH/s in un gruppo da 50 PH/s ha circa 5.900 sat per blocco con quota al 5%, appena sopra la linea; in un gruppo da 100 PH/s circa 2.900 sat, sotto.

Prima di iniziare

  • Sei un gestore Fleet. Registrazione, indirizzo di accesso e console sono quelli di Fleet Split; basta lo stesso wallet che firma messaggi.
  • Un indirizzo nuovo per gruppo. Riceve la tua quota ed è il creatore del gruppo nel software del pool. Non deve essere usato in nessun altro gruppo o accordo Fleet Split; la console rifiuta un indirizzo già in uso.
  • Decidi le regole. Server principale (il server Fleet più vicino alle macchine), la tua quota in percentuale del blocco da 1 a 49 (ammessi due decimali), e se vuoi le tue macchine dichiarate (TH/s, numero, modello). Dopo non cambia niente senza un gruppo nuovo.
  • Spiega ai clienti cosa firmano. Manda loro questa pagina insieme all’invito. Il link mostra cinque cose da sapere, le regole, e chiede di dichiarare di aver capito che il gruppo può non pagare niente per mesi o anni.

Passo 1: crea il gruppo

Nella console su solofury.com/fleet/manage/ apri Gruppi e premi Nuovo gruppo.

  1. Scrivi un’etichetta (solo per te, ad esempio «Capannone nord»), scegli il server principale, imposta la quota del gestore in percentuale del blocco intero e, se vuoi, dichiara le tue macchine.
  2. Incolla l’indirizzo del gestore per questo gruppo: un indirizzo nuovo bc1q… o bc1p… del tuo wallet.
  3. Premi Continua: la pagina mostra il messaggio da firmare, 5 righe con Copia e QR. Firmalo nel wallet con quell’indirizzo e incolla la firma.
  4. Premi Crea il gruppo. La console conferma le regole fisse: quota del gestore, server principale, pagamento minimo 5.000 sat, nessun bonus a chi trova, round azzerato a ogni blocco, massimo 52 membri, rimozione solo dopo 14 giorni senza share.

Il gruppo nasce subito sul server principale ed è copiato sugli altri dalla nostra sincronizzazione in pochi minuti. La console mostra «Gruppo sui server» con il conteggio. Anche il tuo indirizzo può minare nel gruppo: le sue credenziali sono nella scheda del gruppo sotto Le tue impostazioni macchine.

Passo 2: invita un cliente

Nella scheda del gruppo, sotto Inviti, premi Nuovo invito: un’etichetta per il cliente, la validità (24 ore, 3 giorni o 7 giorni) e le macchine dichiarate del cliente, se le conosci. I valori dichiarati sono precompilati nella pagina del cliente; il cliente può correggerli e tu vedi i due valori affiancati.

La console mostra il link e la scadenza. Dallo solo al cliente: funziona una volta. Finché il cliente non firma, puoi revocarlo. L’elenco mostra ogni invito come non aperto, aperto ma non firmato, usato o scaduto. «Gruppo pieno» compare a 52 membri.

Passo 3: cosa fa il cliente

Il cliente apre il link da computer o telefono e vede, nella sua lingua: chi lo ha invitato, il server principale, il numero di membri, cinque cose da sapere prima di firmare, le regole del gruppo e una stima su un blocco trovato oggi per il gestore e per lui. Poi:

  1. Inserisce il suo indirizzo di pagamento (bc1q… o bc1p…) e controlla le macchine dichiarate.
  2. Copia il messaggio di 5 righe (o inquadra il QR), lo firma nel wallet che ha l’indirizzo e incolla la firma.
  3. Spunta Accetto le regole del gruppo e i termini SoloFury Fleet, e la dichiarazione: «Ho capito che questo è mining solo in gruppo: il pagamento arriva SOLO se il gruppo trova un blocco. Possono passare mesi o anni senza blocchi e in quel caso non ricevo niente, anche se le mie macchine hanno lavorato tutto il tempo. Se la mia parte è sotto 5.000 sat, non ricevo niente nemmeno su quel blocco.»
  4. Preme Firma ed entra.

Il link dice anche come si esce: spegni o sposti le macchine e, dopo 14 giorni senza share, il gestore rimuove l’indirizzo (Passo 8). Il cliente può chiederlo dalla sua pagina in qualsiasi momento.

L’indirizzo non deve essere in un altro gruppo né in un accordo Fleet Split, e non deve essere il tuo indirizzo di gestore. La firma prova il possesso e non contiene importi, destinatari o transazioni. Messaggi e firme restano conservati con il gruppo e vengono stampati nella ricevuta.

Passo 4: credenziali, subito

Dopo la firma la pagina del cliente mostra Sei nel gruppo con le impostazioni delle macchine e un pulsante Scarica la ricevuta (PDF). A differenza di Fleet Split non c’è un periodo di conferma: il server principale ha già il membro, e un blocco trovato lì paga secondo le regole dalla prima share.

CampoValore
Usernameindirizzo-del-cliente.NOME_MACCHINA
PasswordCome indicato nella pagina
Pool 1Server principale, prima porta
Pool 2Server principale, seconda porta
Pool 3Lo stesso gruppo su un altro server Fleet; compare nella pagina del cliente quando il gruppo è copiato su tutti i server
Stratum V2Stessi host e porte; la chiave pubblica dell’authority è indicata accanto

Tieni i pool 1 e 2 sul server principale, così le share finiscono nel round che paga con tutto il gruppo. Il pool 3 è una rete di sicurezza per un guasto. Solo gli host e le porte indicati nella pagina appartengono al gruppo: su una porta solo normale del nostro pool l’indirizzo del cliente mina da solo, fuori dal gruppo. Le macchine intere funzionano con qualsiasi firmware; i pool group di Braiins OS possono mandare una parte fissa di ogni macchina, come spiegato nella guida Fleet Split.

Passo 5: segui il gruppo in console

La scheda del gruppo mostra: stato sui server, membri attivi nelle ultime 24 ore rispetto a totale e massimo, hashrate misurato rispetto al dichiarato, blocchi trovati e quelli non ancora registrati, e gli avvisi aperti: richieste di uscita, membri rimovibili ora, rimozioni in corso, inviti in scadenza, membri con più del 5% delle share fuori dal server principale, membri senza share da 48 ore.

La tabella Membri elenca, per ogni membro: indirizzo ed etichetta, data di ingresso, macchine dichiarate e misurate (con la correzione del cliente accanto alla tua), ultima attività, giorni senza share, da quando è rimovibile, richieste di uscita, share nei round in corso e una nota privata che leggi solo tu. La tabella Server e round mostra per ogni server il round, le share e i membri attivi. Per ogni membro c’è un pulsante Ricevuta.

Passo 6: quando arriva un blocco

Un blocco del gruppo compare in console sotto Blocchi del gruppo con altezza, data, server, registrato o non registrato, valore della coinbase, la tua uscita, i membri pagati e l’uscita della fee comprese le parti sotto soglia. Il software registra un blocco alle prime conferme; fino ad allora è «non registrato» e nessuna rimozione o scioglimento è possibile. La pagina del cliente mostra lo stesso blocco con la sua uscita e gli altri membri mascherati. I fondi sono negli indirizzi dal blocco stesso e diventano spendibili dopo 100 conferme, circa 17 ore; la pagina mostra il conteggio. La coinbase è pubblica su qualsiasi explorer.

Passo 7: file CSV e ricevute

  • CSV round per server: una riga per server e membro dei round in corso, con share e parti.
  • CSV blocchi: una riga per ogni uscita della coinbase di ogni blocco del gruppo, con le parti sotto soglia. Entrambi i file portano lo SHA-256 del contenuto nell’intestazione di risposta X-SF-SHA256, la stessa impronta stampata nel PDF.
  • Ricevuta del gruppo (PDF): le regole fisse, gli indirizzi, la tua prova di possesso, la versione dei termini e l’hash del documento. La ricevuta per membro aggiunge il messaggio, la firma e la dichiarazione del membro. Il cliente scarica la stessa ricevuta dal link di invito, che resta valido a questo scopo.

Passo 8: rimuovi un membro

Un membro si può rimuovere solo dopo 14 giorni senza share su tutti i server e con 0 share in ogni round in corso. Premi Rimuovi: la console mostra l’ultima share su ogni server e ti dice se la rimozione è possibile, e altrimenti da quale data. Scegli un motivo dalla lista: macchine spente, contratto finito, richiesta del cliente, trasferito ad altro gruppo, altro (con nota). Il motivo viene registrato e il cliente lo legge nella sua pagina. La rimozione è eseguita da SoloFury su tutti i server in pochi minuti.

Anche il cliente può chiedere la rimozione dalla sua pagina, firmando con il suo indirizzo. La richiesta compare nei tuoi avvisi con la data. Dal giorno in cui la rimozione diventa possibile hai 7 giorni per eseguirla; poi la esegue SoloFury. Nessuna share va persa: finché un membro ha ancora share in un round in corso, la rimozione aspetta che quel round finisca con un blocco o che passino i 14 giorni.

Passo 9: sciogli il gruppo

Sciogli il gruppo è possibile solo quando nessun membro ha mandato share negli ultimi 14 giorni e nessun blocco del gruppo è non registrato. Tutti i membri risultano rimossi con «gruppo sciolto»; lo storico dei blocchi resta leggibile in console e nelle pagine dei clienti. Un cliente che passa a un tuo gruppo nuovo firma un nuovo invito.

La pagina del cliente

Ogni membro ha una pagina su solofury.com/miner/?addr=<indirizzo>&coin=btc, pubblica per indirizzo come ogni pagina miner del pool. Il pannello Fleet Group mostra:

  • I tuoi worker nell’ultima ora, il server principale e un avviso rosso quando le macchine sono in failover su un altro server: in quel caso le share contano solo per un blocco di quell’altro server, quindi controlla l’ordine dei pool sulle macchine o avvisa il gestore.
  • La tua parte del round su ogni server e Se arrivasse un blocco ora: la tua uscita, quella del gestore, la fee del pool, e se la tua parte è sotto 5.000 sat.
  • Le regole come fissate alla creazione, dimensione e hashrate del gruppo, e i blocchi del gruppo con la tua uscita su ciascuno.
  • Verifica il pagamento ora: la pagina chiede al server principale il template reale del prossimo blocco e confronta la coinbase con le regole. Riporta la fee del pool, la quota del gestore e la tua parte, oppure un avviso se qualcosa non torna. L’ultima verifica e la sua data restano nella pagina.
  • Chiedi la rimozione, con una firma, come nel Passo 8.
  • La data di ingresso, la dichiarazione firmata e la ricevuta.

Si vede solo la parte del membro; gli altri membri compaiono mascherati nello storico dei blocchi. Gli avvisi Telegram per il gruppo (blocco trovato, membri senza share, membri rimovibili, richieste di uscita) si stanno aggiungendo al bot Fleet; questa pagina dirà quando sono disponibili.

Il software del pool e la tua quota

I server Fleet per Bitcoin usano blitzpool (AGPL-3.0), ramo main del 22 ottobre 2026, commit d10ab0e. La sua funzione Team Mining divide un blocco di gruppo tra i membri per share, ma non prevede una quota fissa per chi gestisce il gruppo. Abbiamo aggiunto una modifica di 43 righe in un solo file del motore dei gruppi (crates/bp-group-solo-engine/src/distribution.rs): legge la quota del gestore da una colonna della riga del gruppo nel database e la paga all’indirizzo del gestore come frazione fissa della parte miner, con il meccanismo che blitzpool ha già per il bonus a chi trova. Con quota a zero il comportamento è identico al software originale. La validazione delle share, la costruzione del template del blocco, le uscite della coinbase, lo storico dei blocchi, le API e lo stratum non sono modificati. Nel software la tua quota è memorizzata in parti per milione della parte miner del 99% (5% del blocco = 50.505 ppm), quindi l’importo può differire dalla percentuale esatta di 1 o 2 satoshi.

Come lo verifichi:

  • Prima di un blocco: «Verifica il pagamento ora» nella pagina del cliente legge il template reale del prossimo blocco e mostra ogni uscita in satoshi, compresa quella del gestore. Il controllo confronta l’uscita del gestore con la quota del gruppo al satoshi: se la quota mancasse su un server (la colonna che la modifica legge la aggiungiamo noi, non una migrazione del software, e un valore assente vale zero), la pagina mostrerebbe la quota del gestore a 0 con un avviso, quindi il controllo del cliente copre anche il rischio del gestore.
  • Dopo un blocco: la coinbase è su qualsiasi explorer; la console e i file CSV elencano ogni uscita.
  • Nel codice: il diff completo è qui sotto, con il log dei 98 test del motore dei gruppi eseguiti con la modifica, 4 dei quali aggiunti per essa. Lo stesso diff è applicato su ogni server Fleet.

Aggiornamenti. Ogni nuova versione di blitzpool gira prima su un solo server per almeno un giorno, poi viene portata sugli altri con 7 giorni di preavviso, mai mentre un blocco di un gruppo non è registrato. Le correzioni di sicurezza si applicano subito e si comunicano dopo. La versione in produzione e il diff restano in questa pagina.

Il diff completo (sf-group-manager-fee, su blitzpool d10ab0e: 43 righe in distribution.rs più 4 test di integrazione)
diff --git a/crates/bp-group-solo-engine/src/distribution.rs b/crates/bp-group-solo-engine/src/distribution.rs
index 451b6c6..db8fb6b 100644
--- a/crates/bp-group-solo-engine/src/distribution.rs
+++ b/crates/bp-group-solo-engine/src/distribution.rs
@@ -171,6 +171,22 @@ async fn compute_distribution(
       .ok_or(DistributionError::GroupNotFound { group_id })?;
   let finder_bonus_ppm = group_row.finder_bonus_ppm.unwrap_or(0).max(0) as u32;

+    // SoloFury patch (sf-group-manager-fee): a fixed manager quota for the
+    // group creator, as ppm of the miner cut, read from the optional column
+    // `pplns_group."managerFeePpm"` (added by the operator, not by a
+    // migration). When set (> 0) it REPLACES the finder bonus: the same
+    // score-weight boost `S·m/(1e6−m)` pays exactly `m` of the miner cut to
+    // the creator's address at every revenue, and the session's own finder
+    // bonus is not applied. A missing column or a NULL/0 value leaves
+    // upstream behaviour untouched. Only the recipient and the ppm change;
+    // share validation, templates and the §4 builder are upstream code.
+    let manager_fee_ppm = sf_manager_fee_ppm(pool, group_id).await;
+    let (bonus_ppm, bonus_address): (u32, AddressId) = if manager_fee_ppm > 0 {
+        (manager_fee_ppm, group_row.creator_address.clone())
+    } else {
+        (finder_bonus_ppm, finder_address.clone())
+    };
+
   // 2. Round state from Redis. Mode-aware: a PROP group reads its per-round
   //    aggregate; a Window group trims to the sliding window first, so the
   //    built distribution is always current (even for an idle group).
@@ -196,8 +212,8 @@ async fn compute_distribution(
       fee_percent: config.fee_percent,
       min_payout_sats: config.min_payout_sats,
       coinbase_weight_budget: config.coinbase_weight_budget,
-        finder_bonus_ppm,
-        finder_address: Some(finder_address),
+        finder_bonus_ppm: bonus_ppm,
+        finder_address: Some(&bonus_address),
       // An empty round is routine (every reset DELs the hash). Only the
       // uncached call bootstraps, and builds are per-finder, so a bootstrap
       // distribution neither outlives the empty round nor reaches another
@@ -238,3 +254,26 @@ mod tests {
       assert_eq!(dist_cfg.coinbase_weight_budget, 60_000);
   }
}
+
+/// SoloFury patch: `pplns_group."managerFeePpm"` if the column exists and the
+/// value is positive, else 0. The query is runtime (not a sqlx macro) so the
+/// offline query cache and the upstream schema stay untouched; a missing
+/// column is logged once per process and treated as 0.
+async fn sf_manager_fee_ppm(pool: &PgPool, group_id: Uuid) -> u32 {
+    static WARNED: std::sync::OnceLock<()> = std::sync::OnceLock::new();
+    let r: Result<Option<i32>, sqlx::Error> =
+        sqlx::query_scalar(r#"SELECT "managerFeePpm" FROM pplns_group WHERE id = $1"#)
+            .bind(group_id)
+            .fetch_one(pool)
+            .await;
+    match r {
+        Ok(Some(ppm)) if ppm > 0 => ppm as u32,
+        Ok(_) => 0,
+        Err(err) => {
+            if WARNED.set(()).is_ok() {
+                tracing::warn!(%err, "sf-group-manager-fee: managerFeePpm unreadable (column missing?); using 0");
+            }
+            0
+        }
+    }
+}
diff --git a/crates/bp-group-solo-engine/tests/sf_manager_fee_integration.rs b/crates/bp-group-solo-engine/tests/sf_manager_fee_integration.rs
new file mode 100644
index 0000000..b4be12c
--- /dev/null
+++ b/crates/bp-group-solo-engine/tests/sf_manager_fee_integration.rs
@@ -0,0 +1,189 @@
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//! SoloFury patch `sf-group-manager-fee`: the group creator receives a fixed
+//! quota of the miner cut, read from `pplns_group."managerFeePpm"`.
+//! Needs Postgres (`BP_PG_URL`) and Redis (`BP_REDIS_URL`); skips otherwise.
+
+use std::time::Duration;
+
+use bp_common::AddressId;
+use bp_group_solo_engine::config::GroupSoloEngineConfig;
+use bp_group_solo_engine::distribution::{DistributionBuilder, DistributionConfig};
+use bp_group_solo_engine::round::GroupRoundStore;
+use redis::Client;
+use sqlx::{postgres::PgPoolOptions, PgPool};
+use uuid::Uuid;
+
+const REDIS_URL: &str = "redis://127.0.0.1:16379";
+const PG_URL: &str = "postgres://postgres:postgres@localhost:15433/public_pool";
+const FEE_ADDR: &str = "3J98t1WpEZ73CNmQviecrnyiWrnqRhWNLy";
+const MANAGER: &str = "bc1qrp33g0q5c5txsp9arysrx4k6zdkfs4nce4xj0gdcccefvpysxf3qccfmv3";
+const CLIENT_A: &str = "bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4";
+const CLIENT_B: &str = "bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq";
+const SUBSIDY: u64 = 312_500_000;
+
+struct H {
+    pool: PgPool,
+    builder: DistributionBuilder,
+    round: GroupRoundStore,
+    group_id: Uuid,
+}
+
+async fn spawn_or_skip(redis_db: u8, manager_ppm: Option<i32>, finder_ppm: Option<i32>) -> Option<H> {
+    let pg_url = std::env::var("BP_PG_URL").unwrap_or_else(|_| PG_URL.to_string());
+    let redis_base = std::env::var("BP_REDIS_URL").unwrap_or_else(|_| REDIS_URL.to_string());
+    let pool = match tokio::time::timeout(
+        Duration::from_secs(2),
+        PgPoolOptions::new().max_connections(2).connect(&pg_url),
+    )
+    .await
+    {
+        Ok(Ok(p)) => p,
+        _ => {
+            eprintln!("PG unavailable — skipping");
+            return None;
+        }
+    };
+    // Own slots in the distribution binary's range (20..23 are unused there).
+    let redis_db =
+        bp_test_support::redis_db_in_range(bp_test_support::redis_db::GS_DISTRIBUTION, redis_db).await;
+    let client = Client::open(format!("{redis_base}/{redis_db}")).ok()?;
+    let mut conn = match tokio::time::timeout(
+        Duration::from_secs(2),
+        bp_test_support::connection_manager(client),
+    )
+    .await
+    {
+        Ok(Ok(c)) => c,
+        _ => {
+            eprintln!("Redis unavailable — skipping");
+            return None;
+        }
+    };
+    redis::cmd("FLUSHDB").query_async::<()>(&mut conn).await.ok()?;
+
+    // The operator adds the column outside the migrations (see the patch).
+    sqlx::query(r#"ALTER TABLE pplns_group ADD COLUMN IF NOT EXISTS "managerFeePpm" integer"#)
+        .execute(&pool)
+        .await
+        .expect("add column");
+    let group_id = Uuid::new_v4();
+    sqlx::query(
+        r#"INSERT INTO pplns_group
+             (id, name, "creatorAddress", "adminTokenHash", active, "createdAt", "updatedAt",
+              "isPublic", "finderBonusPpm", "managerFeePpm")
+           VALUES ($1, $2, $3, $4, true, 0, 0, false, $5, $6)"#,
+    )
+    .bind(group_id)
+    .bind(format!("sf-mgr-{group_id}"))
+    .bind(MANAGER)
+    .bind(format!("hash-{group_id}"))
+    .bind(finder_ppm)
+    .bind(manager_ppm)
+    .execute(&pool)
+    .await
+    .expect("seed group");
+
+    let round = GroupRoundStore::new(conn);
+    let cfg = DistributionConfig::from_engine_config(&GroupSoloEngineConfig {
+        fee_address: Some(AddressId::new(FEE_ADDR).unwrap()),
+        ..GroupSoloEngineConfig::default()
+    });
+    let builder = DistributionBuilder::new(pool.clone(), round.clone(), cfg);
+    Some(H { pool, builder, round, group_id })
+}
+
+async fn cleanup(h: &H) {
+    sqlx::query("DELETE FROM pplns_group WHERE id = $1")
+        .bind(h.group_id)
+        .execute(&h.pool)
+        .await
+        .ok();
+}
+
+fn outputs(h_entries: &[(AddressId, u64)], a: &str) -> Vec<i64> {
+    h_entries
+        .iter()
+        .filter(|(x, _)| x.as_str() == a)
+        .map(|(_, s)| *s as i64)
+        .collect()
+}
+
+#[tokio::test]
+async fn manager_quota_is_an_exact_fraction_of_the_miner_cut() {
+    // 5 % of the block with a 1 % pool fee = 50 505 ppm of the miner cut.
+    let Some(h) = spawn_or_skip(20, Some(50_505), None).await else { return };
+    let a = AddressId::new(CLIENT_A).unwrap();
+    let b = AddressId::new(CLIENT_B).unwrap();
+    let g = h.group_id.to_string();
+    h.round.record_share(None, &g, a.as_str(), 75.0, 1).await.unwrap();
+    h.round.record_share(None, &g, b.as_str(), 25.0, 2).await.unwrap();
+
+    let result = h.builder.build(h.group_id, SUBSIDY, &a).await.expect("ok");
+    let entries = result.distribution.payout_entries_at(SUBSIDY).expect("§4 vector");
+    let pot = bp_share::miner_pot_sats(result.distribution.fee_ppm, SUBSIDY) as i64;
+
+    let mgr = outputs(&entries, MANAGER);
+    assert_eq!(mgr.len(), 1, "manager has exactly one output");
+    let expected_mgr = pot * 50_505 / 1_000_000;
+    assert!((mgr[0] - expected_mgr).abs() <= 2, "manager {} expected {}", mgr[0], expected_mgr);
+
+    let rest = pot - mgr[0];
+    let oa = outputs(&entries, CLIENT_A)[0];
+    let ob = outputs(&entries, CLIENT_B)[0];
+    assert!((oa - rest * 3 / 4).abs() <= 2, "client A {} expected {}", oa, rest * 3 / 4);
+    assert!((ob - rest / 4).abs() <= 2, "client B {} expected {}", ob, rest / 4);
+    // The fee output is still exactly the pool fee: nothing of the quota leaks to the pool.
+    let fee = outputs(&entries, FEE_ADDR)[0];
+    assert!((fee - (SUBSIDY as i64 - pot)).abs() <= 2, "fee {} expected {}", fee, SUBSIDY as i64 - pot);
+    cleanup(&h).await;
+}
+
+#[tokio::test]
+async fn manager_quota_replaces_the_finder_bonus_and_is_finder_independent() {
+    let Some(h) = spawn_or_skip(21, Some(20_202), Some(300_000)).await else { return };
+    let a = AddressId::new(CLIENT_A).unwrap();
+    let b = AddressId::new(CLIENT_B).unwrap();
+    let g = h.group_id.to_string();
+    h.round.record_share(None, &g, a.as_str(), 50.0, 1).await.unwrap();
+    h.round.record_share(None, &g, b.as_str(), 50.0, 2).await.unwrap();
+
+    let ra = h.builder.build(h.group_id, SUBSIDY, &a).await.expect("ok");
+    let rb = h.builder.build(h.group_id, SUBSIDY, &b).await.expect("ok");
+    let ea = ra.distribution.payout_entries_at(SUBSIDY).unwrap();
+    let eb = rb.distribution.payout_entries_at(SUBSIDY).unwrap();
+    // Same coinbase whoever the finder is: equal peers get equal outputs, the
+    // 30 % finder bonus of the row is NOT applied.
+    assert_eq!(outputs(&ea, CLIENT_A), outputs(&ea, CLIENT_B));
+    assert_eq!(outputs(&ea, MANAGER), outputs(&eb, MANAGER));
+    assert_eq!(outputs(&ea, CLIENT_A), outputs(&eb, CLIENT_A));
+    cleanup(&h).await;
+}
+
+#[tokio::test]
+async fn zero_or_null_quota_keeps_upstream_behaviour() {
+    let Some(h) = spawn_or_skip(22, None, Some(3_200)).await else { return };
+    let a = AddressId::new(CLIENT_A).unwrap();
+    let b = AddressId::new(CLIENT_B).unwrap();
+    let g = h.group_id.to_string();
+    h.round.record_share(None, &g, a.as_str(), 50.0, 1).await.unwrap();
+    h.round.record_share(None, &g, b.as_str(), 50.0, 2).await.unwrap();
+    let r = h.builder.build(h.group_id, SUBSIDY, &a).await.expect("ok");
+    let e = r.distribution.payout_entries_at(SUBSIDY).unwrap();
+    assert!(outputs(&e, MANAGER).is_empty(), "no manager output without a quota");
+    assert!(outputs(&e, CLIENT_A)[0] > outputs(&e, CLIENT_B)[0], "upstream finder bonus still applies");
+    cleanup(&h).await;
+}
+
+#[tokio::test]
+async fn empty_round_bootstrap_still_pays_the_manager_quota() {
+    let Some(h) = spawn_or_skip(23, Some(50_505), None).await else { return };
+    let a = AddressId::new(CLIENT_A).unwrap();
+    let r = h.builder.build(h.group_id, SUBSIDY, &a).await.expect("ok");
+    let e = r.distribution.payout_entries_at(SUBSIDY).unwrap();
+    let pot = bp_share::miner_pot_sats(r.distribution.fee_ppm, SUBSIDY) as i64;
+    let mgr = outputs(&e, MANAGER);
+    assert_eq!(mgr.len(), 1);
+    assert!((mgr[0] - pot * 50_505 / 1_000_000).abs() <= 2);
+    assert!((outputs(&e, CLIENT_A)[0] - (pot - mgr[0])).abs() <= 2, "bootstrap claimant gets the rest");
+    cleanup(&h).await;
+}
Log dei test del motore dei gruppi con la modifica (24 ottobre 2026, 98 test, 0 falliti)
# blitzpool-sf test run — 2026-10-24T00:29Z — base d10ab0e + sf-group-manager-fee (cb06164)
rustc rustc 1.97.0 (2d8144b78 2026-07-07) · Postgres 16 (migrations 0000-0022) · Redis 8 (1024 db)
   Running unittests src/lib.rs (target/debug/deps/bp_group_solo_engine-2d27263d2fe5c576)
test config::tests::a_non_zero_fee_validates ... ok
test config::tests::a_typo_in_the_fee_address_is_refused ... ok
test config::tests::default_validates_clean ... ok
test config::tests::fee_percent_above_hundred_rejects ... ok
test config::tests::fee_percent_nan_rejects ... ok
test config::tests::fee_percent_negative_rejects ... ok
test config::tests::min_payout_below_dust_rejects ... ok
test config::tests::min_payout_exactly_dust_accepts ... ok
test config::tests::the_default_config_is_refused_because_it_has_no_fee_address ... ok
test config::tests::weight_budget_too_low_rejects ... ok
test distribution::tests::distribution_config_from_engine_config_carries_fields ... ok
test engine::tests::engine_error_carries_source_variants ... ok
test engine::tests::history_rows_transcribe_every_paid_output_besides_the_pool ... ok
test engine::tests::lookup_error_reuses_cached_mode_never_misroutes_window ... ok
test engine::tests::trim_watermark_gates_to_new_buckets_only ... ok
test reset::tests::custom_across_the_dst_fall_back_fires_at_local_midnight ... ok
test reset::tests::daily_on_the_dst_fall_back_day_moves_to_the_next_day ... ok
test reset::tests::next_fire_custom_no_last_reset_fires_at_next_midnight ... ok
test reset::tests::next_fire_custom_with_recent_last_reset_skips_until_interval_elapsed ... ok
test reset::tests::next_fire_daily_in_utc ... ok
test reset::tests::next_fire_daily_in_zurich_tz ... ok
test reset::tests::next_fire_monthly_lands_on_first ... ok
test reset::tests::next_fire_on_a_day_without_midnight_does_not_panic ... ok
test reset::tests::next_fire_weekly_lands_on_monday ... ok
test reset::tests::preset_from_wire_strings ... ok
test reset::tests::reset_runner_is_cloneable_without_c_clone_bound ... ok
test reset::tests::reset_schedule_from_row_handles_missing_fields ... ok
test reset::tests::weekly_across_the_dst_fall_back_lands_on_monday ... ok
test round::tests::best_share_partial_eq ... ok
test round::tests::key_helpers_format_correctly ... ok
test round::tests::round_stats_total_is_sum_of_per_address ... ok
test round::tests::window_key_helpers_format_correctly ... ok
test reset::tests::monthly_across_the_dst_fall_back_lands_on_the_first ... ok
test result: ok. 33 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s
   Running tests/distribution_integration.rs (target/debug/deps/distribution_integration-94ee904bdefb95c1)
test an_empty_round_pays_the_finder_not_the_whole_block_to_the_pool ... ok
test build_for_nonexistent_group_returns_group_not_found ... ok
test a_share_after_a_bootstrap_reaches_the_next_build ... ok
test build_with_shares_returns_payouts_and_writes_snapshot ... ok
test concurrent_same_finder_builds_share_one_compute ... ok
test finder_bonus_from_db_row_is_applied ... ok
test distinct_rewards_for_same_group_finder_run_independently ... ok
test invalidate_all_triggers_fresh_compute ... ok
test result: ok. 8 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.13s
   Running tests/engine_integration.rs (target/debug/deps/engine_integration-7a149d8fac6cb6fa)
test a_group_coinbase_below_the_block_subsidy_is_refused ... ok
test a_group_block_far_off_the_reference_is_still_booked ... ok
test a_redelivered_apply_books_nothing ... ok
test a_redelivered_apply_does_not_reset_the_round_again ... ok
test a_retry_completes_a_reset_the_first_apply_did_not_reach ... ok
test a_richer_block_leaves_nobody_owing ... ok
test block_found_books_the_job_coinbase_after_the_round_moved ... ok
test build_distribution_returns_payouts_after_shares ... ok
test concurrent_applies_of_one_block_book_it_once ... ok
test duplicate_block_found_does_not_double_the_history ... ok
test manual_reset_wipes_group_state ... ok
test on_block_found_applies_distribution_and_resets_round ... ok
test on_block_found_keeps_round_when_reset_flag_false ... ok
test reader_best_difficulty_after_shares ... ok
test on_block_found_with_finder_bonus_merges_duplicate_outputs ... ok
test record_reject_updates_round_rejected_total ... ok
test record_share_then_round_stats_sees_it ... ok
test reschedule_group_arms_and_tears_down_reset_cron ... ok
test spawn_core_skips_startup_reset_crons ... ok
test window_grow_invalidates_mode_cache_keeps_in_window_bucket ... ok
test window_mode_kick_drops_the_member_from_the_payout_source ... ok
test window_mode_record_path_trims_aged_buckets ... ok
test window_mode_reject_is_windowed_not_tallied ... ok
test result: ok. 23 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.56s
   Running tests/regtest_group_solo_block_submit.rs (target/debug/deps/regtest_group_solo_block_submit-22aa8a04b030d4a0)
test group_solo_three_member_distribution_block_accepted_by_core ... ok
test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s
   Running tests/reset_integration.rs (target/debug/deps/reset_integration-381fc545f8d05a69)
test reset_custom_preset_skips_if_interval_not_elapsed ... ok
test reset_custom_preset_fires_when_interval_elapsed ... ok
test reset_debounce_skips_within_60_seconds ... ok
test reset_fires_past_debounce_window ... ok
test reset_nonexistent_group_returns_error ... ok
test reset_scheduled_wipes_redis_pg_state_and_stamps ... ok
test reset_one_group_does_not_affect_another ... ok
test result: ok. 7 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.12s
   Running tests/round_integration.rs (target/debug/deps/round_integration-0ffcfc32bf229e96)
test a_redelivered_share_is_still_deduped_across_a_round_reset ... ok
test forget_member_subtracts_contribution ... ok
test multiple_groups_redis_state_is_isolated ... ok
test forget_member_window_removes_the_address_from_both_lanes ... ok
test read_by_address_returns_aggregate ... ok
test read_payout_shares_window_trims_on_read ... ok
test record_reject_increments_per_address ... ok
test read_round_stats_returns_per_address_and_rejected ... ok
test record_share_writes_all_keys ... ok
test record_share_is_idempotent_per_share_id ... ok
test reset_full_wipes_everything_including_last_accepted ... ok
test reset_for_block_found_preserves_last_accepted_share_at ... ok
test update_best_share_only_replaces_on_improvement ... ok
test round_stats_window_reads_the_reject_lane_not_the_running_tally ... ok
test windowed_record_is_idempotent_per_share_id ... ok
test windowed_record_aggregates_into_buckets ... ok
test windowed_reject_lands_in_its_bucket_and_window_aggregate ... ok
test windowed_trim_and_reset_cover_the_reject_lane ... ok
test windowed_trim_does_not_strand_a_negative_entry ... ok
test windowed_trim_drops_aged_buckets ... ok
test result: ok. 20 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.03s
   Running tests/sf_manager_fee_integration.rs (target/debug/deps/sf_manager_fee_integration-2f0a9f24a1978b6e)
test manager_quota_is_an_exact_fraction_of_the_miner_cut ... ok
test empty_round_bootstrap_still_pays_the_manager_quota ... ok
test zero_or_null_quota_keeps_upstream_behaviour ... ok
test manager_quota_replaces_the_finder_bonus_and_is_finder_independent ... ok
test result: ok. 4 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.07s
   Running tests/stream_equivalence.rs (target/debug/deps/stream_equivalence-1186db8b4e5fe73c)
test in_process_and_stream_paths_leave_identical_group_solo_round ... ok
test duplicate_entries_in_the_stream_do_not_break_group_solo_equivalence ... ok
test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.10s
test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

Le regole in una tabella

RegolaValore
Fee del pool1% del blocco, fissa; le parti sotto soglia restano in questa uscita
La tua quotaDall’1% al 49% del blocco intero, fino a due decimali, un valore per gruppo, fissa alla creazione
Divisione tra i membriIn proporzione alle share mandate al server che ha trovato il blocco, dall’ultimo blocco trovato lì
Pagamento minimo5.000 satoshi per membro per blocco
Bonus a chi trovaNessuno
MembriMassimo 52 per gruppo
Server principaleScelto alla creazione; il gruppo è copiato sugli altri server Fleet come failover
Indirizzibc1q… e bc1p…; un indirizzo nuovo del gestore per gruppo; ogni indirizzo cliente in un solo gruppo o accordo
Invito24 ore, 3 giorni o 7 giorni; un solo uso; revocabile finché non firmato
CredenzialiServer principale subito; terza voce pool quando il gruppo è su tutti i server
Rimozione di un membroDopo 14 giorni senza share su tutti i server, con motivo; il cliente può chiederla; SoloFury interviene dopo 7 giorni
ScioglimentoSolo senza share dei membri negli ultimi 14 giorni e senza blocchi non registrati
BlocchiRegistrati alle prime conferme; fondi spendibili dopo 100
Server dedicatoDa 100 PH/s, su richiesta, un server vicino alla tua farm gestito da SoloFury come server principale
Assistenza[email protected], risposta entro 1 giorno lavorativo

Le regole complete sono nei termini Fleet, versione 1.3, sezione 13.

Cosa dicono i numeri

Il gruppo trova blocchi come uno solo, quindi l’hashrate del gruppo fissa la frequenza, e la parte di lavoro di ogni membro fissa la dimensione della sua quota. La tabella usa la difficoltà di rete del 16 ottobre 2026.

Hashrate del gruppoTempo medio per bloccoBlocchi all’annoAlmeno 1 blocco in 12 mesi
20 PH/s349 giorni1,065%
50 PH/s140 giorni2,693%
100 PH/s70 giorni5,299,5%
200 PH/s35 giorni10,5oltre 99,9%

Due esempi con quota del gestore al 5%. Un cliente con due macchine, circa 470 TH/s, in un gruppo da 20 PH/s ha il 2,35% delle share: circa 0,069 BTC per blocco sul solo sussidio, con il gruppo che trova circa un blocco l’anno. Un membro con 2 PH/s in un gruppo da 50 PH/s ha il 4% delle share: circa 0,1175 BTC per blocco sul solo sussidio, circa 0,31 BTC l’anno in media. La tua quota arriva da ogni blocco del gruppo: con il 5% del blocco e 2,6 blocchi l’anno, circa 0,41 BTC l’anno in media. Medie su molti anni, non un reddito: un blocco può arrivare domani o farsi aspettare molto di più.

Fonte: difficoltà di rete 140,58 T, fissata al blocco 971.712 il 16 ottobre 2026. Formula: tempo medio = difficoltà × 232 ÷ hashrate; probabilità in un periodo t = 1 − e−t/T. Commissioni di rete escluse.

Come lavoriamo

  • Pagamento non custodial. Le uscite sono nella coinbase che costruiamo; la rete crea i bitcoin a ogni indirizzo. Non teniamo mai la parte di un membro.
  • Possesso con la firma. Tu provi l’indirizzo del gestore, ogni cliente prova il proprio. Firme, messaggi e dichiarazione restano conservati e stampati nella ricevuta.
  • Regole scritte una volta. Quota, server, soglia, limite e regola del round sono fissi alla creazione e identici in console, nella pagina del cliente, nella ricevuta e nei termini.
  • Software pubblicato. La modifica al software del pool è in questa pagina per intero, con i suoi test, e ogni cliente può leggere in ogni momento la coinbase del prossimo blocco.
  • Cambiamenti annunciati. Gli aggiornamenti che possono interrompere il mining sono annunciati 7 giorni prima su pagine, Telegram ed email; le correzioni di sicurezza si applicano subito e si comunicano dopo.

Conclusione

Fleet Group richiede una firma tua e una di ogni cliente. Da lì in poi ogni blocco trovato sul server principale paga te, noi e i membri in coinbase, per share, con regole che nessuno può cambiare. Il gruppo trova blocchi molto più spesso di un singolo cliente, e ogni numero in console, nella pagina del cliente, nella ricevuta e nei file CSV viene dalla catena o dal template reale del prossimo blocco. Manda ai tuoi clienti questa pagina insieme all’invito: risponde alle domande che hanno prima di firmare.

Apri un gruppo per i tuoi clienti

Un server principale, la tua quota fissa, il resto ai membri per share, tutto in coinbase. Scrivi a [email protected] se non sei ancora un gestore Fleet, oppure apri la console.

Apri la console →

Da leggere dopo

Domande frequenti

Ogni blocco del gruppo è diviso tra tutti i membri?

Un blocco è diviso tra i membri che hanno mandato share al server che lo ha trovato, dall'ultimo blocco trovato su quel server. Ogni server Fleet tiene il proprio round. Le macchine che minano sul server principale, come dicono le credenziali, partecipano a ogni blocco di quel server.

Posso cambiare la quota o il server principale dopo la creazione?

No. Quota, server principale, soglia, numero massimo di membri e regola del round sono scritti alla creazione e i clienti li firmano. Una regola diversa significa un gruppo nuovo e nuove firme.

Che fine fa una parte sotto 5.000 satoshi?

Non viene pagata e resta nell'uscita della fee di SoloFury. La pagina del cliente la mostra prima e dopo il blocco, con l'importo. Un miner da 1 TH/s in un gruppo da 100 PH/s sta sotto la soglia su un blocco normale; in un gruppo da 50 PH/s sta appena sopra.

Il cliente può minare subito dopo la firma?

Sì. Il server principale è pronto subito e un blocco trovato lì paga secondo le regole dalla prima share. La terza voce pool, su un altro server Fleet, compare nella pagina del cliente quando il gruppo è copiato ovunque, in pochi minuti.

Come esce un cliente dal gruppo?

Il cliente scollega le macchine e, se vuole, chiede la rimozione dalla propria pagina con una firma. Dopo 14 giorni senza share su tutti i server, il gestore rimuove il membro con un motivo che il cliente legge; se il gestore non fa niente per 7 giorni, lo fa SoloFury. Le share già mandate in un round contano ancora.

Perché avete modificato il software del pool, e posso leggere la modifica?

blitzpool divide un blocco di gruppo tra i membri ma non ha una quota fissa per chi gestisce il gruppo. Abbiamo aggiunto 43 righe che pagano quella quota all'indirizzo del gestore. Il diff completo e il log dei test sono in questa pagina, e la pagina del cliente può mostrare in ogni momento la coinbase reale del prossimo blocco.

C'è un bonus per il membro che trova il blocco?

No. Il bonus a chi trova è a zero in ogni Fleet Group. Il blocco si divide solo per le share del round.

Che dimensione di gruppo ha senso?

Il gruppo trova blocchi come uno solo. Alla difficoltà di ottobre 2026, 50 PH/s trovano 2,6 blocchi l'anno in media e 100 PH/s circa 5,2. Un gruppo ha al massimo 52 membri e le parti sotto 5.000 sat non vengono pagate, quindi i miner molto piccoli stanno meglio in un gruppo più piccolo.