upleb.uk

Public git repos — served from a NIP-34 GRASP relay at git.upleb.uk

summaryrefslogtreecommitdiff
path: root/src/walletcmd.cpp
diff options
context:
space:
mode:
authorBitflash <btf01@proton.me>2026-08-02 14:03:55 -0300
committerGitHub <noreply@github.com>2026-08-02 14:03:55 -0300
commit67271dda37e4b9764c59059caa2c8268f755d6e5 (patch)
tree1b95c12f5ab6558243833a43f1ef52ad8580d7a0 /src/walletcmd.cpp
parent00c2d6af83c553df90e5950cf68552752b7ae6e7 (diff)
wallet: one quiet batch is not far enough to stop a restore (#115)
Restoring from a phrase gave up after the first batch of a hundred addresses that turned up nothing, and the wallet digs a wider gap than that by itself: a restore leaves nHDNext at the depth it scanned, the key pool derives KEYPOOL_SIZE more, and change takes the index after those. Measured on a real wallet with real coin -- restore, spend once, and the outputs land at indices 201 and 302; restoring again found only the first transaction and reported the spend as gone. With -restoredepth=500 the same wallet found both, which is what said this was reach and not loss. Three quiet batches instead of one: 2 transaction(s) across 601 derived addresses on the same wallet, chain and phrase. Worse after #109, not before it: change used to return to an input's key, a low index the scan had already passed. Making change recoverable moved it out to where the scan stopped looking. Does not fix the gap growing with every restore -- pulling nHDNext back to the last used index is the structural answer and needs its own testing.
Diffstat (limited to 'src/walletcmd.cpp')
-rw-r--r--src/walletcmd.cpp100
1 files changed, 97 insertions, 3 deletions
diff --git a/src/walletcmd.cpp b/src/walletcmd.cpp
index fae2095..6d5f32b 100644
--- a/src/walletcmd.cpp
+++ b/src/walletcmd.cpp
@@ -30,6 +30,12 @@ static const int RESTORE_MAX = 10000;
30// can stop just before that change output. 30// can stop just before that change output.
31static const int RESTORE_MIN_SCAN = KEYPOOL_SIZE + RESTORE_BATCH; 31static const int RESTORE_MIN_SCAN = KEYPOOL_SIZE + RESTORE_BATCH;
32 32
33// Quiet batches required before giving up. Three, because the wallet's own
34// bookkeeping can leave a gap of two hundred used-nothing indices between one
35// used address and the next -- see the note at the stop condition. One was not
36// enough and cost a real balance in testing.
37static const int RESTORE_QUIET_BATCHES = 3;
38
33int CmdNewPhrase() 39int CmdNewPhrase()
34{ 40{
35 AttachTerminal(); 41 AttachTerminal();
@@ -110,6 +116,7 @@ bool RestoreFromPhrase(const std::string& strMnemonic,
110 // because the scan asks the wallet what belongs to it. 116 // because the scan asks the wallet what belongs to it.
111 int nTotalDerived = (int)nHDNext; 117 int nTotalDerived = (int)nHDNext;
112 int nStopDepth = max(nMinDepth, RESTORE_MIN_SCAN); 118 int nStopDepth = max(nMinDepth, RESTORE_MIN_SCAN);
119 int nQuietBatches = 0;
113 while (nTotalDerived < RESTORE_MAX) 120 while (nTotalDerived < RESTORE_MAX)
114 { 121 {
115 // Count wallet transactions, not scan hits. 122 // Count wallet transactions, not scan hits.
@@ -149,9 +156,22 @@ bool RestoreFromPhrase(const std::string& strMnemonic,
149 fnProgress(pArg, nTotalDerived, (int)nRecoveredNow); 156 fnProgress(pArg, nTotalDerived, (int)nRecoveredNow);
150 } 157 }
151 158
152 // A whole batch with nothing in it means far enough -- unless the 159 // Stopping needs more than one quiet batch, because this wallet digs
153 // caller asked to look deeper anyway. 160 // gaps in its own derivation. A restore leaves nHDNext at the depth it
154 if (nWalletAfter == nWalletBefore && nTotalDerived >= nStopDepth) 161 // scanned, the key pool then derives KEYPOOL_SIZE more, and change
162 // takes the index after that -- so the address used *after* a restore
163 // can sit two hundred indices past the last one used before it, with
164 // nothing in between.
165 //
166 // Measured, on a real wallet with real coin: restore, spend once, and
167 // the coins land at indices 201 and 302. Restoring again with the old
168 // one-batch rule stopped at 201 and reported a single transaction. The
169 // money was derived, covered by the phrase, and invisible.
170 if (nWalletAfter == nWalletBefore)
171 nQuietBatches++;
172 else
173 nQuietBatches = 0;
174 if (nQuietBatches >= RESTORE_QUIET_BATCHES && nTotalDerived >= nStopDepth)
155 break; 175 break;
156 } 176 }
157 177
@@ -251,3 +271,77 @@ int CmdShowDerived(int nCount)
251 fflush(stdout); 271 fflush(stdout);
252 return 0; 272 return 0;
253} 273}
274
275// Spend, from the command line.
276//
277// SendMoney() has been in this tree since 0.1.0 and only the window ever called
278// it, so a headless node could be paid and could never pay: it held a balance
279// with no way to move it. Found while trying to prove that change from a wallet
280// with a recovery phrase lands on a key the phrase can reproduce -- a test that
281// needs a spend, and there was no way to make one without a screen.
282//
283// Deliberately strict, because this moves money and there is nobody to click
284// "are you sure": the address must decode, the amount must parse and be
285// positive, and anything else refuses before touching the wallet.
286int CmdSendTo(const std::string& strArg)
287{
288 AttachTerminal();
289
290 std::string::size_type comma = strArg.rfind(',');
291 if (comma == std::string::npos)
292 {
293 fprintf(stderr, "Usage: -sendto=ADDRESS,AMOUNT (for example -sendto=B7kQ...,1.5)\n");
294 return 1;
295 }
296 std::string strAddr = strArg.substr(0, comma);
297 std::string strAmount = strArg.substr(comma + 1);
298
299 // Trim, so a quoted argument with stray spaces does not silently become an
300 // invalid address and send nothing.
301 while (!strAddr.empty() && isspace((unsigned char)strAddr[0])) strAddr.erase(0, 1);
302 while (!strAddr.empty() && isspace((unsigned char)strAddr[strAddr.size()-1])) strAddr.erase(strAddr.size()-1);
303 while (!strAmount.empty() && isspace((unsigned char)strAmount[0])) strAmount.erase(0, 1);
304 while (!strAmount.empty() && isspace((unsigned char)strAmount[strAmount.size()-1])) strAmount.erase(strAmount.size()-1);
305
306 uint160 hash160;
307 if (!AddressToHash160(strAddr, hash160))
308 {
309 fprintf(stderr, "Not a valid address: %s\n", strAddr.c_str());
310 return 1;
311 }
312
313 int64 nValue = 0;
314 if (!ParseMoney(strAmount.c_str(), nValue) || nValue <= 0)
315 {
316 fprintf(stderr, "Not a valid amount: %s\n", strAmount.c_str());
317 return 1;
318 }
319
320 std::string strWhy;
321 if (!CanScanWalletTransactions(strWhy))
322 {
323 // Spending needs the chain: without it the wallet cannot tell which of
324 // its outputs are still unspent, and a transaction built on that guess
325 // is one the network will reject.
326 fprintf(stderr, "The block chain is not loaded, so this wallet cannot tell "
327 "which coins it still has. Start the node and let it sync first.\n");
328 return 1;
329 }
330
331 CScript scriptPubKey;
332 scriptPubKey << OP_DUP << OP_HASH160 << hash160 << OP_EQUALVERIFY << OP_CHECKSIG;
333
334 CWalletTx wtx;
335 if (!SendMoney(scriptPubKey, nValue, wtx))
336 {
337 fprintf(stderr, "The transaction was not created. Usually that means the "
338 "balance is too low once the fee is counted.\n");
339 return 1;
340 }
341
342 printf("Sent %s to %s\n", FormatMoney(nValue).c_str(), strAddr.c_str());
343 printf(" transaction %s\n", wtx.GetHash().ToString().c_str());
344 printf(" it needs to be relayed and mined before the other side sees it.\n");
345 fflush(stdout);
346 return 0;
347}