Skip to content

Commit 75243a8

Browse files
JohnnyFFMclaude
andcommitted
test: regtest submit_nonce wallet-key gate (5 cases)
Add scripts/mining/test-regtest-submitnonce-key-gate-v2.sh covering every branch of the receive-path probe (HaveAccountKey) plus the multi-wallet iteration semantics: A Absent watch-only descriptor; expect -5 + bech32 + no raw-hex regression B Locked encrypted+locked privkey; expect -13 + walletpassphrase hint C Available unlocked privkey; expect exit 0 + raw_quality/poc_time D Multi Locked + Available, Locked loaded first (probe must traverse past Locked to pick Available) E Multi Available + Locked, Available loaded first (later Locked must not downgrade an earlier Available) D + E together prove the {Locked, Available} probe outcome is order- independent, ruling out "break on first Locked" and "Locked overrides later" regressions. Each case loads its wallets in isolation and unloads between cases so the probe state under test is unambiguous. Regression guards for: #4 (PR PoC-Consortium/bitcoin#3) - Absent/Locked split #3 (PR PoC-Consortium/bitcoin#2) - bech32 rendering Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 6b518d7 commit 75243a8

1 file changed

Lines changed: 304 additions & 0 deletions

File tree

Lines changed: 304 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,304 @@
1+
#!/bin/bash
2+
# PoCX regtestv2: submit_nonce wallet-key gate (all three states).
3+
#
4+
# Covers every branch of the receive-path probe (HaveAccountKey):
5+
# Case A Absent — wallet has the script but no private key (watch-only
6+
# descriptor). Expect error -5 (RPC_INVALID_ADDRESS_OR_KEY),
7+
# bech32 signer in the message, no raw hash160 leakage.
8+
# Case B Locked — wallet holds the encrypted private key but is locked.
9+
# Expect error -13 (RPC_WALLET_UNLOCK_NEEDED), "unlock with
10+
# walletpassphrase first" in the message.
11+
# Case C Available — wallet holds the private key, unlocked.
12+
# Expect exit 0 and a JSON result with raw_quality/poc_time.
13+
# Case D Multi-wallet — Locked + Available loaded simultaneously, Locked first
14+
# in iteration order. The probe must continue past the
15+
# Locked match and pick the Available wallet (expect exit 0).
16+
# Case E Multi-wallet — same as D but Available is loaded first. Proves the
17+
# result is order-independent and rules out a "Locked
18+
# overrides Available when seen later" bug.
19+
#
20+
# Regression guards for:
21+
# - PoC-Consortium/bitcoin-pocx#4 (PR PoC-Consortium/bitcoin#3) — Absent/Locked split
22+
# - PoC-Consortium/bitcoin-pocx#3 (PR PoC-Consortium/bitcoin#2) — bech32 rendering
23+
#
24+
# Each case runs against the same daemon with the relevant wallet loaded in
25+
# isolation (others unloaded), so the probe state under test is unambiguous.
26+
27+
set -e
28+
29+
BITCOIN_DIR="bitcoin"
30+
BITCOIN_CLI="$BITCOIN_DIR/build/bin/bitcoin-cli"
31+
BITCOIND="$BITCOIN_DIR/build/bin/bitcoind"
32+
DATADIR="$HOME/.bitcoin-pocx/regtestv2-submitnonce-key-gate"
33+
34+
# Static keypair (same WIF as test-regtest-mining-legacy-v2.sh) for reproducibility.
35+
WIF_KEY="cNgs3AUH8xu2faRgLBR5kT7mAB6tUJZDDRH5YeKtZw4LqhbapNWd"
36+
PASSPHRASE="hodor"
37+
38+
echo "PoCX Regtestv2 submit_nonce Key-Gate Test (Absent / Locked / Available)"
39+
echo "======================================================================="
40+
41+
pkill -9 bitcoind 2>/dev/null || true
42+
sleep 1
43+
rm -rf "$DATADIR"
44+
mkdir -p "$DATADIR"
45+
46+
$BITCOIND -regtest -datadir="$DATADIR" -daemon >/dev/null
47+
for i in 1 2 3 4 5; do
48+
$BITCOIN_CLI -regtest -datadir="$DATADIR" getblockchaininfo >/dev/null 2>&1 && break
49+
sleep 1
50+
done
51+
52+
CLI="$BITCOIN_CLI -regtest -datadir=$DATADIR"
53+
$CLI setmocktime "$(date +%s)" >/dev/null
54+
55+
# Derive the bech32 address and hash160 of the target account up-front.
56+
# getdescriptorinfo on a privkey-bearing descriptor returns the canonical
57+
# PUBKEY-only descriptor in .descriptor, which is what the watch-only case
58+
# imports below. We reuse the privkey descriptor with checksum for the
59+
# privkey-bearing cases.
60+
DESC_INFO=$($CLI getdescriptorinfo "wpkh($WIF_KEY)")
61+
PUBKEY_DESC=$(echo "$DESC_INFO" | jq -r '.descriptor')
62+
CHECKSUM=$(echo "$DESC_INFO" | jq -r '.checksum')
63+
PRIVKEY_DESC="wpkh($WIF_KEY)#$CHECKSUM"
64+
ADDRESS=$($CLI deriveaddresses "$PUBKEY_DESC" | jq -r '.[0]')
65+
echo "Target address: $ADDRESS"
66+
67+
# Fetch hash160 (used as account_id) once via a throwaway wallet, then unload.
68+
$CLI createwallet probe true true "" false true >/dev/null
69+
$CLI -rpcwallet=probe importdescriptors "[{\"desc\": \"$PUBKEY_DESC\", \"timestamp\": \"now\"}]" >/dev/null
70+
WITNESS_PROGRAM=$($CLI -rpcwallet=probe getaddressinfo "$ADDRESS" | jq -r '.witness_program')
71+
$CLI unloadwallet probe >/dev/null
72+
if [ -z "$WITNESS_PROGRAM" ] || [ "$WITNESS_PROGRAM" = "null" ]; then
73+
echo "ERROR: could not derive witness_program for $ADDRESS"
74+
$CLI stop >/dev/null 2>&1 || true
75+
exit 1
76+
fi
77+
echo "Account ID (hash160): $WITNESS_PROGRAM"
78+
79+
# Mining context — submit_nonce step 4 (wallet check) fires after steps 1-3
80+
# (format / height / block_hash / gen_sig / base_target) and before step 5
81+
# (compression) and step 6 (proof validation). So context fields must match
82+
# the current tip; nonce/seed/compression just need to parse.
83+
CTX=$($CLI get_mining_info)
84+
HEIGHT=$(echo "$CTX" | jq -r '.height')
85+
BLOCK_HASH=$(echo "$CTX" | jq -r '.block_hash')
86+
GEN_SIG=$(echo "$CTX" | jq -r '.generation_signature')
87+
BASE_TARGET=$(echo "$CTX" | jq -r '.base_target')
88+
MIN_COMPRESSION=$(echo "$CTX" | jq -r '.minimum_compression_level')
89+
SEED=$(printf '0%.0s' $(seq 1 64)) # 64-hex zeros, parses fine
90+
91+
# submit_nonce wrapper that captures exit code + stdout/stderr together.
92+
submit_nonce_against() {
93+
local _account="$1"
94+
set +e
95+
SN_RAW=$($CLI submit_nonce "$BLOCK_HASH" "$HEIGHT" "$GEN_SIG" "$BASE_TARGET" \
96+
"$_account" "$SEED" 1 "$MIN_COMPRESSION" 0 2>&1)
97+
SN_RC=$?
98+
set -e
99+
}
100+
101+
fail() {
102+
echo ""
103+
echo "FAIL: $1"
104+
$CLI stop >/dev/null 2>&1 || true
105+
exit 1
106+
}
107+
108+
# -----------------------------------------------------------------------------
109+
# Case A — Absent: pubkey-only descriptor loaded, no private key.
110+
# Pre-fix bug: IsMine returned true on the registered script and submit_nonce
111+
# ACK'd; the scheduler then built unsigned blocks that signing dropped.
112+
# Post-fix: GetPoCXPubKey rejects, availability=Absent, RPC throws -5.
113+
# -----------------------------------------------------------------------------
114+
echo ""
115+
echo "Case A — watch-only descriptor (Absent expected)"
116+
echo "------------------------------------------------"
117+
# createwallet args: name, disable_private_keys, blank, passphrase, avoid_reuse, descriptors
118+
$CLI createwallet wo true true "" false true >/dev/null
119+
$CLI -rpcwallet=wo importdescriptors "[{\"desc\": \"$PUBKEY_DESC\", \"timestamp\": \"now\"}]" >/dev/null
120+
121+
INFO=$($CLI -rpcwallet=wo getaddressinfo "$ADDRESS")
122+
IS_MINE=$(echo "$INFO" | jq -r '.ismine')
123+
IS_SOLVABLE=$(echo "$INFO" | jq -r '.solvable')
124+
[ "$IS_MINE" = "true" ] && [ "$IS_SOLVABLE" = "true" ] \
125+
|| fail "watch-only wallet sanity: ismine=$IS_MINE solvable=$IS_SOLVABLE (want both true)"
126+
echo "Wallet sees script (ismine=true solvable=true), no privkey loaded"
127+
128+
submit_nonce_against "$WITNESS_PROGRAM"
129+
echo "submit_nonce exit: $SN_RC"
130+
echo "$SN_RAW"
131+
132+
[ "$SN_RC" -ne 0 ] || fail "submit_nonce returned success — wallet-key gate is broken (issue #4 regression)"
133+
ERR_CODE=$(echo "$SN_RAW" | sed -n 's/^error code: //p' | head -1)
134+
[ "$ERR_CODE" = "-5" ] || fail "expected error code -5 (RPC_INVALID_ADDRESS_OR_KEY), got '$ERR_CODE'"
135+
echo "$SN_RAW" | grep -q "$ADDRESS" || fail "error message does not contain bech32 address $ADDRESS"
136+
if echo "$SN_RAW" | grep -Eq "[^0-9a-f]$WITNESS_PROGRAM[^0-9a-f]|[^0-9a-f]$WITNESS_PROGRAM\$|^$WITNESS_PROGRAM"; then
137+
fail "error message contains raw hash160 hex $WITNESS_PROGRAM (bech32 regression)"
138+
fi
139+
echo "Case A PASS (-5, bech32 rendered, no raw-hex regression)"
140+
141+
$CLI unloadwallet wo >/dev/null
142+
143+
# -----------------------------------------------------------------------------
144+
# Case B — Locked: descriptor wallet with privkey, encrypted, locked.
145+
# encryptwallet on a descriptor wallet encrypts in-place and locks. The wallet
146+
# stays loaded. CanProvide still matches; GetPoCXPubKey fails because the
147+
# privkey is sealed; cwallet->IsLocked() is true → availability=Locked.
148+
# RPC throws -13 with the "unlock with walletpassphrase first" hint.
149+
# -----------------------------------------------------------------------------
150+
echo ""
151+
echo "Case B — encrypted + locked wallet with privkey (Locked expected)"
152+
echo "-----------------------------------------------------------------"
153+
$CLI createwallet locked false true "" false true >/dev/null
154+
$CLI -rpcwallet=locked importdescriptors "[{\"desc\": \"$PRIVKEY_DESC\", \"timestamp\": \"now\"}]" >/dev/null
155+
# encryptwallet locks the wallet as part of encryption.
156+
$CLI -rpcwallet=locked encryptwallet "$PASSPHRASE" >/dev/null
157+
# Sanity: walletinfo reports the wallet as locked (unlocked_until == 0 or missing).
158+
WALLET_INFO=$($CLI -rpcwallet=locked getwalletinfo)
159+
UNLOCKED_UNTIL=$(echo "$WALLET_INFO" | jq -r '.unlocked_until // 0')
160+
[ "$UNLOCKED_UNTIL" = "0" ] || fail "expected wallet to be locked after encryptwallet (unlocked_until=$UNLOCKED_UNTIL)"
161+
echo "Wallet encrypted and locked (unlocked_until=0)"
162+
163+
submit_nonce_against "$WITNESS_PROGRAM"
164+
echo "submit_nonce exit: $SN_RC"
165+
echo "$SN_RAW"
166+
167+
[ "$SN_RC" -ne 0 ] || fail "submit_nonce returned success — Locked branch is broken"
168+
ERR_CODE=$(echo "$SN_RAW" | sed -n 's/^error code: //p' | head -1)
169+
[ "$ERR_CODE" = "-13" ] || fail "expected error code -13 (RPC_WALLET_UNLOCK_NEEDED), got '$ERR_CODE'"
170+
echo "$SN_RAW" | grep -qi "walletpassphrase" \
171+
|| fail "error message does not mention walletpassphrase — operator hint regressed"
172+
echo "$SN_RAW" | grep -q "$ADDRESS" || fail "error message does not contain bech32 address $ADDRESS"
173+
echo "Case B PASS (-13, walletpassphrase hint present)"
174+
175+
$CLI unloadwallet locked >/dev/null
176+
177+
# -----------------------------------------------------------------------------
178+
# Case C — Available: descriptor wallet with privkey, unlocked.
179+
# CanProvide matches and GetPoCXPubKey returns the pubkey. submit_nonce
180+
# proceeds through compression bounds + proof validation. Any nonce parses
181+
# (proofs are not gate-checked for "winning"); a successful JSON result
182+
# carries raw_quality and poc_time.
183+
# -----------------------------------------------------------------------------
184+
echo ""
185+
echo "Case C — unlocked wallet with privkey (Available expected)"
186+
echo "----------------------------------------------------------"
187+
$CLI createwallet keyed false true "" false true >/dev/null
188+
$CLI -rpcwallet=keyed importdescriptors "[{\"desc\": \"$PRIVKEY_DESC\", \"timestamp\": \"now\"}]" >/dev/null
189+
190+
# Refresh mining context — height may have advanced if anything mined; for
191+
# this script's lifetime no blocks are produced, but it's cheap insurance.
192+
CTX=$($CLI get_mining_info)
193+
HEIGHT=$(echo "$CTX" | jq -r '.height')
194+
BLOCK_HASH=$(echo "$CTX" | jq -r '.block_hash')
195+
GEN_SIG=$(echo "$CTX" | jq -r '.generation_signature')
196+
BASE_TARGET=$(echo "$CTX" | jq -r '.base_target')
197+
MIN_COMPRESSION=$(echo "$CTX" | jq -r '.minimum_compression_level')
198+
199+
submit_nonce_against "$WITNESS_PROGRAM"
200+
echo "submit_nonce exit: $SN_RC"
201+
echo "$SN_RAW"
202+
203+
[ "$SN_RC" -eq 0 ] || fail "submit_nonce did not succeed for unlocked privkey wallet"
204+
RAW_QUALITY=$(echo "$SN_RAW" | jq -r '.raw_quality // empty')
205+
POC_TIME=$(echo "$SN_RAW" | jq -r '.poc_time // empty')
206+
[ -n "$RAW_QUALITY" ] || fail "success response missing raw_quality"
207+
[ -n "$POC_TIME" ] || fail "success response missing poc_time"
208+
echo "Case C PASS (raw_quality=$RAW_QUALITY poc_time=${POC_TIME}s)"
209+
210+
$CLI unloadwallet keyed >/dev/null
211+
212+
# -----------------------------------------------------------------------------
213+
# Case D — multi-wallet probe: Locked wallet loaded alongside an Available
214+
# wallet. The receive-path loop must iterate past the locked match and pick
215+
# the unlocked one. A naive "break on first match" or "break on first Locked"
216+
# refactor would mis-route to RPC_WALLET_UNLOCK_NEEDED here. The locked wallet
217+
# is loaded FIRST so it appears first in the iteration order — that's the
218+
# ordering that exercises the failure mode.
219+
#
220+
# The forger (scheduler.cpp ForgeBlock) uses the same iteration shape with
221+
# the same Available-only short-circuit (&&), so structural correctness
222+
# carries over; the actual forge path is out of scope here.
223+
# -----------------------------------------------------------------------------
224+
echo ""
225+
echo "Case D — Locked wallet + Available wallet (Available must win across wallets)"
226+
echo "-----------------------------------------------------------------------------"
227+
$CLI createwallet locked_first false true "" false true >/dev/null
228+
$CLI -rpcwallet=locked_first importdescriptors "[{\"desc\": \"$PRIVKEY_DESC\", \"timestamp\": \"now\"}]" >/dev/null
229+
$CLI -rpcwallet=locked_first encryptwallet "$PASSPHRASE" >/dev/null
230+
LF_UNLOCKED_UNTIL=$($CLI -rpcwallet=locked_first getwalletinfo | jq -r '.unlocked_until // 0')
231+
[ "$LF_UNLOCKED_UNTIL" = "0" ] || fail "locked_first did not lock (unlocked_until=$LF_UNLOCKED_UNTIL)"
232+
233+
$CLI createwallet keyed_second false true "" false true >/dev/null
234+
$CLI -rpcwallet=keyed_second importdescriptors "[{\"desc\": \"$PRIVKEY_DESC\", \"timestamp\": \"now\"}]" >/dev/null
235+
236+
echo "Loaded wallets (probe order): $($CLI listwallets | jq -c '.')"
237+
238+
CTX=$($CLI get_mining_info)
239+
HEIGHT=$(echo "$CTX" | jq -r '.height')
240+
BLOCK_HASH=$(echo "$CTX" | jq -r '.block_hash')
241+
GEN_SIG=$(echo "$CTX" | jq -r '.generation_signature')
242+
BASE_TARGET=$(echo "$CTX" | jq -r '.base_target')
243+
MIN_COMPRESSION=$(echo "$CTX" | jq -r '.minimum_compression_level')
244+
245+
submit_nonce_against "$WITNESS_PROGRAM"
246+
echo "submit_nonce exit: $SN_RC"
247+
echo "$SN_RAW"
248+
249+
[ "$SN_RC" -eq 0 ] || fail "multi-wallet probe failed: expected Available to win over Locked, got exit $SN_RC"
250+
RAW_QUALITY=$(echo "$SN_RAW" | jq -r '.raw_quality // empty')
251+
POC_TIME=$(echo "$SN_RAW" | jq -r '.poc_time // empty')
252+
[ -n "$RAW_QUALITY" ] || fail "multi-wallet success response missing raw_quality"
253+
[ -n "$POC_TIME" ] || fail "multi-wallet success response missing poc_time"
254+
echo "Case D PASS (Available won across wallets; raw_quality=$RAW_QUALITY poc_time=${POC_TIME}s)"
255+
256+
$CLI unloadwallet locked_first >/dev/null
257+
$CLI unloadwallet keyed_second >/dev/null
258+
259+
# -----------------------------------------------------------------------------
260+
# Case E — same multi-wallet setup as Case D, but Available is loaded FIRST.
261+
# Once availability=Available the loop breaks, so a later Locked match must
262+
# never downgrade the result. Together with D this proves the probe outcome
263+
# is order-independent for the {Locked, Available} pair.
264+
# -----------------------------------------------------------------------------
265+
echo ""
266+
echo "Case E — Available wallet + Locked wallet (Available must win, regardless of order)"
267+
echo "-----------------------------------------------------------------------------------"
268+
$CLI createwallet keyed_first false true "" false true >/dev/null
269+
$CLI -rpcwallet=keyed_first importdescriptors "[{\"desc\": \"$PRIVKEY_DESC\", \"timestamp\": \"now\"}]" >/dev/null
270+
271+
$CLI createwallet locked_second false true "" false true >/dev/null
272+
$CLI -rpcwallet=locked_second importdescriptors "[{\"desc\": \"$PRIVKEY_DESC\", \"timestamp\": \"now\"}]" >/dev/null
273+
$CLI -rpcwallet=locked_second encryptwallet "$PASSPHRASE" >/dev/null
274+
LS_UNLOCKED_UNTIL=$($CLI -rpcwallet=locked_second getwalletinfo | jq -r '.unlocked_until // 0')
275+
[ "$LS_UNLOCKED_UNTIL" = "0" ] || fail "locked_second did not lock (unlocked_until=$LS_UNLOCKED_UNTIL)"
276+
277+
echo "Loaded wallets (probe order): $($CLI listwallets | jq -c '.')"
278+
279+
CTX=$($CLI get_mining_info)
280+
HEIGHT=$(echo "$CTX" | jq -r '.height')
281+
BLOCK_HASH=$(echo "$CTX" | jq -r '.block_hash')
282+
GEN_SIG=$(echo "$CTX" | jq -r '.generation_signature')
283+
BASE_TARGET=$(echo "$CTX" | jq -r '.base_target')
284+
MIN_COMPRESSION=$(echo "$CTX" | jq -r '.minimum_compression_level')
285+
286+
submit_nonce_against "$WITNESS_PROGRAM"
287+
echo "submit_nonce exit: $SN_RC"
288+
echo "$SN_RAW"
289+
290+
[ "$SN_RC" -eq 0 ] || fail "multi-wallet probe failed (Available first): expected exit 0, got $SN_RC"
291+
RAW_QUALITY=$(echo "$SN_RAW" | jq -r '.raw_quality // empty')
292+
POC_TIME=$(echo "$SN_RAW" | jq -r '.poc_time // empty')
293+
[ -n "$RAW_QUALITY" ] || fail "Available-first success response missing raw_quality"
294+
[ -n "$POC_TIME" ] || fail "Available-first success response missing poc_time"
295+
echo "Case E PASS (Available won; order-independent; raw_quality=$RAW_QUALITY poc_time=${POC_TIME}s)"
296+
297+
$CLI unloadwallet keyed_first >/dev/null
298+
$CLI unloadwallet locked_second >/dev/null
299+
300+
$CLI stop >/dev/null
301+
sleep 1
302+
303+
echo ""
304+
echo "ALL CASES PASS"

0 commit comments

Comments
 (0)