Skip to main content

Executor & Signer Protocols

Source of truth: src/qtg/domain/protocols.py This document describes ExecutorProtocol and SignerProtocol, 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

  1. Overview
  2. ExecutorProtocol
  3. Executor Dataclasses
  4. Executor execution lifecycle
  5. SignerProtocol
  6. Signer Dataclasses
  7. Probe Protocols
  8. Registry pattern
  9. Binding Resolution
  10. 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.
All dataclasses inherit from 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 the prepare() stage.
  • ExecutionResult — precondition failure. next_state becomes the node’s new state (usually "FAILED"). The dispatcher commits and exits immediately.
CEX withdrawal example: performs whitelist validation, 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 node
  • prepared_action — the action payload produced by prepare()
  • session — the dispatcher’s AsyncSession, for executors that must read or write in the same transaction
  • **kwargs — additional data such as the signature result. When signing is required, it includes sign_result: dict.
Optional 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
Error handling:
  • network timeout -> next_state="UNKNOWN" (recover target)
  • business error -> next_state="FAILED" (terminal)
Dispatcher post-processing: the submit result’s 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 by retry_after_seconds.
  • next_state="FAILED" — work confirmed as failed.
  • next_state="UNKNOWN" — state cannot be determined. Transitioned to the recover target.
Polling interval: the observe worker uses 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.
CEX example: if 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 the build_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 the prepare() 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

Generated artifacts are persisted to the MovementArtifact table via persist_generated_artifacts().

Executor execution lifecycle

The full node execution flow, organized with state transitions.

Dispatcher (dispatch.py) processing order

  1. Acquire one READY node via SELECT ... FOR UPDATE SKIP LOCKED
  2. If the request is APPROVED, transition it to EXECUTING
  3. Transition the node to PREPARING
  4. Call preflight() — if non-None, transition state and exit immediately
  5. Call prepare() — if prepared_action is None, set FAILED
  6. Persist PreparedAction to MovementArtifact
  7. If signing_required: transition to AWAITING_SIGNATURE -> look up signer -> build SignRequest -> call signer.sign()
  8. Transition to SUBMITTING, record started_at/attempt_no, commit
  9. Call submit()
  10. Store provider_state, provider_refs, provider_ref_id
  11. Transition the node per next_state and derive request state

Observer (observe.py) processing order

  1. Acquire up to 10 SUBMITTED/OBSERVING nodes (skip locked)
  2. Skip if next_observe_at has not yet arrived
  3. build_execution_context() -> call executor.observe()
  4. Store provider_state, provider_refs, proof, artifacts
  5. If COMPLETED, unblock successors (advance_after_completion)
  6. If OBSERVING, compute the next polling time
  7. If FAILED/UNKNOWN, derive request state

Recovery (recover.py) processing order

  1. Acquire up to 10 UNKNOWN nodes (skip locked)
  2. build_execution_context() -> call executor.recover()
  3. Store provider_state, provider_refs, proof, artifacts
  4. 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

Detects on-chain receive transactions. match_mode specifies the matching strategy (e.g. "txid", "address_amount"), and targets carries the matching conditions. ChainReceiveProbeResult:

ChainFinalityProbe

Checks the finality of a specific transaction. ChainFinalityProbeResult:

ProtocolProbe

Checks protocol-level completion evidence. CCTP attestation, bridge delivery, etc. ProtocolProbeResult:

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’s executor_binding and signer_binding can take two forms:

1. String binding

Looked up directly in the registry under this key.

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:
If 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 by RemoteSignerProxy: POST /sign
  • Request: SignRequest JSON body
  • Response: SignResult JSON body
  • Auth: Authorization: Bearer {token} (optional)
GET /health
  • Response: signer state JSON

Remote Executor HTTP Protocol

The remote executor HTTP interface used by RemoteExecutorProxy: A 204 response from preflight means “precondition passed” (equivalent to returning None).

Cross-References