Skip to content

Commit f4f7695

Browse files
committed
Reject s9_..._... deposit-format wrapper in Thunder address validation
Empirical finding on the live forknet deployment: the LayerTwo-Labs enforcer accepts the wrapper string in CreateDepositTransaction's address field, and Thunder's OP_RETURN parser logs 'Ignoring invalid deposit address' for deposits made to it. Thunder's own transfer RPC also refuses to send to the wrapper form. Net effect: a miner who authorizes with the wrapper accrues PPS balance in pps_credits that can never be paid out — the reserve is intact but the address is unspendable. Change: thunder_address_decode strictly rejects any address containing '_'. Only bare base58 (raw base58 of the 20-byte hash) is accepted. Miners still see an actionable error at mining.authorize time with a message pointing at 'thunder-cli get-new-address' as the correct form. - src/thunder.c: reject on '_' with a friendly message before b58_decode - src/thunder.h: docstring rewritten to describe the reject; explains why the wrapper is invalid at the byte level despite looking like a Thunder address - tests/test_thunder.c: test_deposit_format renamed to test_rejects_wrapper_form and inverted — the wrapper must now be rejected, and the error message must contain a wrapper-related keyword so the miner knows what to fix - OPERATOR_GUIDE.md: deposit runbook rewritten to use POOL_ADDR directly (bare base58) instead of the wrapper from format-deposit-address; adds a note explaining why - scripts/regtest/thunder-init.sh: same fix; the printed grpcurl example now passes the bare address to CreateDepositTransaction - PPS_THUNDER.md: user-facing description now says 'bare Thunder addresses' — wrapper explicitly noted as rejected Verified: 7/7 test suites green including the inverted wrapper test.
1 parent fc64cfd commit f4f7695

6 files changed

Lines changed: 80 additions & 52 deletions

File tree

OPERATOR_GUIDE.md

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -124,27 +124,40 @@ reconciliation.
124124
```sh
125125
ssh -i <ssh-key> root@<pool-host>
126126

127-
# 1. Get a Thunder-owned deposit address (formatted for the mainchain wallet)
127+
# 1. Get a Thunder-owned deposit address (BARE base58 — 20-byte hash).
128+
# Do NOT use `format-deposit-address`; that produces the display-only
129+
# `s<n>_<base58>_<hex6>` wrapper that Thunder's own OP_RETURN parser
130+
# rejects. Empirically verified — deposits to the wrapper form are
131+
# logged as "Ignoring invalid deposit address" and end up unpayable.
128132
TCLI=/home/forknet/forknet-software/thunder-rust/target/debug/thunder_app_cli
129133
POOL_ADDR=$(sudo -u forknet $TCLI get-new-address)
130-
DEP=$(sudo -u forknet $TCLI format-deposit-address "$POOL_ADDR")
131-
echo "deposit target: $DEP"
134+
echo "deposit target: $POOL_ADDR"
132135

133136
# 2. Trigger the deposit via the enforcer's wallet. AMOUNT + FEE are yours to pick.
134137
GRPCURL=/home/forknet/forknet-software/grpcurl
135138
AMOUNT_SATS=1000000 # 0.01 BTC = 1_000_000 sats
136139
FEE_SATS=1000
137140
$GRPCURL -plaintext \
138-
-d "{\"sidechain_id\":9,\"address\":\"$DEP\",\"value_sats\":$AMOUNT_SATS,\"fee_sats\":$FEE_SATS}" \
141+
-d "{\"sidechain_id\":9,\"address\":\"$POOL_ADDR\",\"value_sats\":$AMOUNT_SATS,\"fee_sats\":$FEE_SATS}" \
139142
127.0.0.1:50051 cusf.mainchain.v1.WalletService/CreateDepositTransaction
140143

141-
# 3. Wait for a mainchain block. If you want to force one on the test:
144+
# 3. Wait for a natural mainchain block from your miner (or on regtest,
145+
# force one with GenerateBlocks).
142146
$GRPCURL -plaintext -d '{"blocks":1}' \
143147
127.0.0.1:50051 cusf.mainchain.v1.WalletService/GenerateBlocks
144148

145149
# 4. Confirm the Ctip moved
146150
$GRPCURL -plaintext -d '{"sidechain_number":9}' \
147151
127.0.0.1:50051 cusf.mainchain.v1.ValidatorService/GetCtip
152+
153+
# 5. Poke Thunder to include the new deposit in a sidechain block.
154+
# `mine` may time out on the client side but the block gets produced.
155+
curl -sS --max-time 15 -H 'content-type: application/json' \
156+
--data '{"jsonrpc":"2.0","id":1,"method":"mine","params":[]}' \
157+
http://127.0.0.1:6009/
158+
159+
# 6. Confirm Thunder's wallet balance grew
160+
sudo -u forknet $TCLI balance
148161
```
149162

150163
**About the "enforcer's wallet"**: the enforcer runs its own on-box BTC

PPS_THUNDER.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,10 @@ b1f3537 Add Redis broadcast for pool events alongside SQLite
4646
### 2. PPS / Thunder pool mode
4747
- New config key `pool_mode = solo | pps` (default `solo`, behaviour
4848
unchanged). In `pps`:
49-
- Stratum usernames must be **Thunder addresses** (base58 of the
50-
20-byte hash, or the `s9_<base58>_<hex6>` deposit-format wrapper).
49+
- Stratum usernames must be **bare Thunder addresses** — base58 of
50+
the 20-byte hash. The `s9_<base58>_<hex6>` deposit-format wrapper
51+
is rejected (Thunder itself doesn't recognize it at the byte level,
52+
so a miner authorized with it would accrue unpayable PPS balance).
5153
Validated in [src/thunder.c](src/thunder.c).
5254
- Every block's coinbase is a **BIP300 drivechain deposit** into the
5355
configured `pool_thunder_reserve_address` instead of paying the

scripts/regtest/thunder-init.sh

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,7 @@ fi
2222
# succeeds silently when yes; errors "no seed" to stderr when no.
2323
if ADDR="$($TCLI get-new-address 2>/dev/null)" && [[ -n "$ADDR" ]]; then
2424
echo "==> Thunder wallet already initialised"
25-
DEP="$($TCLI format-deposit-address "$ADDR" 2>/dev/null)"
26-
echo " new address: $ADDR"
27-
echo " deposit format: $DEP"
25+
echo " new address: $ADDR"
2826
exit 0
2927
fi
3028

@@ -36,15 +34,17 @@ echo "==> setting seed"
3634
$TCLI set-seed-from-mnemonic "$MNEMONIC" >/dev/null
3735

3836
ADDR="$($TCLI get-new-address)"
39-
DEP="$($TCLI format-deposit-address "$ADDR")"
4037

4138
echo ""
4239
echo "Thunder wallet ready."
43-
echo " new address: $ADDR"
44-
echo " deposit format: $DEP"
40+
echo " new address: $ADDR"
4541
echo ""
42+
# NOTE: pass the BARE base58 address to CreateDepositTransaction. The
43+
# display-only 's<n>_<base58>_<hex6>' wrapper from format-deposit-address
44+
# is NOT recognized by Thunder's OP_RETURN parser — deposits to it are
45+
# logged as 'Ignoring invalid deposit address' and end up unpayable.
4646
echo "To send a deposit into this wallet from the mainchain:"
47-
echo " grpcurl -plaintext -d '{\"sidechain_id\":9, \"address\":\"$DEP\","
47+
echo " grpcurl -plaintext -d '{\"sidechain_id\":9, \"address\":\"$ADDR\","
4848
echo " \"value_sats\":100000000, \"fee_sats\":1000}' \\"
4949
echo " 127.0.0.1:50051 cusf.mainchain.v1.WalletService/CreateDepositTransaction"
5050
echo " grpcurl -plaintext -d '{\"blocks\":1}' \\"

src/thunder.c

Lines changed: 21 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -60,33 +60,35 @@ int thunder_address_decode(const char *addr,
6060
}
6161
size_t alen = strlen(addr);
6262

63-
/* Deposit-format: 's' <digits> '_' <base58> '_' <hex6>.
64-
* Strip the prefix and checksum suffix; keep the base58 middle.
63+
/* Accept ONLY the bare base58 form (raw base58 of 20 hash bytes).
6564
*
66-
* We don't verify the checksum here — that's the wallet's job and
67-
* its bytes (3-byte sha256 prefix of the rest) are constructed
68-
* client-side. We only need the raw 20 bytes for accounting. */
69-
const char *b58_start = addr;
70-
size_t b58_len = alen;
71-
if (addr[0] == 's' || addr[0] == 'S') {
72-
const char *first_us = strchr(addr, '_');
73-
if (first_us) {
74-
const char *last_us = strrchr(addr, '_');
75-
if (last_us && last_us != first_us) {
76-
b58_start = first_us + 1;
77-
b58_len = (size_t)(last_us - b58_start);
78-
}
79-
}
65+
* The deposit-format wrapper `s<sc>_<base58>_<hex6>` looks like a
66+
* Thunder address but is a display-only string that neither Thunder's
67+
* wallet nor its OP_RETURN parser recognizes at the byte level — a
68+
* pool that stores wrapper strings as workers.payout_address ends up
69+
* with unpayable balances (thunder.transfer rejects the wrapper).
70+
* To catch this at authorize time rather than payout time we treat
71+
* any character outside the base58 alphabet as a hard error.
72+
*
73+
* '_' isn't in base58, so the wrapper's underscores make b58_decode
74+
* fail below — but we bail early with a friendlier message so a
75+
* miner using the wrong form gets an actionable stratum reject. */
76+
if (strchr(addr, '_') != NULL) {
77+
set_err(errbuf, errlen,
78+
"thunder address contains '_' — looks like the "
79+
"'s<n>_<base58>_<hex6>' deposit-format wrapper; use "
80+
"the bare base58 (thunder-cli get-new-address) instead");
81+
return -1;
8082
}
81-
if (b58_len < 20 || b58_len > 40) {
83+
if (alen < 20 || alen > 40) {
8284
set_err(errbuf, errlen,
83-
"thunder address base58 length %zu out of range", b58_len);
85+
"thunder address base58 length %zu out of range", alen);
8486
return -1;
8587
}
8688

8789
uint8_t buf[64];
8890
size_t dec_len = 0;
89-
if (b58_decode(b58_start, b58_len, buf, sizeof buf, &dec_len) < 0) {
91+
if (b58_decode(addr, alen, buf, sizeof buf, &dec_len) < 0) {
9092
set_err(errbuf, errlen, "thunder address base58 decode failed");
9193
return -1;
9294
}

src/thunder.h

Lines changed: 18 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -8,21 +8,27 @@
88
*
99
* Per thunder-rust (`lib/types/address.rs`), a Thunder address is a
1010
* 20-byte hash, displayed as plain base58 (no version byte, no
11-
* checksum) — i.e. shorter than a P2PKH base58check address. There is
12-
* also a deposit-format string `s9_<base58>_<3-byte sha256 prefix hex>`
13-
* used by the mainchain wallet UI; we accept either form and surface
14-
* the raw 20 bytes.
11+
* checksum) — i.e. shorter than a P2PKH base58check address.
1512
*
16-
* The mainchain coinbase carries the *pool's* Thunder reserve address
17-
* (every deposit accrues to the pool), not the miner's. The miner's
18-
* Thunder address from the stratum username is recorded for accounting
19-
* so the PPS payout worker (separate service, off-chain Thunder txs)
20-
* can credit the right account.
13+
* There is also a display-only "deposit-format" wrapper
14+
* `s<sc>_<base58>_<3-byte sha256 prefix hex>` produced by
15+
* `thunder-cli format-deposit-address`. That form is for humans to
16+
* paste into a mainchain wallet's deposit UI; it is NOT a Thunder
17+
* address at the byte level and Thunder rejects it in every RPC that
18+
* takes an address. Live empirical evidence: transfer() to a wrapper
19+
* form fails, and the enforcer's OP_RETURN parser logs
20+
* "Ignoring invalid deposit address" for it. We therefore reject
21+
* the wrapper here so miners get a clear stratum error at authorize
22+
* time instead of unpayable PPS balances at payout time.
23+
*
24+
* The miner's bare Thunder address from the stratum username is
25+
* recorded for accounting; the PPS payout worker (separate service,
26+
* off-chain Thunder txs) uses it as the recipient of thunder.transfer.
2127
*/
2228

23-
/* Decode a Thunder address string. Accepts:
24-
* - bare base58: `<28-34 chars>` -> 20 raw bytes
25-
* - deposit-format: `s<num>_<base58>_<hex6>` -> 20 raw bytes
29+
/* Decode a Thunder address string. Accepts ONLY the bare base58
30+
* form (no wrapper, no underscores). The mainchain-wallet deposit
31+
* wrapper is rejected with a message pointing at the correct form.
2632
*
2733
* `out[20]` receives the address bytes. Returns 0 ok, negative on error.
2834
* errbuf may be NULL. */

tests/test_thunder.c

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -71,8 +71,11 @@ static void test_roundtrip_random_pattern(void) {
7171
CHECK(memcmp(got, in, 20) == 0);
7272
}
7373

74-
static void test_deposit_format(void) {
75-
/* s9_<base58>_<checksum hex> — the wrapper format. */
74+
/* The `s<n>_<base58>_<hex6>` wrapper is a display-only string produced
75+
* by `thunder-cli format-deposit-address`. Thunder's own transfer RPC
76+
* refuses it, so we must reject it at authorize time; otherwise a
77+
* miner authorizes fine but their PPS balance is unpayable forever. */
78+
static void test_rejects_wrapper_form(void) {
7679
uint8_t in[20];
7780
for (int i = 0; i < 20; i++) in[i] = (uint8_t)(i * 11 + 1);
7881
char b58[64];
@@ -82,11 +85,13 @@ static void test_deposit_format(void) {
8285
snprintf(wrapped, sizeof wrapped, "s9_%s_a1b2c3", b58);
8386

8487
uint8_t got[20];
85-
char err[128];
88+
char err[192];
8689
int rc = thunder_address_decode(wrapped, got, err, sizeof err);
87-
if (rc != 0) fprintf(stderr, "deposit decode err: %s\n", err);
88-
CHECK(rc == 0);
89-
CHECK(memcmp(got, in, 20) == 0);
90+
CHECK(rc < 0);
91+
/* Error message must mention the wrapper so miners know what to do. */
92+
CHECK(strstr(err, "wrapper") != NULL ||
93+
strstr(err, "deposit-format") != NULL ||
94+
strstr(err, "_") != NULL);
9095
}
9196

9297
static void test_rejects_garbage(void) {
@@ -102,7 +107,7 @@ static void test_rejects_garbage(void) {
102107
int main(void) {
103108
test_roundtrip_bare();
104109
test_roundtrip_random_pattern();
105-
test_deposit_format();
110+
test_rejects_wrapper_form();
106111
test_rejects_garbage();
107112
if (failures) {
108113
fprintf(stderr, "%d failure(s)\n", failures);

0 commit comments

Comments
 (0)