Skip to content

System Accounts: treasury view + gated fund movement (bankowner/funder/dealer)#50

Merged
islandbitcoin merged 4 commits into
mainfrom
feat/system-accounts
Jul 11, 2026
Merged

System Accounts: treasury view + gated fund movement (bankowner/funder/dealer)#50
islandbitcoin merged 4 commits into
mainfrom
feat/system-accounts

Conversation

@islandbitcoin

Copy link
Copy Markdown
Contributor

Adds a System Accounts desk page (/app/system-accounts) giving operators the treasury handle that cashout testing needs — live bankowner/funder/dealer balances, cashout-coverage math, and a gated way to move funds between system wallets. This is the admin handle called for before consolidating the parked ops accounts (ENG-492).

What it does

View (require_financial — System Manager + Accounts Manager):

  • Pulse tiles: BankOwner Float, Outstanding Payables (USD-equiv of Pending/Draft/In-Progress cashouts), Free Float (float − payables, red when negative), Funder Float
  • A card per role account (bankowner/funder/dealer) plus an optional watchlist of ops accounts, each with live IBEX balance and expandable transaction activity (reusing the census tx-type labels)

Move funds (System Manager only):

  • Transfer between transfer-enabled wallets via the IBEX add-invoice-on-receiver / pay-from-sender primitive the flash cutover code uses; review → confirm → execute
  • Per-transfer cap from site_config (system_transfer_cap_usd, default $100)
  • Every attempt recorded in an append-only System Transfer Log before the IBEX call

UI-managed watchlist with per-account transfer opt-in: watch any ops account from the page (validated against mongo); it's view-only until you explicitly enable transfers on it — only then is it a valid transfer endpoint. This is the safe path to consolidate parked accounts (watch → enable → move → disable).

Money-flow basis (verified in the flash backend)

wallets.id IS the IBEX account id (system accounts too); a cashout is the user paying an invoice on bankowner, so bankowner float grows with cashouts and the fiat owed is the liability. Role accounts are identified by mongo accounts.role.

New backend surface

  • api/system_accounts.py — resolve accounts, payables, transfer, watchlist management
  • api/ibex_client.py gains exactly two writes (add_invoice, pay_invoice) behind _post; a contract test pins the write surface to those two endpoints
  • Two doctypes: System Transfer Log (append-only), System Watchlist
  • One shared fix: handle_api_errors now re-raises frappe.ValidationError/PermissionError so deliberate throw() messages (e.g. the cap) reach the operator instead of being masked as "internal error" — this was a latent bug affecting every admin endpoint

Adversarial money-safety review (multi-agent) + fixes

An 8-dimension + completeness-critic review confirmed 14 findings; all fixed in fb73981:

  • Critical — double-spend on retry: a pay_invoice HTTP timeout was logged "Failed", but a Lightning payment can settle after the HTTP call times out → operator retries → funds move twice. Fixed: a timeout marks a distinct Pending status (never Failed), and an idempotency guard refuses a new transfer from a wallet with any unresolved (Draft/Pending) prior.
  • Major — false success: the pay result wasn't inspected, so a 200-with-FAILED body logged "Paid". New _payment_settled() gates Paid on an affirmative SUCCEEDED, fail-safe to Pending on any ambiguous shape.
  • Major — dropped balances / BTC leak: the currency fallback returned title-case ("Usdt"/"Btc"), failing the uppercase checks → balances silently zeroed and BTC leaked into the picker as dollars. Normalized to uppercase at the source.
  • Major — dead watchlist buttons for id-added entries; now thread the exact stored watch_ref.
  • Minor: NaN bypassed the cap (math.isfinite guard); the raw IBEX response incl. the LN preimage was returned to the browser (now returns only {success, log, status}); config-masks-doctype watchlist edge.

Tests

test_system_accounts_page_contract.py (16 tests) pins the money-safety invariants: log-before-move ordering, System-Manager-only transfer, cap enforcement (was unpinned — could have been deleted green), Paid-only-on-settlement, Pending-on-timeout, the idempotency guard, the NaN guard, no-raw-response-leak, IBEX write-surface lock, activity membership revalidation, append-only doctype, per-account opt-in. Suite: 60 passed; the 3 remaining failures are the pre-existing stale test_account_hub_search_js tests.

Verification

Every path verified live against IBEX sandbox: seeded role + watchlist accounts, ran real transfers (happy-path marks Paid with no preimage leak; funder→bankowner and bankowner→watchlist both settled and logged), and confirmed every guard rejects — cap, non-finite amount, non-transfer-enabled wallet, and the dedup guard blocking a retry while a Pending row exists. The _payment_settled fail-safe logic is unit-tested across success/failure/ambiguous/malformed shapes.

Deploy notes

  • New site_config key: set system_transfer_cap_usd on TEST and prod (per-transfer cap; default $100 if unset). Same mechanism as the IBEX creds: bench --site <site> set-config system_transfer_cap_usd <n>.
  • Page JS is redis-cached → bench clear-cache after the image bump.
  • Two new doctypes migrate cleanly; the page registers via setup.py sync_pages + the workspace fixture.
  • The system_transfer_cap_usd cap lives in site_config precisely so a web-only System Manager can't raise their own limit — changing it needs shell/bench access.

🤖 Generated with Claude Code

Dread added 4 commits July 11, 2026 12:51
…ted transfers

New desk page /app/system-accounts giving operators the treasury handle
that cashout testing (and the parked mikeyy consolidation) needs.

View (require_financial — System Manager + Accounts Manager):
- pulse tiles: BankOwner Float, Outstanding Payables (USD-equiv of
  Pending/Draft/In Progress cashouts), Free Float (float − payables,
  red when negative), Funder Float
- per-account cards for every mongo role account (bankowner/funder/
  dealer) plus an optional site_config `system_watchlist` of view-only
  ops accounts (e.g. FLASH FLOAT, the parked mikeyy account): live IBEX
  balance per wallet + expandable recent activity reusing the census
  tx-type labels/signed amounts

Control (System Manager only — Move Funds):
- transfer between role wallets via the IBEX add-invoice-on-receiver /
  pay-from-sender primitive the flash cutover code uses; review →
  frappe.confirm → execute
- per-transfer cap (site_config `system_transfer_cap_usd`, default $100)
- watchlist accounts are view-only, never transfer endpoints
- every attempt recorded in a new append-only System Transfer Log
  doctype (in_create, no write/create/delete perms) BEFORE the IBEX
  call; status Draft → Paid/Failed; audit_log on success

Money-flow basis (verified in the flash backend): wallet.id IS the IBEX
account id; a cashout is the user paying an invoice on bankowner, so
bankowner float grows with cashouts and "coverage" is float minus unpaid
fiat liability. IbexClient gains exactly two writes (add_invoice,
pay_invoice) behind _post; a contract test pins the write surface to
those two endpoints.

Also fixes a latent UX bug in the shared handle_api_errors: deliberate
frappe.throw() (ValidationError/PermissionError) was masked as "An
internal error occurred" for EVERY admin endpoint; now re-raised so the
message (e.g. the transfer cap) reaches the operator.

New test_system_accounts_page_contract.py (9 tests): gate stack,
System-Manager-only transfer + cap + role-wallet guard, log-before-move
ordering, activity membership revalidation, IBEX write-surface lock,
CSS scoping, registration, append-only doctype. Suite: 53 passed.

Verified live against IBEX sandbox: seeded three role accounts, executed
a real funder→bankowner $0.25 transfer (IBEX SUCCEEDED, balances moved
$2.00→$2.25 / $1.14→$0.89, logged STL-2026-00009 Paid), and confirmed
the cap + role-wallet guards reject.
… opt-in

Answers "can I add to the watchlist from the UI, and move funds in/out of
those accounts?" — yes to both, with a deliberate safety default.

- New System Watchlist doctype (System-Manager CRUD, Accounts-Manager
  read) backs a UI-managed list; the legacy site_config `system_watchlist`
  still works but stays view-only and non-managed.
- Page gains a "Watch Account" action (validates the ref resolves in
  mongo) and, on each managed watch card, a remove ✕ and an "Enable/
  Disable transfers" toggle. A "view-only" / "transfers on" chip shows
  state at a glance.
- Per-account opt-in: watchlist accounts are view-only until you flip
  allow_transfers. Only then do they count as a transfer endpoint and
  appear in the Move Funds picker. Role accounts (bankowner/funder/
  dealer) stay always-transferable. Enabling is confirm-gated; every
  add/remove/toggle is audit_logged.
- transfer guard reworded ("transfer-enabled system wallet") and now
  admits opted-in watch accounts; cap + System-Manager gate + System
  Transfer Log unchanged.

Three new endpoints (add/remove/set_watchlist_transfers), all System
Manager only. +1 contract test (opt-in semantics + endpoint gating);
suite 54 passed.

Verified live against IBEX sandbox: watched sandbox_user_2, enabled
transfers (confirm dialog), it appeared in the picker, moved bankowner→
watch $0.10 (SUCCEEDED, STL-2026-00010); disabling opt-in then rejected
the identical move ("not a transfer-enabled system wallet").
Fixes the confirmed findings from the money-safety review, most-severe
first. All verified live against IBEX sandbox.

CRITICAL — double-spend on retry: pay_invoice's HTTP call timing out was
recorded as "Failed", but a Lightning payment can settle server-side
after the HTTP timeout — the operator would see failure and retry,
moving the funds twice. Now:
  - a requests timeout/connection error marks the transfer "Pending"
    (new status), never "Failed", and tells the operator the outcome is
    unknown — verify balances before retrying;
  - an idempotency guard refuses a new transfer from a wallet that has
    any unresolved (Draft/Pending) prior transfer, so a retry can't
    double-spend.

MAJOR — false success: the pay_invoice result was never inspected, so a
200-with-FAILED/PENDING body was logged "Paid". New _payment_settled()
gates "Paid" on an affirmative SUCCEEDED (status name or statusId 2);
an affirmative failure marks "Failed"; anything ambiguous stays
"Pending". Fail-safe: an unknown response shape returns None (never
success). Unit-tested across success/fail/ambiguous/malformed shapes.

MAJOR — dropped balances + BTC leak: the currency fallback returned
title-case "Usdt"/"Btc" (as mongo actually stores it), which failed the
uppercase USD/USDT float check and the BTC exclusion — silently zeroing
balances from the totals and leaking BTC wallets into the transfer
picker as dollars. Currency is now normalized to uppercase at the source.

MAJOR — dead watchlist buttons: toggle/remove sent username||account_id,
but id-added entries are stored under the id, so the buttons no-op'd.
The resolved account now carries watch_ref (the exact stored ref) and
the buttons use it.

MINOR — NaN bypass: float("nan") slipped past `amount > cap` (NaN
comparisons are always False). math.isfinite() now gates the amount.
MINOR — preimage leak: the raw pay_invoice response (incl. LN preimage)
was returned to the browser; the endpoint now returns only {success,
log, status}.
MINOR — config masks doctype: a legacy site_config watch entry could
mask a doctype transfers-on row for the same account; resolution now
prefers the managed (doctype) entry.

+6 contract tests pinning: cap enforcement (was unpinned — could be
deleted green), Paid-only-on-settlement, Pending-on-timeout, the
idempotency guard, the NaN guard, and no-raw-response-leak. Suite: 60
passed / 3 known-stale.

Live-verified: happy-path transfer still marks Paid with no preimage in
the response; NaN rejected; the dedup guard blocks a retry while a
Pending row exists; balances/totals correct with normalized currencies;
a mongo-id-added watch entry resolves its watch_ref to the id.
The container's ruff formatted the multiline assert differently from
the pinned ruff-pre-commit v0.8.1 that CI runs. Reformatted to match.
@islandbitcoin
islandbitcoin merged commit 54eafa6 into main Jul 11, 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