Skip to content

Harden autoforward/autoconvert for unattended operation - #1

Merged
sethforprivacy merged 12 commits into
mainfrom
cake/hardening
Oct 20, 2026
Merged

sethforprivacy merged 12 commits into
mainfrom
cake/hardening

Conversation

@sethforprivacy

Copy link
Copy Markdown

First set of Cake changes on our fork of MAGICGrants/autoforward-autoconvert. Everything here came out of the 2026-10-20 investigation on btcpay-usa-01; see the commit messages for detail.

What prompted it

  • XMR silently stopped forwarding on 2026-10-09. BTCPay issues one subaddress per invoice. A run of unpaid invoices pushed new payments past the forwarding wallet's default 200 minor lookahead, so 329 payments (~112 XMR) were invisible to it. The prod wallet was fixed by hand: set_subaddress_lookahead 50:20000 plus a rescan.
  • Kraken has marked every LTC deposit since 2025-07 Failure: 173 deposits, 716 LTC, all MWEB peg-outs. That is being taken up with Kraken separately; this PR adds the switch to stop sending them.

Changes

  • AUTOFORWARD_COINS / AUTOCONVERT_ASSETS: enable wallets and Kraken assets individually. Startup waits and seed imports follow the coin list.
  • MONERO_SUBADDRESS_LOOKAHEAD: applied after a restore.
  • Periodic Monero store (hourly and after each sweep).
  • Timeouts on every RPC and HTTP request (HTTP_TIMEOUT, SEND_TIMEOUT).
  • autoconvert:
    • survives non-JSON Kraken responses;
    • stops the intermediary route after a successful sale;
    • checks the second leg against the intermediary balance.
  • Send minimums and the wallet filename come from env instead of host-side edits to constants.py.
  • compose passes through the fee and slippage settings that were previously ignored.
  • status/<program>.json heartbeat and health for Zabbix, including the Monero wallet's max_received_subaddress, to compare with BTCPay's.
  • The wallet-rpc image is pinned instead of :latest.
  • Upstream's SSH deploy workflow is replaced with CI: py_compile, pyflakes, compose config.

Not in this PR

  • Zcash autoforwarding (next PR; Kraken only offers transparent ZEC deposits).
  • Deployment: cake-tech/ansible will render the compose file and .env and run this at a pinned commit.

Testing

  • py_compile, pyflakes clean, docker compose config OK.
  • Status writer and env parsing smoke-tested locally.
  • Not yet run against the live wallets; it gets a --check deploy via Ansible first.

🤖 Generated with Claude Code

sethforprivacy and others added 12 commits October 20, 2026 10:38
deploy.yml SSHes into MAGIC Grants' VPS on every push to main; in this
fork it has no secrets and would fail (or, worse, be pointed somewhere).
Cake deploys this from cake-tech/ansible. CI now byte-compiles, runs
pyflakes and validates the compose file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
:latest swaps the wallet software under a hot wallet whenever anyone
rebuilds. VERSION is a build arg (MONERO_WALLET_RPC_VERSION in compose).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Found in production (Cake Pay, 2026-10):

- Monero payments silently stopped being seen for 11 days. The merchant
  (BTCPay) issues one subaddress per invoice; a run of unpaid invoices
  pushed new payments past the wallet's default 200 minor lookahead.
  MONERO_SUBADDRESS_LOOKAHEAD is applied right after a restore, and the
  loop now reports the highest subaddress that has received funds so a
  monitor can compare it with the merchant wallet's.
- monero-wallet-rpc only writes its cache on close; the prod wallet had
  not been stored for 35 days. It is now stored hourly and after sweeps.
- No request had a timeout, and every coin shares one loop, so a single
  hung RPC stalls all coins indefinitely. HTTP_TIMEOUT / SEND_TIMEOUT.
- autoconvert crashed on a non-JSON Kraken response from load_balances(),
  which ran outside any try. A Kraken outage now costs one pass.
- The intermediary route never stopped after a successful sale (it went
  on re-selling a spent balance through every other intermediary), and
  checked the second leg against the asset's minimum instead of the
  intermediary balance's.
- Kraken has been failing MWEB peg-out deposits. AUTOFORWARD_COINS
  enables wallets individually (startup waits and seed imports follow
  it); AUTOCONVERT_ASSETS does the same for sales, e.g. to add ZEC.
- MIN_*_SEND_AMOUNT and MONERO_WALLET_FILENAME were edited into
  constants.py on the host; they are environment settings now.
- compose never passed MAX_NETWORK_FEE_PERCENT, MAX_SLIPPAGE_PERCENT or
  the fee-source settings to the containers, so .env values for them
  were ignored. They are passed through now.

Each program writes STATUS_DIR/<program>.json (heartbeat, per-section
last_ok/last_error/errors_total, balances, last forward/sale) for
monitoring. Also: pyflakes clean-ups and copy-pasted log messages in
the seed importer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
DRY_RUN does everything a live pass does except the parts that move
funds: Electrum transactions are built, fee-checked and signed but not
broadcast; Monero sweeps are built with do_not_relay (wallet-rpc then
skips commit_tx, so nothing is relayed or marked spent); Kraken orders go
out with validate=true; no new Kraken deposit address is created. With
no Kraken key a dry run forwards to the wallet's own address and
autoconvert idles. Default ON: a staging box or a fresh deploy that
forgets the setting must not move money. Every status file reports
dry_run, so monitoring can tell a prod host that silently is not
forwarding.

tests/test_dry_run.py runs each forward/sale path against fake
Electrum/Monero/Kraken endpoints at the HTTP boundary and asserts no
fund-moving request went out; each scenario also runs with DRY_RUN=0 and
must then produce one, so a path that gives up early cannot pass. Removing
any one guard (broadcast, do_not_relay, validate) fails it.

Also: autoforward's loop is now under main() so it can be imported, and
get_monero_unused_address() returned the RPC result dict instead of the
address (upstream's TESTNET path never worked).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
simple-monero-wallet-rpc's pinned v0.18.5.1 image no longer ships fixuid;
it starts as root and its own /entrypoint.sh adds --non-interactive and
the bind flags, chowns the volume to PUID/PGID (1000, the old monero uid)
and drops privileges with su-exec. This wrapper still exec'd fixuid, so
wallet-rpc crash-looped on 'fixuid: not found' - found on btcpay-stg, and
it would have taken production's wallet down at cutover. Now it only adds
--testnet and hands off.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Found testing on btcpay-stg and confirmed on Cake Pay production:

- Gap limit. The LTC chain had a run of 1093 unpaid invoice addresses,
  past Electrum's 1000, so Electrum stopped there and never saw the 2859
  outputs (4441 LTC) paid since mid-2025; staging's BTC wallet missed a
  payment at index 4975 the same way. ELECTRUM_GAP_LIMIT is now applied at
  every startup, not only at import, so raising it reaches live wallets.
- Batching. Sweeping that backlog in one transaction (~195 kvB) would
  exceed the 100 kvB standard size and never relay. Each pass now spends
  at most ELECTRUM_MAX_INPUTS outputs, largest first.
- MWEB. Kraken fails MWEB peg-out deposits (173 of them since 2025-07), so
  MWEB is no longer sent to Kraken: it is pegged out to a CHANGE address of
  the LTC wallet (invoices only get receiving addresses, so it can't be
  taken for a payment), and the LTC pass forwards it in a standard
  transaction once mature. LTC outputs need LITECOIN_MIN_CONFIRMATIONS (6):
  consensus rejects spending a peg-out before PEGOUT_MATURITY.
- Volumes. Compose mounted the LTC volumes at /home/electrum-ltc/.electrum;
  the image's user writes /home/electrum/.electrum-ltc, so both LTC wallets
  lived in their containers and a recreate lost them.
- Each wallet's tracked receiving-address count is reported hourly, for a
  monitor that compares it with the merchant's address index.

Tests: LTC waits for peg-out maturity, a 1000-output backlog goes out 400
at a time largest first, MWEB goes to an LTC change address and never to
Kraken; each fails when its rule is removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The image's /entrypoint.sh is byte-identical to v0.18.5.1's, so the
wrapper's hand-off is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Lets a test bed run the whole MWEB path - the real peg-out to an LTC
change address, its 6-block maturity and the LTC pass that forwards it -
while every send to the exchange stays a dry run. A peg-out only moves
coins between the operator's own wallets. Off by default; reported as
pegout_live in the status file. Tests: a peg-out is broadcast, BTC/LTC/
XMR forwards stay dry under the same setting.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Both found on btcpay-stg by breaking backends on purpose:

- A restarted Electrum daemon answers 'wallet not loaded', and wallets
  were loaded only at startup, so the coin stayed dead until this program
  restarted (the old runbook's 'wallet isn't loaded on startup'). Any call
  that gets 'wallet not loaded' now loads the wallet and retries once.
- With monero-wallet-rpc paused, no pass completed for 12+ minutes: the
  receipts read carried the 600 s send timeout, then each later call its
  own. Each Monero pass now starts with a 15 s get_height probe
  (PROBE_TIMEOUT), and only the first full receipts read gets the long
  timeout.

Tests for both; each fails without its fix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…exists'

Found by rebuilding a Monero wallet on btcpay-stg: the deploy recreated
wallet-rpc as soon as the restored keys file appeared, while the
importer was still ~70 s into set_subaddress_lookahead, so the lookahead
was never applied and the rebuilt wallet was blind past index 200 - the
very miss it exists to prevent - and nothing noticed, because every
failure was printed and the importer still exited 0. It now exits 1 if
any import fails, so a deploy can wait for it and check.

Monero's 'Wallet already exists.' / 'No wallet dir configured' (opened by
--wallet-file) were reported as import errors on every deploy; they are
'already set up' now. (The Electrum check only knew Electrum's message.)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@sethforprivacy
sethforprivacy merged commit 21954da into main Oct 20, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant