Plan Compilation
What is a Plan?
Plan = a blueprint that defines “what to do and in what order” ahead of time. Think of it like an international travel itinerary:- Departure: Incheon Airport (= Upbit)
- Layover: Singapore transit (= CCTP Attestation)
- Destination: Sydney, Australia (= Base Chain)
- Transport for each leg: Korean Air ICN–SIN, Qantas SIN–SYD (= CEX Executor, CCTP Executor)
A Template is a reusable blueprint. Once you create a plan template like “Send XRP from Upbit to Bithumb,” you can stamp out requests from that same template — whether for 25 XRP or 100 XRP.
Why do we “compile” a Plan?
A Template is abstract. It contains variables and no concrete amounts or addresses. Compilation is the process of turning it into a concrete execution plan. Analogy: C source code → compiler → executable. Plan Template → Plan Compiler → executable Request. Four things happen sequentially during compilation:- Graph validation — confirms the plan is in an executable form
- Binding resolution — determines which executor/signer to attach to each node
- Snapshot creation — merges all information into a single immutable document
- Hash computation — generates a fingerprint of the snapshot
executable_in_v3_0 flag is checked.
Graph Structure
A Plan is a DAG
A Plan is a DAG (Directed Acyclic Graph) — a directed graph with no cycles.- Node = a unit of execution. Things like “withdraw XRP from Upbit,” “poll withdrawal status,” “confirm Bithumb deposit.”
- Edge = a dependency. “Withdrawal must complete before withdrawal confirmation can start.”
Why must it be a DAG?
A cycle causes an infinite loop. “A must finish before B, and B must finish before A…” — you can never start. In a system handling real money, an infinite loop is catastrophic, so cycles are blocked at compile time.Graph Validation
Validation runs in 6 steps. Failure at any step throws aGraphValidationError and the template registration is rejected outright.
A closer look at each step
1. Duplicate node_key check If two nodes share the same name, it becomes ambiguous which node to execute — duplicatenode_keys are rejected.
2. Minimum node count check
A plan with zero nodes does nothing. Meaningless, so it’s rejected.
3. Edge reference integrity
If an edge says {from: "withdrawal", to: "deposit_check"} but no node named deposit_check exists, execution will fail with “can’t find next node.”
4. Self-loop prohibition
{from: "A", to: "A"} — depending on yourself makes no sense.
5. DAG cycle detection (Kahn’s Algorithm)
This is the most critical validation step. Kahn’s Algorithm performs a topological sort while simultaneously detecting cycles.
Simply put: “Remove nodes one by one, starting with those that have no prerequisites. If all nodes can be removed, there are no cycles. If some nodes remain that can’t be removed, there’s a cycle.”
Mechanically: start with every node whose indegree is 0, repeatedly remove a node and decrement its children’s indegree, enqueuing any child that reaches indegree 0. If every node gets processed this way the graph is a DAG; if some nodes can never be reached, a cycle exists and the template is rejected.
The order in which nodes come off the queue is the topological sort result, which also becomes the basis for execution order.
6. completion_policy validation
Verifies that the criteria for declaring a plan “complete” are correct.
Each completion-assurance level requires a matching terminal-node family:
Examples:
destination_credited: “complete when deposit is confirmed at the destination exchange” → the terminal node must be from thedestination_provider_observefamilydestination_finalized: “complete when N confirmations accumulate on the destination chain” → the terminal node must bedestination_chain_finality_observe, andconfig.confirmations_requiredmust be setprotocol_finalized: “complete when confirmed at the protocol level (e.g., CCTP attestation)” → the terminal node must be from theprotocol_observefamily
compiled_plan_hash
Why is a hash needed?
Analogy: detecting tampering after signing a contract. An operator approved a plan to “send 25 XRP from Upbit to Bithumb.” But what if someone swaps the plan to “2500 XRP” between approval and execution?compiled_plan_hash prevents this.
Approval flow:
compiled_plan_hashis computed at request creation → stored in DB- Operator sends
compiled_plan_hashalong with the approval request - Server compares it against the stored hash → mismatches are rejected
How is it computed?
Hash computation follows this procedure: Key points:- Sorting is mandatory: the same content in a different order produces a different hash. Nodes are sorted by
node_key; edges by(from, to, edge_type). - Fixed separators:
(",", ":")with no spaces —{"a":1}not{"a": 1} - ensure_ascii=False: Korean and other Unicode characters may appear — keep them as-is
- Result format:
"sha256:a1b2c3d4..."— the prefix explicitly identifies the algorithm
Compiled Snapshot
A snapshot is an immutable document. Once created it never changes, and it serves as the source of truth for the hash. The snapshot bundles everything needed to describe one concrete execution plan into a single object, with these fields:schema_version— snapshot schema version (e.g."v3.0")template_key/template_version— which template and version this was compiled fromcompletion_policy— the criteria for declaring the plan completenodes/edges— the validated graphinput_params— the concrete values for this instance (asset, amount, addresses)resolved_bindings— the executor/signer attached to each noderisk_controls— caps and guardscallback_config— where to send status callbacks
Binding Resolution
This is the process of deciding “which executor and signer to attach to each node.” In the current v3.0 it is straightforward — theexecutor_selector and signer_selector already specified on each node in the template are carried through as-is, producing a per-node map of {executor, signer}.
Why is this so simple?
The current behavior starts with explicit assignment. In the future, capability-based dynamic resolution — e.g.,
executor_selector: {"capabilities": ["cex", "withdrawal"], "prefer": "lowest_fee"} — may be added.Frontier Computation
Frontier = “the list of nodes that can be executed right now.” These are nodes that are not the successor of any other node — the root nodes. Simple logic: “nodes not referenced as theto of any edge = starting nodes.”
In the CEX 3-node lane, only withdrawal is the frontier. Once approved, this node transitions from BLOCKED → READY and execution begins.
GraphShape
Validation classifies the “shape” of the graph:
Classification logic, in plain terms:
- A node with more than one outgoing edge means the graph branches —
BRANCHINGif those edges carry conditions, otherwiseSPLIT. - A node with more than one incoming edge means the graph merges (
MERGING). - Both branching and merging →
HYBRID. - Neither →
LINEAR.
v3.0 only executes LINEAR
The data model accepts DAGs, but the runtime only supports sequential execution. You can register non-LINEAR templates (for future use), but attempting to create a request from a template that is not marked
executable_in_v3_0 is rejected. non_executable_reasons records the specific reason (e.g., "graph shape 'branching' requires graph runtime").