Executor & Signer Protocols
Source of truth:src/qtg/domain/protocols.pyThis document describesExecutorProtocolandSignerProtocol, the core execution interfaces of the v3 movement orchestration framework, along with their related dataclasses. All executor/signer implementations must follow this protocol.
Table of Contents
- Overview
- ExecutorProtocol
- Executor Dataclasses
- Executor execution lifecycle
- SignerProtocol
- Signer Dataclasses
- Probe Protocols
- Registry pattern
- Binding Resolution
- Implementation categories
Overview
The v3 framework uses a structural typing Protocol pattern. The@runtime_checkable decorator is applied so isinstance() checks are
possible, but actual registration happens through the in-memory dict in the
registry module.
pydantic.BaseModel, so JSON
serialization/deserialization is straightforward.
This allows local executors and remote executors (HTTP proxy) to operate under
the same contract.
ExecutorProtocol
submit() and recover() receive the dispatcher’s live AsyncSession, so an executor can read or write inside the same transaction that commits the node state. Executors that do not need it accept and ignore it.
Required attributes
Method details
preflight(context) -> ExecutionResult | None
When called: invoked during dispatch, before prepare(). Called immediately after the node transitions READY -> PREPARING.
Purpose: validate preconditions before execution. Checks include exchange whitelist address, withdrawal availability, balance, wallet service status, and so on.
Return value:
None— all preconditions met. The dispatcher proceeds to theprepare()stage.ExecutionResult— precondition failure.next_statebecomes the node’s new state (usually"FAILED"). The dispatcher commits and exits immediately.
check_withdrawal_available(), min/max amount validation, and check_wallet_service(). If any of these fails, it returns a result such as ExecutionResult(next_state="FAILED", error_code="ADDRESS_NOT_WHITELISTED").
Special note: for CCTP burn, the dispatcher calls validate_cctp_burn_caller_alignment() first. If that returns a non-None result, the executor’s preflight() is skipped.
prepare(context) -> ExecutionResult
When called: after preflight() returns None.
Purpose: construct the transaction payload to be submitted. Does not yet cause any side effect on external systems.
Return value: must return ExecutionResult with PreparedAction included in the prepared_action field.
Failure handling: if prepared_action is None, the dispatcher transitions the node to FAILED.
Artifact persistence: the dispatcher stores the returned PreparedAction in the MovementArtifact table with artifact_type='prepared_action'. artifact_digest uses the value of prepared_action.payload_hash.
CEX example:
submit(context, prepared_action, **kwargs) -> ExecutionResult
When called: after prepare() completes. When signing is required (prepared_action.signing_required == True), the signer is invoked first and the signature result is then passed as sign_result in kwargs.
Purpose: submit the actual transaction to the external system. Side effects begin at this point.
Arguments:
context— execution context for the current nodeprepared_action— the action payload produced byprepare()session— the dispatcher’sAsyncSession, for executors that must read or write in the same transaction**kwargs— additional data such as the signature result. When signing is required, it includessign_result: dict.
pre_submit_check(context, session) hook: if the executor (or its _handler) defines this coroutine, the dispatcher awaits it immediately before submit() — inside the same transaction, after the node is already SUBMITTING. It is the last gate before a side effect, and it can hold a row lock across the submit. Raising TemporaryMovementError retries the node (PRE_SUBMIT_CHECK_TEMPORARY_ERROR); FatalMovementError or any other exception fails it and propagates (PRE_SUBMIT_CHECK_FATAL_ERROR / PRE_SUBMIT_CHECK_UNEXPECTED_ERROR). Used by the CCIP and Stargate send lanes.
Return value: ExecutionResult
next_state: typically"COMPLETED"(synchronous completion) or"SUBMITTED"(asynchronous, observation required)provider_refs: exchange/chain reference IDs. Example:{"exchange_withdrawal_id": "uuid-xxx"}provider_state: the state string on the exchange/protocol side
- network timeout ->
next_state="UNKNOWN"(recover target) - business error ->
next_state="FAILED"(terminal)
provider_refs, provider_state, and generated_artifacts are stored on the node. If next_state is COMPLETED, successor nodes are unblocked; if FAILED/UNKNOWN, the request state is derived accordingly.
observe(context) -> ExecutionResult
When called: while the node is in SUBMITTED or OBSERVING state, called periodically by the observe worker.
Purpose: poll the progress of asynchronous work. Examples include exchange withdrawal/deposit state checks, chain transaction checks, and protocol completion checks.
Return value: ExecutionResult
next_state="COMPLETED"— work completed. The observer unblocks successor nodes.next_state="OBSERVING"— still in progress. The next polling time is determined byretry_after_seconds.next_state="FAILED"— work confirmed as failed.next_state="UNKNOWN"— state cannot be determined. Transitioned to the recover target.
result.retry_after_seconds when set; otherwise it uses the per-action_type default from DEFAULT_OBSERVE_INTERVALS:
recover(context) -> ExecutionResult
When called: while the node is in UNKNOWN state, called by the recovery worker.
Purpose: recover from an uncertain state. For example, when a timeout prevented confirmation of submission, query the exchange API again to check whether submission actually occurred.
Return value: ExecutionResult
next_state="SUBMITTED"— submission confirmed. Transition to observe.next_state="COMPLETED"— already completed.next_state="FAILED"— recovery not possible.
provider_context contains exchange_withdrawal_id, the submission is judged to have occurred and COMPLETED is returned. Otherwise FAILED is returned.
health() -> dict[str, Any] | None
When called: at the health check endpoint or during system diagnostics.
Purpose: check the availability of the executor implementation.
Return value: a state dict (e.g. {"ok": True, "executor_key": "exec.cex.withdrawal_action", "mode": "local"}) or None.
close() (informal)
Not included in the Protocol definition, but both LocalExecutorAdapter and RemoteExecutorProxy implement a close() method. It is used for resource cleanup (closing the HTTP client, etc.).
Executor Dataclasses
ExecutionContext
An immutable snapshot containing all context information for the currently executing node. Built by thebuild_execution_context() function from the DB.
How
provider_context is constructed: build_execution_context() collects provider_refs from COMPLETED direct predecessors of the current node and merges them into a single flat dict. This lets successor nodes (observe) reference the predecessor node’s (action) exchange ID or txid.
For template-config $ref: resolution, the runtime builds a separate synthetic namespaced ref map ({node_key}.{field}), but that value is not exposed to the executor. In other words, the executor contract remains a flat provider_context plus an already-resolved node_config.
ExecutionResult
The unified return type of executor methods. All methods (preflight, prepare, submit, observe, recover) return this type.
PreparedAction
The immutable snapshot of the transaction to be submitted, produced in theprepare() stage.
signing_required behavior flow:
SigningIntent
An intent declaration included in the signing request. Human-readable metadata that lets the signer check “what is being signed”.ArtifactRef / GeneratedArtifact
MovementArtifact table via persist_generated_artifacts().
Executor execution lifecycle
The full node execution flow, organized with state transitions.Dispatcher (dispatch.py) processing order
- Acquire one
READYnode viaSELECT ... FOR UPDATE SKIP LOCKED - If the request is
APPROVED, transition it toEXECUTING - Transition the node to
PREPARING - Call
preflight()— if non-None, transition state and exit immediately - Call
prepare()— ifprepared_actionisNone, setFAILED - Persist
PreparedActiontoMovementArtifact - If
signing_required: transition toAWAITING_SIGNATURE-> look up signer -> buildSignRequest-> callsigner.sign() - Transition to
SUBMITTING, recordstarted_at/attempt_no, commit - Call
submit() - Store
provider_state,provider_refs,provider_ref_id - Transition the node per
next_stateand derive request state
Observer (observe.py) processing order
- Acquire up to 10
SUBMITTED/OBSERVINGnodes (skip locked) - Skip if
next_observe_athas not yet arrived build_execution_context()-> callexecutor.observe()- Store
provider_state,provider_refs, proof, artifacts - If
COMPLETED, unblock successors (advance_after_completion) - If
OBSERVING, compute the next polling time - If
FAILED/UNKNOWN, derive request state
Recovery (recover.py) processing order
- Acquire up to 10
UNKNOWNnodes (skip locked) build_execution_context()-> callexecutor.recover()- Store
provider_state,provider_refs, proof, artifacts - If
FAILED, derive request state
SignerProtocol
Required attributes
Method details
sign(request: SignRequest) -> SignResult
When called: when the dispatcher sees prepared_action.signing_required == True and the node has a signer_binding.
Purpose: signs the transaction payload. The signer can confirm the meaning of what is being signed via SigningIntent.
Security contract: the signer must verify that signing_intent.allowed_payload_hash matches request.payload_hash. On mismatch it must refuse to sign.
health() -> dict[str, Any] | None
Checks signer availability. For remote signers, calls the HTTP health endpoint.
Signer Dataclasses
SignRequest
Construction example in the dispatcher (
dispatch.py):
SignResult
The signature result is passed to
executor.submit() as the sign_result key in kwargs, in the form sign_result.model_dump(mode='json').
Probe Protocols
Beyond executors, three additional probe protocols are defined. These are auxiliary interfaces used inside observe executors.ChainReceiveProbe
match_mode specifies the matching strategy (e.g. "txid", "address_amount"), and targets carries the matching conditions.
ChainReceiveProbeResult:
ChainFinalityProbe
ProtocolProbe
Registry pattern
Both Executor and Signer use the same in-memory registry pattern.Executor Registry
Source: src/qtg/infrastructure/executors/registry.py
Signer Registry
Source: src/qtg/infrastructure/signers/registry.py
Registry API summary
Registry initialization timing
During the bootstrap stage (infrastructure/bootstrap.py), all executor/signer implementations are constructed and registered in the registry. Afterwards, the dispatch/observe/recover workers retrieve them via get_executor() / get_signer().
Binding Resolution
A node’sexecutor_binding and signer_binding can take two forms:
1. String binding
2. Dictionary binding (Dict / Mapping)
binding["executor_key"] is used to look up the registry. The remaining fields can be referenced inside the executor.
Signer binding resolution
How the dispatcher finds the signer:signer_binding is None and signing_required is True, the dispatcher transitions the node to FAILED and records "missing signer binding" in detail.
Implementation categories
Executor implementations
For the full key list, gating, and bootstrap order, see executors/overview.md § 5.2 / § 7.
Signer implementations
Remote Signer HTTP Protocol
The remote signer HTTP interface used byRemoteSignerProxy:
POST /sign
- Request:
SignRequestJSON body - Response:
SignResultJSON body - Auth:
Authorization: Bearer {token}(optional)
- Response: signer state JSON
Remote Executor HTTP Protocol
The remote executor HTTP interface used byRemoteExecutorProxy:
A 204 response from
preflight means “precondition passed” (equivalent to returning None).
Cross-References
- State transition rules: domain/states-and-transitions.md
- Type definitions: domain/types-and-enums.md
- Error taxonomy: domain/error-taxonomy.md
- Plan compilation: compiler/plan-compilation.md
- CEX executor details: executors/cex-lane.md
- CCTP executor details: executors/cctp-lane.md