Why not just reuse one address per merchant?
Reusing one address is the lazy path and it's wrong for three reasons. Privacy: every customer can see every other customer's payment by looking at the merchant's address history. Reorgs: a 6-block reorg can roll back a payment that you've already credited, and untangling which credit belongs to which order from a shared address is a forensic exercise. Accounting: tying a deposit to an invoice requires a memo or amount-match, both of which fail when two customers pay the same amount.
The Apirone-style fix is per-invoice addresses: derive a fresh address at index N+1 from the merchant's xpub, hand it out, and move on. We use BIP-49 paths so addresses are P2SH-P2WPKH (the boring 3-prefix that every wallet supports).
The state machine
Once the address is handed out, the invoice walks through eight explicit states: awaiting → detected → confirming → ready → broadcasting → completed (or failed, or deferred). Each transition writes a row into the forwarding_runs table with a timestamp, so when something goes wrong at 03:00 we have an audit log instead of a vibe.
The chain-monitor process polls every 1.5s, looks for new transactions to known addresses, and advances the state. When ready, the forwarder constructs a PSBT, ships it to the signer service, broadcasts the signed transaction, and waits for one confirmation before marking the run completed.
What can go wrong
Reorgs are the obvious one — we keep a 6-block confirmation buffer for BTC and re-process the last 12 blocks on every poll. If a tx vanishes from the chain, the invoice flips back to detected and waits for the new chain to catch up.
Dust outputs are the subtle one. If a customer overpays by 100 sats and we have to refund, the refund itself costs 200 sats in network fee — so we'd be sending a negative balance. The fix is a per-coin dust threshold and a 'consolidation' job that batches dust outputs into a single hourly transfer.