Skip to main content

State Machine


Why do we need a state machine?

Analogy: package delivery tracking

When you ship a package, it goes through states like this:
Without this state tracking, you are left wondering “where did my package go?” Packages are not money, so in the worst case you can just resend — but crypto transfers mean if you lose track in the middle, the money is gone. Cross-exchange transfers also go through multiple stages:
What a state machine does:
  1. Precisely tracks “how far along we are”
  2. Blocks out-of-order progression at the code level
  3. Determines the recovery path by knowing the exact state when something goes wrong
Why a “strict” state machine? Being free to change state arbitrarily would be convenient, but in a system handling real money, only permitted transitions should be possible. For example, going from COMPLETED back to EXECUTING makes no sense — re-executing a completed transfer would mean a double-send.

QTG v3’s two-level state model

QTG v3 has two levels of state: This two-level structure is the core of v3. v2 had only Request-level state, but in v3 a transfer route is a graph composed of multiple nodes (steps), so each node needs its own independent state.
Request state is not set directly — it is “derived” by aggregating all Node states. The request state is always a function of the current node states, never written ad hoc.

Request state (RequestState)

Full transition graph

State descriptions

Terminal state vs recoverable state

Terminal states have no allowed outgoing transitions — once a request reaches COMPLETED, FAILED, REJECTED, EXPIRED, or CANCELLED, it cannot move anywhere else.
Why MANUAL_INTERVENTION exists: Consider a CCTP scenario. You burned USDC on Ethereum (= the money was burned), but the mint on Arbitrum failed. The money is already burned — you can’t simply end it as FAILED. A human must intervene to complete the mint manually or contact Circle.The same is true in a CEX scenario. Upbit completed the withdrawal (= the money already left), but the deposit on Bithumb is not confirmed. Automatically marking it FAILED is wrong — the money may still be floating on the blockchain.

Node state (NodeState)

Full transition graph

Both routes into UNKNOWN are deliberate. A side effect that becomes ambiguous after submission — observe() raises, or returns UNKNOWN — must be able to enter recovery without passing through OBSERVING first. The observer picks up SUBMITTED rows directly and does not pre-flip them, so SUBMITTED → UNKNOWN is a real lifecycle edge, symmetric with SUBMITTING → UNKNOWN. Either way the recovery worker re-reconciles; nothing with money in flight is auto-terminalized.

State descriptions

How state progression differs between Action Node and Observe Node

State progression differs depending on the node type. The difference:
  • Action Node can pass through the AWAITING_SIGNATURE step (on-chain transactions require signing)
  • Observe Node typically loops through PREPARINGSUBMITTINGOBSERVING
  • Key point: Action Nodes have has_side_effect = true

Why UNKNOWN state is special

UNKNOWN = “We don’t know whether the money was sent or not” — this is the most dangerous node state.Example scenario:
  1. Called the Upbit withdrawal API
  2. Got no response due to a network timeout
  3. The withdrawal may or may not have actually been submitted
UNKNOWN is not terminal — it must be resolved to SUBMITTED or FAILED through reconciliation.
Where UNKNOWN can go (only via reconciliation):

Why has_side_effect matters

has_side_effect is declared per node in the template definition. For example, a CEX template’s nodes carry the flag like this:
The compiler propagates this flag onto each compiled node, so it travels with the plan. This flag plays a decisive role when the request state is derived:
Why this distinction matters:
  • withdraw node COMPLETED (money left) + deposit_observe node FAILED → money already left! → MANUAL_INTERVENTION
  • withdraw node FAILED (money never left) → safely FAILED
“Failed after money was already sent” and “failed before money was sent” are completely different situations.

State derivation logic

Request state is “calculated” by aggregating all node states — it is always a pure function of the current node states.

Derivation priority

Priority order:
  1. Everything completeCOMPLETED
  2. Failed/UNKNOWN + completed node with side effectMANUAL_INTERVENTION (most dangerous)
  3. Failed onlyFAILED (safe failure)
  4. Waiting at manual gateWAITING_MANUAL_ACTION
  5. Everything elseNone (still in progress, no change to Request state)
None means “it is not yet time to change the Request state.” The caller does nothing when it receives None. The Request stays in EXECUTING state.

Waking the next node

When a node reaches COMPLETED, what should happen next? Execution order:
  1. Find the successor nodes of the completed node
  2. If a successor exists:
    • Transition BLOCKEDREADY (wake it up)
    • Update current_frontier
    • Send frontier_advanced callback
  3. If no successor exists (last node):
    • Derive the Request state from all node states
What is current_frontier? It is “the list of currently executable nodes.” In the package delivery analogy, it is “the next hub this package needs to reach.” This information is sent as a callback to external systems (Dashboard, etc.) to show real-time progress.

Post-transition handling

Deriving the request state is responsible not only for the state transition but also for follow-up processing:
What is a Reservation? A “limit reservation” held when a transfer request is made in the hardcap reserve system. Consumed (consumed) on completion, returned (released) on safe failure, held until finalized when there are side effects.

Core safety mechanisms

Transition rule validation

Every transition is checked against an allowed-transitions set before it is applied — both for request state and for node state. A transition is only permitted if it is explicitly listed as a legal edge from the current state.
Attempting an unauthorized state transition: Any transition not in the allowed set is rejected. For example:
  • COMPLETED → EXECUTING : not allowed (something already finished cannot restart)
  • FAILED → READY : not allowed (a failed node cannot be directly awakened)
  • OBSERVING → PREPARING : not allowed (going backwards is not allowed)
Violating these rules raises an illegal-state-transition error at the upper layer rather than silently corrupting state.

Event audit trail

Every state transition is recorded as a movement event capturing the old state, the new state, and a detail payload. Who (actor type and id), when, and which state transitioned to which are all recorded. When something goes wrong, this event log lets you trace “what exactly happened.”

Summary: the power of the two-level state model

  • Node state fine-grained tracks the actual progress of each step
  • Request state is derived by aggregating Node states into the overall transfer status
  • The has_side_effect flag is the key that distinguishes safe failure vs dangerous failure