sweeps stable; consolidation and outbound engines built + tested but not yet operator reachable (no admin route mounted — see body)
Sourced from: docs/DECISIONS.md D3.1-D3.9 D1.6 D1.8, docs/07-API-REFERENCE.md §5.3-§5.5, docs/10-SECURITY-SPEC.md §5.3 §3.3, apps/node/src/treasury/consolidation.ts, apps/node/src/treasury/consolidation-settle.ts, apps/node/src/treasury/checkout.ts, apps/node/src/outbound/routes.ts, apps/node/src/outbound/status.ts, apps/node/src/outbound/delivery.ts, apps/node/src/engine/outbound-transfer.ts, apps/node/src/index.ts, apps/node/admin/src/pages/overview/ConsolidateAction.tsx, apps/node/admin/src/pages/transfers/

Treasury operations

Money in your node moves in three ways: sweeps (automatic, sub-wallets to treasury), consolidation (manual, treasury to treasury), and outbound transfers (manual, treasury to anywhere — this is also how refunds work in v1). This page explains all three, and the two rules that shape every one of them.

The two rules behind everything here

One in-flight transaction per wallet, always. SplitChain allows exactly one unsettled transaction to be "in flight" from any given wallet at a time — starting a second one from the same wallet before the first settles doesn't queue, it fails one of them permanently. Your node enforces this by "checking out" a wallet the instant it starts a transfer: a treasury wallet in the middle of a sweep-target receive, a consolidation, or an outbound send is locked from participating in any other transfer until that one settles. This is why you'll sometimes see a 409 treasury_busy when trying to start a second action on the same treasury wallet — it's not a bug, it's the system refusing to violate the one-in-flight rule. Both consolidation and outbound transfers use the treasury's checkout_kind column for this (CONSOLIDATION, SWEEP_TARGET, or OUTBOUND); a treasury can hold exactly one of these at a time, enforced by a guarded WHERE checkout_kind IS NULL claim that never preempts an in-progress transfer.

The node never blind-retries a submission. If a transfer's outcome is ambiguous — the gateway call times out, or the response is unclear — your node does not just try again. Retrying blindly risks submitting the same transfer twice, which could double-spend or corrupt the wallet's transaction chain. Instead, before doing anything else, the node asks the gateway directly: "did my last submission actually land?" If it landed, the node finalizes it as a success and moves on. If it provably did not land, it's safe to rebuild and retry. If the answer is unclear — the gateway itself was unreachable — the node marks the transfer NEEDS_ATTENTION rather than guessing, and it stays that way until either an automatic health-check recheck resolves it or an operator looks at it (see incidents.md).

Sweeps (automatic — this part is live)

Every sub-wallet that receives a customer payment gets swept to a treasury wallet automatically, as soon as the payment settles — you don't trigger this manually. A sub-wallet holding funds is unusable for new checkout sessions until it's fully swept to zero, so sweeps happen promptly by design, not on a schedule.

  • Sweeps run per treasury wallet as a serial lane — if you have multiple treasury wallets configured, sweeps to different treasuries proceed in parallel, but two sweeps never race into the same treasury wallet at once (that would violate the one-in-flight rule above).
  • A failed sweep automatically retries with jitter/backoff, using the same reconcile-before-retry discipline described above. If it exhausts retries or lands in an unclear state, it's flagged NEEDS_ATTENTION and an admin alert fires — but the funds themselves are never at risk in this state, because they're sitting in a wallet whose key your node already holds.
  • The sweep-automation itself (the background lane that builds, signs, and submits sweeps) runs regardless of the admin API. Viewing sweep status or retrying a failed one from the admin dashboard is a separate surface that is not reachable yet — like consolidation and outbound transfers below, no /admin/v1/sweeps* route is mounted in apps/node/src/index.ts as of this writing (the same ZUP-195 gap). Until that lands, a stuck sweep is visible only via audit_log/DB inspection or a support escalation — see incidents.md.

Consolidation — engine built, not yet reachable ("Withdraw to Treasury 1")

If you run multiple treasury wallets, consolidation is the manual action that moves a treasury wallet's whole balance into your primary treasury (commonly called "Treasury 1"). Use this to keep balances centralized rather than spread across several treasury wallets.

What's actually landed: the full operation — TOTP gate, drain-in-flight pre-check, dual-signed treasury→treasury transfer (reusing the same sweep build/sign/submit engine byte-for-byte), and settle/release — is implemented in apps/node/src/treasury/consolidation.ts and consolidation-settle.ts, and is covered by tests (22/22 per the ZUP-28 QA handoff). The admin SPA also already has the UI for it: a Consolidate button per treasury row on the Overview screen (ConsolidateAction.tsx), which opens a destination picker and fires POST /admin/v1/treasuries/:pubkey/consolidate on confirm.

What's missing: that HTTP route does not exist. There is no route file anywhere in apps/node/src implementing /admin/v1/treasuries/:pubkey/consolidate — only the underlying initiateConsolidation() service function that a route would call. The GET /admin/v1/treasuries endpoint the UI uses to list treasuries and destinations doesn't exist either. Wiring this up was explicitly flagged as a follow-up on the ZUP-28 QA handoff ("HTTP route NOT mounted... a thin follow-up in the admin-routes-owning lane"). Result: the Consolidate button in your admin dashboard will fail today — there is no operator-usable path to trigger a consolidation yet, even though the underlying transfer logic is correct and verified.

How it will work once the route lands (unchanged from the reviewed engine behavior — described here so this section only needs a status flip, not a rewrite, once wiring lands):

  1. From the admin dashboard's Overview screen, click Consolidate on the source treasury's row and pick a destination.
  2. Confirm with a fresh TOTP code.
  3. The source treasury is checked out (checkout_kind='CONSOLIDATION') for the duration of the transfer; the destination is checked out the same way a sweep target is (checkout_kind='SWEEP_TARGET'), so a concurrent sweep routes around both automatically rather than racing them.
  4. If the source treasury already has a pending transaction, the action is rejected outright (409 treasury_busy, per the code's TreasuryBusyError / the ConsolidateAction UI's error handling) rather than queued — retry once the in-flight transfer clears. The consolidation never force-preempts an in-progress sweep or another consolidation.
  5. The full source balance moves (not a partial amount) — this is a drain, not an arbitrary-amount transfer.

Reference (spec, still not operable): POST /admin/v1/treasuries/:pubkey/consolidate with body {"to_pubkey": "<treasury-1-pubkey>"}, TOTP-gated, intended to return 202 {consolidation_id} (docs/07-API-REFERENCE.md §5.3).

Outbound transfers — route built, not yet mounted (treasury to any address)

Outbound transfer is the general-purpose "send Zucoins somewhere" action from a treasury wallet — vendor payouts, manual corrections, and, in v1, refunds. There is no dedicated refund feature yet; a refund is handled as a manual outbound transfer to the customer's wallet, with reason: "refund" tagging it internally as a REFUND-kind row for your own reconciliation.

What's actually landed: more than consolidation. The construction engine (apps/node/src/engine/outbound-transfer.ts, ZUP-93 — builds and signs a real external v2 transaction from the treasury's key, amount bounds checked before signing), the TOTP-gated mutation route itself (apps/node/src/outbound/routes.ts, ZUP-94 — POST /admin/v1/outbound-transfers, merged and Done), and the push-or-code delivery decision (apps/node/src/outbound/delivery.ts, status.ts, ZUP-95) are all built and tested. The admin SPA has full UI for it too: TransferCreatePage.tsx, TransfersPage.tsx, and TransferDetailPage.tsx.

What's missing:

  • The create route is exported (createOutboundTransferRouter) but not mounted in apps/node/src/index.ts — same gap as consolidation and sweeps above (ZUP-195). apps/node/src/index.ts currently mounts only /admin/v1/auth and /admin/v1/backups; everything else under /admin/v1/* returns a 404 not_found.
  • GET /admin/v1/outbound-transfers (list) and GET /admin/v1/outbound-transfers/:id (detail) — which TransfersPage and TransferDetailPage both call — are not built at all yet; the ZUP-94 code comments explicitly scope them out ("Only the POST (create) is in ZUP-94 scope; GET list/detail are separate tickets").
  • Push delivery's concrete transport is also not wired (ZUP-192, deferred, live-chain-labelled): with no PushChannel injected, every transfer takes the manual-code path regardless of whether the recipient is actually subscribed to push.
  • GET /admin/v1/treasuries (used by the transfer-create form's source selector) doesn't exist either — see the consolidation section above.

Result: like consolidation, outbound transfers cannot be performed from a running node today. The route with the real construction/signing/TOTP logic exists and is verified in isolation, but nothing serves it. Once ZUP-195's mounting work lands POST /admin/v1/outbound-transfers and the missing GET routes are built, this section's flow below is accurate to the landed code (verified against outbound/routes.ts directly, not spec):

  1. From the admin dashboard, choose New outbound transfer, pick the source treasury, enter the destination public key, the amount (as a string, e.g. "40" — never enter or expect fractional values in floating-point form), and an optional message/reason (reason: "refund" tags it as a refund for reconciliation).
  2. Confirm with a fresh TOTP code (sent as the X-ZP-TOTP header; single-use — the same code cannot authorize a second transfer).
  3. The source treasury is checked out (checkout_kind='OUTBOUND') for the transfer's duration, exactly like consolidation.
  4. Delivery: today, because the push transport isn't wired (ZUP-192), every transfer shows you a transfer code to deliver to the recipient manually — the funds don't move until the recipient's wallet redeems that code. Once push lands, a destination subscribed to push notifications will instead get the transfer pushed directly.
  5. Track status from the outbound transfers list once GET is built: pending → delivered → settled (or failed, mapped from the internal outbound_transfers state machine — apps/node/src/outbound/status.ts).

Amount bounds are checked before any signing happens — an out-of-bounds amount is rejected up front (422 amount_out_of_bounds), never partially attempted; this is enforced in the engine itself (AmountOutOfBoundsError/assertAmountWithinBounds), not just documented.

Reference (route exists, not reachable yet):

Endpoint Status
POST /admin/v1/outbound-transfers Built + tested (ZUP-94); unmounted (ZUP-195).
GET /admin/v1/outbound-transfers Not built — separate ticket, unfiled as of this writing.
GET /admin/v1/outbound-transfers/:id Not built — separate ticket, unfiled as of this writing.

Errors the built create route returns once mounted: 402 insufficient_treasury_balance, 409 treasury_busy, 422 amount_out_of_bounds, 401 totp_required / totp_invalid, 404 wallet_not_found (unknown source treasury), 500 internal_error (opaque, post-sign failure — the row is left NEEDS_ATTENTION for reconcile, the checkout deliberately stays held).

What to do until these ship

If you need to move funds out of a treasury wallet today and neither consolidation nor outbound transfers are reachable from your node's admin UI yet, the only supported path is a backup export of the source wallet and a manual send from the official Zucoins wallet app after import — see backup-restore.md. Treat this as a stop-gap, not a routine workflow: it takes the key outside your node's automated one-in-flight tracking for as long as you're operating it from the wallet app, so make sure the node-side wallet is genuinely idle before you do this and re-sync it (re-import) afterward.