Skip to content

Deferred from the audit: payout loss on crash, and change-key reuse #23

Description

@Bitflash-sh

Two findings from the audit that I deliberately did not patch, because a hasty fix for either is more dangerous than the bug. Recording them so they get solved on purpose rather than forgotten.

1. Pool payouts are lost if the operator crashes at the wrong moment

FlushMaturePayouts erases a matured payout from gPendingPayouts, rewrites the file without it, and only then pushes it to gPayoutExecQueue — which is memory-only. SavePendingPayouts serialises gPendingPayouts and nothing else.

Crash between the rewrite and SendMoney and those miners are never paid, with nothing on disk recording the debt. The comment claiming recovery from an "exec-queue section of the file" was wrong and is corrected in #22.

Why not just persist the queue and replay it: a crash after SendMoney would then pay the same miners twice, draining the operator's wallet. Shortchanging a miner is bad; silently double-spending the operator's balance is worse.

What it actually needs: idempotent payment. Record the txid alongside each payout, and on restart look it up in the wallet — re-send only when absent. Wants testing against a live pool with real miners, which is not something the audit could exercise.

2. Change goes back to a key the wallet already used

CreateTransaction builds the change output from a pubkey pulled off one of the inputs:

CTransaction& txFirst = *(*setCoins.begin());
foreach(const CTxOut& txout, txFirst.vout)
    if (txout.IsMine())
        if (ExtractPubKey(txout.scriptPubKey, true, vchPubKey))
            break;

Bitcoin generates a fresh key here, with the comment "to keep it from being obvious which side is the change." Reusing an input's key makes the change output trivially identifiable, and links every transaction that touches that key into one cluster. For a project whose stated point is anonymity — .btf addressing, no IP advertised to peers — the wallet undoes on-chain what the network layer works to protect.

Why not just call GenerateNewKey(): there is no keypool. Every transaction would create a key that is not in the user's last wallet.dat backup, so restoring a backup taken before the send loses the change. That converts a privacy weakness into lost coins, which is not a trade worth making silently.

What it actually needs: a keypool — pre-generate keys so a backup covers the next N transactions — and then fresh change keys on top of it. That is how Bitcoin resolved the same tension.

Note on severity

Neither is remotely exploitable. They are correctness and privacy gaps, unlike the denial-of-service and theft issues in #12 and #16#20. They are filed because both are the kind of thing that gets rediscovered painfully later, by someone who lost money.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions