> ## Documentation Index
> Fetch the complete documentation index at: https://jephalabs.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Signer Protocol

> Signer interface — AWS KMS, local key, and remote signer implementations

# Signer Protocol

> This document covers the **entire signer infrastructure** of the QTG v3 movement orchestration framework.
> It includes the protocol interface, data model, implementations, binding resolution, the signing flow inside dispatch,
> registry/health synchronization, CCTP caller-address validation, the local AWS KMS signer, and the remote proxy.

**Source of truth**:

* `src/qtg/domain/protocols.py` — SignerProtocol, SignRequest, SignResult, SigningIntent
* `src/qtg/infrastructure/signers/` — `noop`, `aws_kms_evm`, `remote_proxy`, `local_private_key_evm`, `registry`
* `src/qtg/application/services/dispatch.py` — signing flow
* `src/qtg/application/services/registry.py` — health sync
* `src/qtg/application/services/signer_metadata.py` — caller verification
* `src/qtg/infrastructure/db/models.py` — SignerRegistryEntry

## Current snapshot

* There are currently **four** signer implementations.
  1. `NoopSigner`
  2. `AwsKmsEvmSigner` — default EVM / KMS path
  3. `RemoteSignerProxy`
  4. `LocalPrivateKeySignerEvm` — development-only (Tier-3, key in process memory)
* The current default path for the EVM signer-required proving lane (CCTP, Gateway) is **`AwsKmsEvmSigner`**.
* `RemoteSignerProxy` is still supported, but according to the current repository docs the production-readiness path is the local AWS KMS signer.
* `LocalPrivateKeySignerEvm` is for local evaluation / dev only — see [local-private-key-signer.md](/reference/signers/local-private-key-signer). Do not use it for production fund movement.

***

## 1. SignerProtocol Interface

`@runtime_checkable` protocol defined in `domain/protocols.py`.

```python theme={null}
@runtime_checkable
class SignerProtocol(Protocol):
    signer_key: str

    async def sign(self, request: SignRequest) -> SignResult: ...
    async def health(self) -> dict[str, Any] | None: ...
```

### Method description

| Method          | Role                                                      | Return           |
| --------------- | --------------------------------------------------------- | ---------------- |
| `sign(request)` | Receives a signing request and returns the signing result | `SignResult`     |
| `health()`      | Returns signer status and metadata                        | `dict` or `None` |

### Core properties

* **`signer_key`**: unique identifier inside the registry. Referenced from the node's `signer_binding` dict.
* **`runtime_checkable`**: type checks are possible with `isinstance(obj, SignerProtocol)`.

### health() contract

For an EVM signer, `health()` may include a `signer_address` value. Registry health refresh validates and normalizes it, then writes it only to `observed_signer_address`; it is not the operator-pinned identity and is not copied into capabilities.

```python theme={null}
{
    "ok": True,
    "signer_key": "signer.cctp.mainnet",
    "signer_address": "0x1234567890abcdef1234567890abcdef12345678",
    "chain_families": ["evm"],
    "version": "1.2.0"
}
```

### Optional `SignerIdentityProtocol`

EVM signers that support operator enrollment also implement the optional, runtime-checkable `SignerIdentityProtocol`:

```python theme={null}
@runtime_checkable
class SignerIdentityProtocol(Protocol):
    async def resolve_signer_identity(self, *, fresh: bool) -> str | None: ...
```

The enrollment endpoint resolves this value with `fresh=True`, requires a nonzero canonical EVM address, and compares it with the submitted expected address. KMS and identity-capable remote adapters may implement the protocol; signer-free and non-EVM adapters simply do not, so identity enrollment is unavailable rather than guessed.

***

## 2. SigningIntent

This is the **signing-intent declaration** passed to the signer. It allows the signer to verify in a human-readable form "what is being signed."

```python theme={null}
class SigningIntent(BaseModel):
    action: str
    asset: str
    amount: str
    destination: str
    max_fee_usd: str | None = None
    chain_family: str
    allowed_payload_hash: str
```

### Field details

| Field                  | Type          | Source                                                             | Description                          |
| ---------------------- | ------------- | ------------------------------------------------------------------ | ------------------------------------ |
| `action`               | `str`         | `PreparedAction.action_type`                                       | action type selected by the executor |
| `asset`                | `str`         | `ExecutionContext.intent["asset"]`                                 | identifier of the asset being moved  |
| `amount`               | `str`         | `ExecutionContext.intent["amount"]` or `input_params["amount"]`    | transfer amount                      |
| `destination`          | `str`         | `ExecutionContext.intent["destination"]`                           | destination address or identifier    |
| `max_fee_usd`          | `str \| None` | currently always `None`                                            | maximum fee in USD terms             |
| `chain_family`         | `str`         | `ExecutionContext.input_params["chain_family"]` (default: `"evm"`) | chain family being signed for        |
| `allowed_payload_hash` | `str`         | `PreparedAction.payload_hash`                                      | hash of the payload being signed     |

### Security role of intent

`SigningIntent` exists to prevent **blind signing**. The signer should compare at least the following two:

1. the intent's `action`, `asset`, `amount`, `destination`
2. `allowed_payload_hash` and the actual payload hash

***

## 3. SignRequest

This is the full signing request passed to the signer by combining the `PreparedAction` returned from `executor.prepare()` with `ExecutionContext`.

```python theme={null}
class SignRequest(BaseModel):
    protocol_version: str
    request_id: str
    request_node_id: str
    signing_intent: SigningIntent
    payload_format: str
    payload: str
    payload_hash: str
    metadata: dict[str, Any] = Field(default_factory=dict)
```

### Field details

| Field              | Type            | Source                              | Description               |
| ------------------ | --------------- | ----------------------------------- | ------------------------- |
| `protocol_version` | `str`           | `ExecutionContext.protocol_version` | v3 protocol version       |
| `request_id`       | `str`           | `ExecutionContext.request_id`       | movement request UUID     |
| `request_node_id`  | `str`           | `ExecutionContext.request_node_id`  | request node UUID         |
| `signing_intent`   | `SigningIntent` | assembled in dispatch               | see the section above     |
| `payload_format`   | `str`           | `PreparedAction.payload_format`     | payload format identifier |
| `payload`          | `str`           | `PreparedAction.payload`            | raw payload being signed  |
| `payload_hash`     | `str`           | `PreparedAction.payload_hash`       | `sha256:...` hash         |
| `metadata`         | `dict`          | `ExecutionContext.input_params`     | additional context        |

***

## 4. SignResult

Signing result returned by the signer.

```python theme={null}
class SignResult(BaseModel):
    signature: str
    signature_format: str
    signer_ref: str
```

### Field details

| Field              | Type  | Description                                                                                                           |
| ------------------ | ----- | --------------------------------------------------------------------------------------------------------------------- |
| `signature`        | `str` | signing result. For `evm_raw_tx`, the signed raw transaction hex; for `eip712_compact`, the compact EIP-712 signature |
| `signature_format` | `str` | signing format identifier. Example: `evm_raw_tx`, `eip712_compact`, `noop`                                            |
| `signer_ref`       | `str` | signer-side reference ID                                                                                              |

### signature\_format values

`AwsKmsEvmSigner` and `LocalPrivateKeySignerEvm` both produce two distinct formats depending on what is being signed:

| signature\_format | Used for                                           | Submit-side consumer                                                                                                    |
| ----------------- | -------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
| `evm_raw_tx`      | Signed EVM raw transaction (legacy tx) broadcast   | CCTP burn/mint, EVM erc20\_transfer, USDT0/Stargate send, Gateway deposit/mint — RLP-decoded + recipient/target guarded |
| `eip712_compact`  | Gateway burn-intent (EIP-712 typed-data) signature | `exec.gateway.intent` — digest re-hashed and compared (`EIP712_DIGEST_MISMATCH` on mismatch)                            |
| `noop`            | No-op signer (CEX / dry-run lanes)                 | not broadcast                                                                                                           |

### Use in executors

In `dispatch.py`, `sign_result` is serialized to JSON and passed to `executor.submit()`.
The CCTP burn/mint executors allow only signed raw transactions where `signature_format == "evm_raw_tx"`; the Gateway intent executor consumes the `eip712_compact` form.

***

## 5. NoopSigner

Implementation for tests and signer-free lane bindings.

```python theme={null}
class NoopSigner:
    def __init__(self, *, signer_key: str) -> None:
        self.signer_key = signer_key
```

### When it is used

| Scenario | Description                                                          |
| -------- | -------------------------------------------------------------------- |
| Test     | dummy implementation to satisfy signer binding                       |
| CEX lane | on-chain signer is unnecessary because exchange API key auth is used |
| Dry-run  | checks the full flow without signer infrastructure                   |

***

## 6. AwsKmsEvmSigner

Current default implementation for the EVM signer-required lane.

### Role

* Derives the signer EOA from an AWS KMS `ECC_SECG_P256K1` key.
* Exposes `signer_address` from `health()`.
* Validates the payload hash in `sign()` and signs the EVM raw transaction.
* Follows the current v1 architecture that reuses the same signer EOA across multiple EVM chains.

### Required env

* `MG_LOCAL_SIGNER_BACKEND=aws_kms_evm` — **load-bearing** (selects the KMS backend)
* `MG_LOCAL_SIGNER_KMS_KEY_ID` — **load-bearing** (the KMS key to derive the EOA from)
* `MG_LOCAL_SIGNER_AWS_REGION` — **load-bearing**
* `MG_LOCAL_SIGNER_KEY` — the registry `signer_key` **label** for this signer. Optional for KMS readiness (it names the registry row, it is not key material). If set on a Stage 3 KMS-only path it can become a signer-runtime blocker; when unset it is not required.
* `MG_LOCAL_SIGNER_AWS_PROFILE` (optional — AWS SDK default credential chain is used when absent)
* `MG_LOCAL_SIGNER_AWS_ENDPOINT_URL` (optional)
* `MG_LOCAL_SIGNER_TIMEOUT_SECONDS`

<Note>
  `MG_LOCAL_SIGNER_KEY` is a registry label, not a secret. The cryptographic key material never leaves AWS KMS — `KMS_KEY_ID`, `AWS_REGION`, and `BACKEND` are the load-bearing values.
</Note>

### Bootstrap rules

* `bootstrap_runtime()` automatically registers `AwsKmsEvmSigner` when local signer env vars are configured.
* Bootstrap fails if `MG_LOCAL_SIGNER_BACKEND` is not set to `aws_kms_evm`.
* Bootstrap fails if the local signer and remote signer share the same `signer_key`.

### health example

```python theme={null}
{
    "ok": True,
    "signer_key": "signer.local.kms.evm.staging",
    "signer_address": "0x1234567890abcdef1234567890abcdef12345678",
    "chain_families": ["evm"],
    "backend": "aws_kms",
    "kms_key_id": "alias/qtg/staging/evm"
}
```

### Operational references

* preflight: `uv run python -m qtg.interfaces.tools.cctp_live_preflight`
* retained TESTNET one-shot:

```bash theme={null}
QTG_CCTP_LIVE_E2E=1 uv run python -m qtg.interfaces.tools.cctp_live_e2e \
  --template-key cctp.single_lane_usdc \
  --template-version <approved-version> \
  --checkpoint-path <new-local-packet-path> \
  --i-understand-this-can-sign-and-broadcast
```

The one-shot additionally requires an AWS KMS signer, the durable TESTNET
database, and fresh human approval for the exact run. The command and this
document do not grant live authority; pytest cannot arm CCTP signing or
broadcast.

***

## 7. RemoteSignerProxy

Proxy implementation that delegates over HTTP to a remote signer service.

### Current position

* The code remains in the repository
* Bootstrap is still possible (`MG_REMOTE_SIGNER_*`)
* However, it is not the default EVM signer path in the current repository docs

### Constructor

```python theme={null}
class RemoteSignerProxy:
    def __init__(
        self,
        *,
        signer_key: str,
        base_url: str,
        auth_token: str | None = None,
        timeout: float = 30.0,
        client: httpx.AsyncClient | None = None,
    ) -> None:
```

| Parameter    | Description                    |
| ------------ | ------------------------------ |
| `signer_key` | registry unique key            |
| `base_url`   | remote signer service base URL |
| `auth_token` | Bearer token                   |
| `timeout`    | HTTP timeout                   |
| `client`     | injected client for tests      |

### Error mapping

The current implementation does not raise raw `httpx` exceptions directly. It converts them into `MovementError` variants.
This allows the dispatch signing phase to handle the node as `FAILED` without crashing the worker.

***

## 7b. LocalPrivateKeySignerEvm

Development-only signer (`MG_LOCAL_SIGNER_BACKEND=local_private_key_evm`) that holds the EVM private key in process memory. Produces the same `evm_raw_tx` and `eip712_compact` signature formats and the same lowercase `signer_address` derivation as `AwsKmsEvmSigner`, so the proving lane and the signed-recipient guards behave identically.

* Key source backends: `op` (1Password CLI, recommended Tier-3) or `plain` (env var, dev only).
* Applies process hygiene at bootstrap (`prctl(PR_SET_DUMPABLE=0)` on Linux, `setrlimit(RLIMIT_CORE, 0)` on Linux + macOS) and fails closed if hygiene cannot be applied.
* **Not for production fund movement.** The AWS-KMS-only operator preflights do not cover it.

Full operator reference: [local-private-key-signer.md](/reference/signers/local-private-key-signer).

***

## 8. Registry / caller verification

* `GET /v3/signers` exposes `expected_signer_address`, `observed_signer_address`, and the derived `identity_state` separately for EVM rows; non-EVM rows report identity as not applicable.
* `PUT /v3/signers/{signer_key}/expected-identity` is the sole enrollment writer for `expected_signer_address`; it uses a reasoned compare-and-set request and a fresh `SignerIdentityProtocol` observation.
* Health refresh is the sole writer for `observed_signer_address`. It clears that value when a signer becomes orphaned, so stale runtime evidence is never presented as current.
* `PATCH /v3/signers/{signer_key}` can switch the signer state between `active` and `disabled`.
* CCTP burn dispatch precheck and mint preflight cross-check the signer address against `destination_caller`; a mismatch fails closed as `SIGNER_CALLER_MISMATCH`.
* This contract does not add movement-time or rotation-time drift enforcement; that boundary is a separate follow-up.

***

## 9. Practical getting-started

1. Uncomment the `MG_LOCAL_SIGNER_*` and `MG_EVM_RPC_ENDPOINTS_JSON` sections in `.env.example` and fill in the values.
2. Run preflight:

```bash theme={null}
uv run python -m qtg.interfaces.tools.cctp_live_preflight
```

3. Run the retained TESTNET one-shot only after fresh human approval:

```bash theme={null}
QTG_CCTP_LIVE_E2E=1 uv run python -m qtg.interfaces.tools.cctp_live_e2e \
  --template-key cctp.single_lane_usdc \
  --template-version <approved-version> \
  --checkpoint-path <new-local-packet-path> \
  --i-understand-this-can-sign-and-broadcast
```

The environment flag and explicit acknowledgement are both required software
gates, but neither grants live authority. A fresh reviewed human approval is
still required for the exact run.

At the current point, the project already has **preflight / retained CLI /
runbook + a successful staged testnet E2E record**.
The main remaining follow-ups are protocol signature verification, stronger registry-driven binding resolution, and operator audit-trail cleanup.
