Skip to content

Commit f455a0e

Browse files
committed
Implement pool_mode=pps-classic — traditional coinbase, operator deposits
pool_mode gains a third value on top of solo/pps. When pool_mode = pps-classic: - Stratum username validation is a Thunder address (same as pps). - Coinbase is rendered by the existing coinbase_build_split (or coinbase_build_from_template for the CUSF enforcer path), paying pool_btc_address for the net-of-fee reward and operator_address for the fee_bps cut. No OP_DRIVECHAIN output, no OP_RETURN destination. - PPS accrual fires exactly like pps mode: pps_credits.accrued_sats += difficulty * pps_sats_per_diff on every accepted share. - New pool_btc_address config key required (validated at startup via coinbase_address_to_script so the operator sees a fast failure instead of every rendered job dropping at runtime). Rationale: end-to-end regtest AND live forknet runs proved the LayerTwo-Labs BIP300 enforcer does not credit coinbase-source outputs as sidechain deposits. Blocks with the drivechain-shape coinbase (pps mode) are accepted into the chain but the OP_DRIVECHAIN UTXOs are stranded — consensus-locked to Ctip-roll / withdrawal-bundle spends that never happen for non-Ctip outputs. Real pools have to accept BTC into a normal wallet and batch deposits, which is exactly what pps-classic does. main.c now emits a WARN when pool_mode=pps for the same reason. schema + store.c: new `deposits` table (id, ts, btc_txid, sats_deposited, fee_sats, thunder_recipient, ctip_seq_before/after, notes) owned by the admin dashboard's deposit action. The C proxy creates it for fresh-DB convenience but never writes to it. proxy.conf.example rewritten with all three modes documented inline, including the pps-mode "does not credit" warning. Existing test suite (7 suites, 12 coinbase assertions) still passes; config validation smoke-tested locally (rejects missing pool_btc_address, accepts valid config, chain of startup logs matches the new mode).
1 parent 096d581 commit f455a0e

8 files changed

Lines changed: 163 additions & 40 deletions

File tree

proxy.conf.example

Lines changed: 24 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -40,18 +40,32 @@ redis_url =
4040
redis_publish_timeout_ms = 200
4141
redis_reconnect_backoff_ms = 2000
4242

43-
# PPS / Thunder mode. "solo" preserves the original per-block direct-payout
44-
# flow. "pps" makes every block's coinbase deposit to the configured
45-
# pool_thunder_reserve_address via a BIP300 drivechain deposit (sidechain
46-
# 9 = Thunder), credits each accepted share to the worker's pps_credits
47-
# row, and expects stratum usernames to be Thunder addresses (base58).
43+
# pool_mode controls the coinbase shape and the username validation:
44+
# solo default. Coinbase pays the miner (stratum username is a
45+
# BTC address) minus the operator fee. No PPS accrual.
46+
# pps Every block's coinbase carries a BIP300 drivechain deposit
47+
# output for the configured pool_thunder_reserve_address.
48+
# Miners authorize with a Thunder address (base58 of a
49+
# 20-byte hash). WARNING: the LayerTwo-Labs enforcer does
50+
# not credit coinbase-source deposits; this mode produces a
51+
# well-formed but effectively unspendable OP_DRIVECHAIN
52+
# output. Kept for shape-validation only.
53+
# pps-classic Coinbase pays a pool BTC address (pool_btc_address below)
54+
# as a normal P2WPKH/P2PKH output. Miners authorize with a
55+
# Thunder address and accrue in pps_credits, same as pps.
56+
# The operator batches accumulated BTC into Thunder from
57+
# the admin dashboard.
4858
pool_mode = solo
59+
60+
# pps-classic
61+
# pool_btc_address = REPLACE_WITH_POOL_BTC_ADDRESS
62+
63+
# pps (only)
4964
# pool_thunder_reserve_address = REPLACE_WITH_BASE58_THUNDER_ADDRESS
5065
# thunder_sidechain_number = 9
51-
# Optional override for the OP_RETURN payload after the OP_DRIVECHAIN output.
52-
# When empty, the ASCII bytes of pool_thunder_reserve_address are embedded
53-
# (matches Thunder's wallet behaviour).
54-
# thunder_op_return_hex =
55-
# pps_sats_per_diff = 1000 # sats credited per unit of share difficulty
66+
# thunder_op_return_hex = # optional override; else ASCII of the address
67+
68+
# pps + pps-classic — sats credited per unit of share difficulty
69+
# pps_sats_per_diff = 1000
5670

5771
log_level = info

schema.sql

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,22 @@ CREATE TABLE IF NOT EXISTS pps_credits (
6464
last_updated INTEGER NOT NULL
6565
);
6666

67+
/* Ledger of operator-triggered mainchain → Thunder deposits, used by
68+
* pool_mode=pps-classic. The C proxy does not touch this table; the
69+
* admin dashboard is the only writer. */
70+
CREATE TABLE IF NOT EXISTS deposits (
71+
id INTEGER PRIMARY KEY AUTOINCREMENT,
72+
ts INTEGER NOT NULL, /* unix seconds */
73+
btc_txid TEXT NOT NULL, /* mainchain deposit tx */
74+
sats_deposited INTEGER NOT NULL,
75+
fee_sats INTEGER NOT NULL,
76+
thunder_recipient TEXT NOT NULL, /* s9_<base58>_<hex6> */
77+
ctip_seq_before INTEGER,
78+
ctip_seq_after INTEGER,
79+
notes TEXT
80+
);
81+
CREATE INDEX IF NOT EXISTS deposits_ts_idx ON deposits(ts);
82+
6783
/* In-flight payout ledger. The payout worker INSERTs a row before
6884
* broadcasting a Thunder transaction; on successful broadcast it
6985
* atomically (in one tx) sets txid, increments pps_credits.paid_sats,

src/config.c

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ void proxy_config_defaults(proxy_config_t *cfg) {
5454
cfg->redis_reconnect_backoff_ms = 2000;
5555

5656
snprintf(cfg->pool_mode, sizeof cfg->pool_mode, "%s", "solo");
57+
cfg->pool_btc_address[0] = '\0';
5758
cfg->pool_thunder_reserve_address[0] = '\0';
5859
cfg->thunder_sidechain_number = 9;
5960
cfg->thunder_op_return_hex[0] = '\0';
@@ -155,6 +156,7 @@ int proxy_config_load(const char *path, proxy_config_t *cfg,
155156
else if (strcmp(k, "redis_publish_timeout_ms") == 0) cfg->redis_publish_timeout_ms = atoi(v);
156157
else if (strcmp(k, "redis_reconnect_backoff_ms")== 0) cfg->redis_reconnect_backoff_ms = atoi(v);
157158
else if (strcmp(k, "pool_mode") == 0) copy_str(cfg->pool_mode, sizeof cfg->pool_mode, v);
159+
else if (strcmp(k, "pool_btc_address") == 0) copy_str(cfg->pool_btc_address, sizeof cfg->pool_btc_address, v);
158160
else if (strcmp(k, "pool_thunder_reserve_address") == 0) copy_str(cfg->pool_thunder_reserve_address, sizeof cfg->pool_thunder_reserve_address, v);
159161
else if (strcmp(k, "thunder_sidechain_number") == 0) cfg->thunder_sidechain_number = atoi(v);
160162
else if (strcmp(k, "thunder_op_return_hex") == 0) copy_str(cfg->thunder_op_return_hex, sizeof cfg->thunder_op_return_hex, v);
@@ -184,14 +186,25 @@ int proxy_config_load(const char *path, proxy_config_t *cfg,
184186
cfg->fee_bps);
185187
return -4;
186188
}
187-
if (strcmp(cfg->pool_mode, "solo") != 0 &&
188-
strcmp(cfg->pool_mode, "pps") != 0) {
189+
if (strcmp(cfg->pool_mode, "solo") != 0 &&
190+
strcmp(cfg->pool_mode, "pps") != 0 &&
191+
strcmp(cfg->pool_mode, "pps-classic") != 0) {
189192
set_err(errbuf, errlen,
190-
"config: 'pool_mode' must be 'solo' or 'pps', got '%s'",
193+
"config: 'pool_mode' must be 'solo', 'pps' or 'pps-classic', got '%s'",
191194
cfg->pool_mode);
192195
return -5;
193196
}
194-
if (strcmp(cfg->pool_mode, "pps") == 0) {
197+
int is_pps = (strcmp(cfg->pool_mode, "pps") == 0);
198+
int is_pps_classic = (strcmp(cfg->pool_mode, "pps-classic") == 0);
199+
if (is_pps || is_pps_classic) {
200+
if (cfg->pps_sats_per_diff <= 0.0) {
201+
set_err(errbuf, errlen,
202+
"config: 'pps_sats_per_diff' must be > 0 when pool_mode=%s",
203+
cfg->pool_mode);
204+
return -8;
205+
}
206+
}
207+
if (is_pps) {
195208
if (cfg->pool_thunder_reserve_address[0] == '\0') {
196209
set_err(errbuf, errlen,
197210
"config: 'pool_thunder_reserve_address' is required when pool_mode=pps");
@@ -203,10 +216,12 @@ int proxy_config_load(const char *path, proxy_config_t *cfg,
203216
cfg->thunder_sidechain_number);
204217
return -7;
205218
}
206-
if (cfg->pps_sats_per_diff <= 0.0) {
219+
}
220+
if (is_pps_classic) {
221+
if (cfg->pool_btc_address[0] == '\0') {
207222
set_err(errbuf, errlen,
208-
"config: 'pps_sats_per_diff' must be > 0 when pool_mode=pps");
209-
return -8;
223+
"config: 'pool_btc_address' is required when pool_mode=pps-classic");
224+
return -9;
210225
}
211226
}
212227
return 0;

src/config.h

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -44,17 +44,16 @@ typedef struct {
4444
* existing per-block direct-payout flow. pool_mode = "pps" enables
4545
* Thunder drivechain deposits in the coinbase and per-share PPS
4646
* accrual in the database. */
47-
char pool_mode[16]; /* "solo" | "pps"
48-
* TODO(pps-classic): add
49-
* a "pps-classic" value —
50-
* traditional coinbase to
51-
* pool_btc_address, with
52-
* operator-driven Thunder
53-
* deposits via the admin
54-
* dashboard. See
55-
* CLASSIC_PAYOUTS.md. */
47+
char pool_mode[16]; /* "solo" | "pps" | "pps-classic" */
48+
/* pps-classic: coinbase pays this BTC address (P2WPKH/P2PKH/P2SH), NOT
49+
* the Thunder reserve. The operator later batches this BTC into Thunder
50+
* via the admin dashboard's deposit action. Required when
51+
* pool_mode = pps-classic; ignored otherwise. */
52+
char pool_btc_address[128];
5653
char pool_thunder_reserve_address[128]; /* base58 Thunder address that
57-
* receives every block's deposit */
54+
* receives every block's deposit
55+
* — pps mode only, ignored in
56+
* pps-classic */
5857
int thunder_sidechain_number; /* 9 for Thunder */
5958
/* OP_RETURN payload bytes following the OP_DRIVECHAIN output, hex-
6059
* encoded. If empty, the configured pool_thunder_reserve_address is

src/main.c

Lines changed: 37 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -246,8 +246,12 @@ static void on_share_cb(void *ctx, const char *worker_name,
246246
}
247247
/* PPS accrual. Credit the worker proportional to share difficulty.
248248
* Truncates to whole sats; sub-sat dust accumulates per-share so
249-
* over many shares the rounding error is bounded by 1 sat per row. */
250-
if (s && s->cfg && strcmp(s->cfg->pool_mode, "pps") == 0 &&
249+
* over many shares the rounding error is bounded by 1 sat per row.
250+
* Fires for both pool_mode=pps (drivechain coinbase) and pool_mode=
251+
* pps-classic (traditional coinbase, operator-driven deposits). */
252+
if (s && s->cfg &&
253+
(strcmp(s->cfg->pool_mode, "pps") == 0 ||
254+
strcmp(s->cfg->pool_mode, "pps-classic") == 0) &&
251255
s->cfg->pps_sats_per_diff > 0.0) {
252256
int64_t delta = (int64_t)(difficulty * s->cfg->pps_sats_per_diff);
253257
if (delta > 0) {
@@ -530,14 +534,37 @@ int main(int argc, char **argv) {
530534
stcfg.vardiff_max = cfg.vardiff_max;
531535
stcfg.vardiff_window_sec = cfg.vardiff_window_sec;
532536

533-
/* PPS / Thunder. Precompute the OP_RETURN bytes for the drivechain
534-
* coinbase: either the explicit hex from config, or the ASCII of the
535-
* pool's base58 Thunder address (matches Thunder's wallet behaviour). */
537+
/* PPS / Thunder. Both pps and pps-classic modes require Thunder-address
538+
* usernames and produce PPS accruals; they differ only in the coinbase
539+
* shape (drivechain vs traditional). */
536540
static uint8_t pps_payload[80];
537541
size_t pps_payload_len = 0;
538-
stcfg.pps_enabled = (strcmp(cfg.pool_mode, "pps") == 0);
542+
stcfg.pps_enabled = (strcmp(cfg.pool_mode, "pps") == 0 ||
543+
strcmp(cfg.pool_mode, "pps-classic") == 0);
544+
stcfg.pps_classic_enabled = (strcmp(cfg.pool_mode, "pps-classic") == 0);
539545
stcfg.thunder_sidechain_number = cfg.thunder_sidechain_number;
540-
if (stcfg.pps_enabled) {
546+
snprintf(stcfg.pool_btc_address, sizeof stcfg.pool_btc_address, "%s",
547+
cfg.pool_btc_address);
548+
549+
if (stcfg.pps_classic_enabled) {
550+
/* Fail fast on a misconfigured pool_btc_address so we don't drop
551+
* every rendered job at runtime. */
552+
uint8_t spk[64];
553+
size_t spk_len = sizeof spk;
554+
char perr[256] = {0};
555+
if (coinbase_address_to_script(cfg.pool_btc_address, spk, sizeof spk,
556+
&spk_len, perr, sizeof perr) < 0) {
557+
fprintf(stderr,
558+
"config error: invalid pool_btc_address '%s': %s\n",
559+
cfg.pool_btc_address, perr);
560+
return 2;
561+
}
562+
LOG_INFO("pool_mode=pps-classic: pool_btc_address=%s, pps_sats_per_diff=%.2f",
563+
cfg.pool_btc_address, cfg.pps_sats_per_diff);
564+
} else if (stcfg.pps_enabled) {
565+
/* pool_mode=pps — precompute the OP_RETURN bytes for the drivechain
566+
* coinbase: either the explicit hex from config, or the ASCII of the
567+
* pool's base58 Thunder address (matches Thunder's wallet behaviour). */
541568
if (cfg.thunder_op_return_hex[0]) {
542569
size_t hlen = strlen(cfg.thunder_op_return_hex);
543570
if (hlen % 2 != 0 || hlen / 2 > sizeof pps_payload) {
@@ -566,6 +593,9 @@ int main(int argc, char **argv) {
566593
}
567594
stcfg.pps_op_return_payload = pps_payload;
568595
stcfg.pps_op_return_payload_len = pps_payload_len;
596+
LOG_WARN("pool_mode=pps: coinbase deposits will NOT credit the Thunder "
597+
"Ctip on the LayerTwo-Labs enforcer — this mode strands the "
598+
"block reward. Use pool_mode=pps-classic for real deployments.");
569599
LOG_INFO("pool_mode=pps: sidechain=%d, op_return_payload=%zu bytes, "
570600
"pps_sats_per_diff=%.2f",
571601
cfg.thunder_sidechain_number, pps_payload_len,

src/store.c

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,21 @@ static const char *SCHEMA_SQL =
9191
" txid TEXT NOT NULL DEFAULT '',"
9292
" started_at INTEGER NOT NULL"
9393
");"
94-
"CREATE INDEX IF NOT EXISTS payouts_in_flight_worker_idx ON payouts_in_flight(worker_id);";
94+
"CREATE INDEX IF NOT EXISTS payouts_in_flight_worker_idx ON payouts_in_flight(worker_id);"
95+
/* pps-classic deposit ledger. Owned by the admin dashboard; the C
96+
* proxy never writes here. Created here so a fresh DB is complete. */
97+
"CREATE TABLE IF NOT EXISTS deposits ("
98+
" id INTEGER PRIMARY KEY AUTOINCREMENT,"
99+
" ts INTEGER NOT NULL,"
100+
" btc_txid TEXT NOT NULL,"
101+
" sats_deposited INTEGER NOT NULL,"
102+
" fee_sats INTEGER NOT NULL,"
103+
" thunder_recipient TEXT NOT NULL,"
104+
" ctip_seq_before INTEGER,"
105+
" ctip_seq_after INTEGER,"
106+
" notes TEXT"
107+
");"
108+
"CREATE INDEX IF NOT EXISTS deposits_ts_idx ON deposits(ts);";
95109

96110
/* Forward-compat: ALTER existing DBs to add columns that didn't exist in
97111
* earlier schemas. Duplicate-column errors are silently ignored. */

src/stratum.c

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -458,10 +458,34 @@ static int conn_render_coinbase(stratum_server_t *s, stratum_conn_t *c,
458458
coinbase_parts_t parts = {0};
459459
char err[256] = {0};
460460
int rc;
461-
if (s->cfg.pps_enabled) {
461+
if (s->cfg.pps_classic_enabled) {
462+
/* PPS-classic: every miner's coinbase is identical, paying the
463+
* pool's BTC wallet for the net-of-fee reward and the operator
464+
* address for the fee. No drivechain output; the operator later
465+
* moves accumulated BTC into Thunder via the admin dashboard's
466+
* deposit action. */
467+
if (job->coinbasetxn_hex) {
468+
rc = coinbase_build_from_template(job->coinbasetxn_hex,
469+
s->cfg.pool_btc_address,
470+
s->cfg.operator_address, s->cfg.fee_bps,
471+
s->cfg.coinbase_tag,
472+
job->en1_size, job->en2_size,
473+
&parts, NULL, NULL, NULL, err, sizeof err);
474+
} else {
475+
rc = coinbase_build_split(job->height, job->value_sats,
476+
s->cfg.pool_btc_address,
477+
s->cfg.operator_address, s->cfg.fee_bps,
478+
job->wc_hex, s->cfg.coinbase_tag,
479+
job->en1_size, job->en2_size,
480+
&parts, NULL, NULL, err, sizeof err);
481+
}
482+
} else if (s->cfg.pps_enabled) {
462483
/* PPS / Thunder: every miner's coinbase is identical — the reward
463484
* is deposited to the pool's Thunder reserve via BIP300; per-miner
464-
* accounting happens off-chain via pps_credits. */
485+
* accounting happens off-chain via pps_credits. NOTE: The BIP300
486+
* enforcer does not credit coinbase deposits — this mode produces
487+
* a well-formed but effectively unspendable OP_DRIVECHAIN output.
488+
* Use pool_mode=pps-classic for real deployments. */
465489
if (job->coinbasetxn_hex) {
466490
rc = coinbase_build_drivechain_from_template(
467491
job->coinbasetxn_hex,

src/stratum.h

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -72,16 +72,27 @@ typedef struct {
7272

7373
/* PPS / Thunder. When pps_enabled = 1:
7474
* - mining.authorize accepts Thunder addresses (base58 of 20-byte hash)
75-
* - per-connection coinbase rendering uses coinbase_build_drivechain
76-
* with the precomputed OP_RETURN payload below
7775
* - the share observer's payout_address argument is the miner's
7876
* Thunder address (for PPS accrual), not a Bitcoin address.
77+
*
78+
* The coinbase behavior then depends on pps_classic_enabled:
79+
* - 0 (pool_mode=pps): render with coinbase_build_drivechain using
80+
* the OP_RETURN payload below. Deposits into Thunder via BIP300 —
81+
* but the enforcer does NOT credit coinbase-source deposits, so
82+
* this shape does not actually move value onto Thunder.
83+
* - 1 (pool_mode=pps-classic): render with coinbase_build_split
84+
* paying pool_btc_address for the miner-share and operator_address
85+
* for the fee. Traditional coinbase; deposits to Thunder happen
86+
* off-band via the admin dashboard.
7987
*/
8088
int pps_enabled;
89+
int pps_classic_enabled;
8190
int thunder_sidechain_number;
91+
char pool_btc_address[128]; /* pps-classic: coinbase spendable output */
8292
/* OP_RETURN payload bytes that ride next to every drivechain output.
8393
* Typically the ASCII of the pool's base58 Thunder address. Owned by
84-
* the caller (main.c precomputes from config). */
94+
* the caller (main.c precomputes from config). Unused when
95+
* pps_classic_enabled = 1. */
8596
const uint8_t *pps_op_return_payload;
8697
size_t pps_op_return_payload_len;
8798

0 commit comments

Comments
 (0)