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_ATTENTIONand 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 inapps/node/src/index.tsas of this writing (the same ZUP-195 gap). Until that lands, a stuck sweep is visible only viaaudit_log/DB inspection or a support escalation — seeincidents.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):
- From the admin dashboard's Overview screen, click Consolidate on the source treasury's row and pick a destination.
- Confirm with a fresh TOTP code.
- 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. - If the source treasury already has a pending transaction, the action is
rejected outright (
409 treasury_busy, per the code'sTreasuryBusyError/ 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. - 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 inapps/node/src/index.ts— same gap as consolidation and sweeps above (ZUP-195).apps/node/src/index.tscurrently mounts only/admin/v1/authand/admin/v1/backups; everything else under/admin/v1/*returns a404 not_found. GET /admin/v1/outbound-transfers(list) andGET /admin/v1/outbound-transfers/:id(detail) — whichTransfersPageandTransferDetailPageboth 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 noPushChannelinjected, 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):
- 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). - Confirm with a fresh TOTP code (sent as the
X-ZP-TOTPheader; single-use — the same code cannot authorize a second transfer). - The source treasury is checked out (
checkout_kind='OUTBOUND') for the transfer's duration, exactly like consolidation. - 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.
- Track status from the outbound transfers list once GET is built:
pending → delivered → settled(orfailed, mapped from the internaloutbound_transfersstate 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.