> ## 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.

# CCTP Lane

> Circle CCTP bridge executor — burn, attestation, and mint flow

# CCTP Lane Executors

> **Source files**
>
> * `src/qtg/infrastructure/executors/cctp/__init__.py`
> * `src/qtg/infrastructure/executors/cctp/burn.py`
> * \`src/qtg/infrastructure/executors/cctp/mint.py>
> * `src/qtg/infrastructure/executors/cctp/common.py`
> * `src/qtg/infrastructure/executors/cctp/api.py`
> * `src/qtg/infrastructure/executors/observe/cctp.py`
> * `src/qtg/infrastructure/executors/observe/evm.py`

***

## 1. CCTP 5-node lane overview

A cross-chain USDC transfer via Circle CCTP (Cross-Chain Transfer Protocol) v2 is built from 5 nodes.

**CCTP 완료 callback = destination에서 expected 주소/자산/금액 이상 수령 관찰 + finality confirmations.**

```mermaid theme={null}
flowchart LR
    N1["Node 1: cctp_burn<br/>source chain<br/>depositForBurn tx"]
    N2["Node 2: attestation<br/>action_type: protocol_observe<br/>IRIS API polling<br/>wait for attestation"]
    N3["Node 3: cctp_mint<br/>dest chain<br/>receiveMessage tx"]
    N4["Node 4: mint_receive_observe<br/>action_type: destination_chain_receive_observe<br/>destination USDC receipt"]
    N5["Node 5: mint_finality<br/>confirmation wait"]
    N1 --> N2 --> N3 --> N4 --> N5
    N1 -.- R1["provider_refs:<br/>burn_tx_hash<br/>txid<br/>source_domain_id<br/>protocol_ref<br/>destination_caller_bytes32<br/>min_finality_threshold"]
    N2 -.- R2["provider_refs:<br/>attestation<br/>message_bytes<br/>message_nonce<br/>attestation_hash<br/>burn_tx_hash<br/>source_domain_id"]
    N3 -.- R3["provider_refs:<br/>mint_tx_hash<br/>dest_txid<br/>txid"]
    N4 -.- R4["provider_refs:<br/>txid<br/>destination_address<br/>proof: observed_amount"]
    N5 -.- R5["provider_refs:<br/>txid<br/>proof: confirmations"]
```

### Executor key mapping

| Node | executor\_key                             | Class                                                               | Kind    |
| ---- | ----------------------------------------- | ------------------------------------------------------------------- | ------- |
| 1    | `exec.cctp.burn`                          | `CctpBurnExecutor`                                                  | action  |
| 2    | `exec.observe.protocol`                   | `ProtocolObserveExecutor` + `CctpAttestationProbe`                  | observe |
| 3    | `exec.cctp.mint`                          | `CctpMintExecutor`                                                  | action  |
| 4    | `exec.observe.destination_chain_receive`  | `DestinationChainReceiveObserveExecutor` + `EvmChainReceiveProbe`   | observe |
| 5    | `exec.observe.destination_chain_finality` | `DestinationChainFinalityObserveExecutor` + `EvmChainFinalityProbe` | observe |

Nodes 2, 4, and 5 are structured as a generic observe executor with a bound probe. The CCTP-specific code lives in the probe.

The descriptive node keys remain `attestation` and `mint_receive_observe`, but their node `action_type` values are `protocol_observe` and `destination_chain_receive_observe` respectively. The compiler requires every node `action_type` to exactly equal its `executor_selector.action_type`.

### Registration (bootstrap)

```python theme={null}
def register_builtin_cctp_executors(*, evm_endpoints, cctp_base_url, cctp_timeout_seconds):
    rpc_client = EvmJsonRpcClient(timeout_seconds=cctp_timeout_seconds)
    cctp_client = CircleCctpApiClient(base_url=cctp_base_url, timeout_seconds=cctp_timeout_seconds)
    register_executor(LocalExecutorAdapter(
        executor_key="exec.cctp.burn",
        handler=CctpBurnExecutor(evm_endpoints=evm_endpoints, rpc_client=rpc_client, cctp_client=cctp_client),
    ))
    register_executor(LocalExecutorAdapter(
        executor_key="exec.cctp.mint",
        handler=CctpMintExecutor(evm_endpoints=evm_endpoints, rpc_client=rpc_client),
    ))
```

`exec.observe.protocol`, `exec.observe.destination_chain_receive`, and `exec.observe.destination_chain_finality` are registered in `register_builtin_observe_executors()`, and the CCTP attestation probe is registered separately via `register_protocol_probe("cctp_iris", ...)`.

### Signed-recipient invariant (validated == signed)

In addition to the `destination_caller` triple-check (see §5.1), CCTP burn/mint enforce QTG's cross-lane **signed-recipient invariant**: the recipient and source re-derived from the **actual signed transaction** must equal the **allowlist-validated intent**, checked at prepare time (intent-only resolvers) and at submit/broadcast time (RLP-decode of the signed legacy tx). CCTP is a **strict source + destination** action (`cctp_burn` / `cctp_mint` in `ONCHAIN_ACTION_TYPES`), so both ends are validated.

Mismatch is **fail-closed** — `FatalMovementError` → node `FAILED`:

| Code                        | Trigger                                            |
| --------------------------- | -------------------------------------------------- |
| `SIGNED_RECIPIENT_MISMATCH` | signed recipient ≠ validated destination authority |
| `SIGNED_SOURCE_MISMATCH`    | signed source ≠ validated source authority         |

Enforcement: `src/qtg/infrastructure/executors/recipient_guard.py` + `src/qtg/infrastructure/executors/signed_evm_tx.py`.

***

## 2. Provider refs propagation chain (detail)

```mermaid theme={null}
flowchart TB
    A["cctp_burn.submit()<br/>provider_refs:<br/>txid: 0xabc...<br/>burn_tx_hash: 0xabc...<br/>protocol_ref: 0xabc...<br/>source_domain_id: 0<br/>min_finality_threshold: 1000<br/>destination_caller_bytes32: 0x000...caller"]
    B["attestation<br/>action_type: protocol_observe<br/>(ProtocolObserveExecutor + CctpAttestationProbe)<br/>provider_refs:<br/>protocol_ref: 0xabc...<br/>burn_tx_hash: 0xabc...<br/>attestation_hash: sha256:...<br/>attestation: 0x...(hex)<br/>message_bytes: 0x...(hex)<br/>message_nonce: 12345<br/>source_domain_id: 0<br/>generated_artifacts:<br/>[GeneratedArtifact(attestation, hex, ...)]"]
    C["cctp_mint.submit()<br/>provider_refs:<br/>txid: 0xdef...<br/>mint_tx_hash: 0xdef...<br/>dest_txid: 0xdef..."]
    D["mint_receive_observe<br/>action_type: destination_chain_receive_observe<br/>(EvmChainReceiveProbe)<br/>provider_refs:<br/>txid: 0xdef...<br/>destination_address: 0x...<br/>proof: observed_amount"]
    E["mint_finality (EvmChainFinalityProbe)<br/>provider_refs:<br/>txid: 0xdef...<br/>proof:<br/>confirmations: 65<br/>required_confirmations: 65"]
    A -->|ctx.provider_context| B
    B -->|ctx.provider_context| C
    C -->|ctx.provider_context| D
    D -->|ctx.provider_context| E
```

Key propagation paths:

| Field                           | burn -> attestation                      | attestation -> mint                         | mint -> receive                        | receive -> finality          |
| ------------------------------- | ---------------------------------------- | ------------------------------------------- | -------------------------------------- | ---------------------------- |
| `burn_tx_hash` / `protocol_ref` | `txid` = `burn_tx_hash` = `protocol_ref` | used by IRIS lookup keyed on `protocol_ref` | -                                      | -                            |
| `source_domain_id`              | set at burn                              | used by attestation IRIS lookup             | -                                      | -                            |
| `attestation`                   | -                                        | produced by probe                           | used in mint `receiveMessage` calldata | -                            |
| `message_bytes`                 | -                                        | produced by probe                           | used in mint `receiveMessage` calldata | -                            |
| `message_nonce`                 | -                                        | produced by probe                           | included in mint provider\_hints       | -                            |
| `mint_tx_hash` / `txid`         | -                                        | -                                           | expected mint tx receipt               | propagated txid for finality |

***

## 3. CctpBurnExecutor

**File**: `cctp/burn.py`

Defined as `@dataclass(slots=True)`. Submits the `depositForBurn` transaction on the source chain.

```python theme={null}
@dataclass(slots=True)
class CctpBurnExecutor:
    evm_endpoints: dict[str, str]
    rpc_client: EvmJsonRpcClient
    cctp_client: CircleCctpApiClient
```

### 3.1 preflight

The most comprehensive on-chain preflight. Six validation stages:

1. **Parameter validity**: extract `source_address`, `recipient_address`, `destination_caller`, `amount_raw`, `min_finality_threshold`, `source_chain_id`, `source_domain_id`, `destination_domain_id` (missing -> `FatalMovementError`)

2. **ERC-20 Allowance check**: `eth_call` of `allowance(owner, spender)`
   ```
   allowance_raw < amount_raw -> FAILED(INSUFFICIENT_ALLOWANCE)
   ```

3. **ERC-20 Balance check**: `eth_call` of `balanceOf(owner)`
   ```
   balance_raw < amount_raw -> FAILED(INSUFFICIENT_BALANCE)
   ```

4. **CCTP fee check**: look up `fee_bps` from Circle API and compute `minimum_fee`
   ```
   minimum_fee_raw > max_fee_raw -> FAILED(MAX_FEE_EXCEEDED)
   ```

5. **Fast Allowance check** (when threshold == 1000): check Circle API fast burn allowance remaining
   ```
   available < requested -> FAILED(FAST_ALLOWANCE_EXHAUSTED)
   ```

6. **Gas estimation**: pre-validate that the calldata is executable via `eth_estimateGas`

**Preflight error codes**:

| Error Code                           | Condition                          |
| ------------------------------------ | ---------------------------------- |
| `INSUFFICIENT_ALLOWANCE`             | ERC-20 approve too low             |
| `INSUFFICIENT_BALANCE`               | Token balance too low              |
| `MAX_FEE_EXCEEDED`                   | Computed fee exceeds max\_fee\_raw |
| `FAST_ALLOWANCE_EXHAUSTED`           | Fast burn allowance exhausted      |
| `MISSING_SOURCE_ADDRESS`             | source address missing             |
| `MISSING_RECIPIENT`                  | recipient address missing          |
| `MISSING_DESTINATION_CALLER`         | destination\_caller missing        |
| `MISSING_TOKEN_DECIMALS`             | token\_decimals missing            |
| `MISSING_AMOUNT`                     | amount missing                     |
| `MISSING_SOURCE_CHAIN_ID`            | source\_chain\_id missing          |
| `MISSING_SOURCE_DOMAIN_ID`           | source\_domain\_id missing         |
| `MISSING_DESTINATION_DOMAIN_ID`      | destination\_domain\_id missing    |
| `MISSING_TOKEN_MESSENGER_ADDRESS`    | token\_messenger\_address missing  |
| `MISSING_BURN_TOKEN_ADDRESS`         | burn\_token\_address missing       |
| `MISSING_MIN_FINALITY_THRESHOLD`     | min\_finality\_threshold missing   |
| `UNSUPPORTED_MIN_FINALITY_THRESHOLD` | value other than 1000 or 2000      |
| `MISSING_MAX_FEE_RAW`                | max\_fee\_raw missing              |
| `MISSING_EVM_ENDPOINT`               | no RPC endpoint for chain\_id      |
| `PROTOCOL_TEMPORARY`                 | transient Circle API error         |

### 3.2 prepare

Builds the EVM transaction payload.

1. ABI-encode `depositForBurn` calldata
2. Look up nonce / gas / gasPrice
3. Return `PreparedAction`

```python theme={null}
PreparedAction(
    prepared_action_id=f"cctp-burn:{ctx.request_node_id}",
    action_type="cctp_burn",
    payload_format="evm_tx",
    payload=build_evm_tx_payload(...),
    payload_hash=compute_payload_hash(payload),
    signing_required=True,  # must be signed by an external signer
    provider_hints={
        "method": "depositForBurn",
        "amount_raw": ...,
        "destination_domain_id": ...,
        "mint_recipient_bytes32": ...,
        "destination_caller_bytes32": ...,
        "min_finality_threshold": ...,
        "max_fee_raw": ...,
    },
)
```

Since `signing_required=True`, the dispatcher invokes the signer to sign the payload before passing it to `submit`.

### 3.3 submit

Broadcasts the signed transaction via `eth_sendRawTransaction`.

```python theme={null}
signed_tx = common.select_signed_tx(kwargs.get("sign_result"))
tx_hash = await common.send_raw_transaction(rpc_client, endpoint=endpoint, signed_tx=signed_tx)
```

**Provider refs on success**:

```python theme={null}
{
    "txid": tx_hash,
    "burn_tx_hash": tx_hash,
    "protocol_ref": tx_hash,
    "source_domain_id": source_domain_id,
    "min_finality_threshold": min_finality_threshold,
    "destination_caller_bytes32": "0x000...caller",
}
```

`burn_tx_hash` and `protocol_ref` carry the same value. `protocol_ref` is used as the IRIS API lookup key by the next node (attestation).

**Error handling**:

* `TemporaryMovementError` -> `UNKNOWN` (TX broadcast result is uncertain)
* `MISSING_SIGN_RESULT` / `MISSING_SIGNED_TX` -> `FatalMovementError` propagated

### 3.4 observe

Simple pass-through:

```python theme={null}
return ExecutionResult(next_state="COMPLETED", provider_refs=dict(ctx.provider_context))
```

### 3.5 recover

If `txid` or `burn_tx_hash` is present -> `COMPLETED`; otherwise `FAILED(RECOVERY_FAILED)`.

***

## 4. CCTP Attestation (Node 2)

The attestation node is the combination of the generic `ProtocolObserveExecutor` and the CCTP-specific `CctpAttestationProbe`.

### 4.1 ProtocolObserveExecutor

**File**: `observe/protocol.py`

A generic observe executor that looks up a probe via `probe_key` and calls `check_proof`. For details, see [observe-probes.md](/reference/executors/observe-probes).

`probe_key: "cctp_iris"` must be set in node\_config.

### 4.2 CctpAttestationProbe

**File**: `observe/cctp.py`

```python theme={null}
@dataclass(slots=True)
class CctpAttestationProbe:
    base_url: str
    iris_client: CctpIrisClient
```

#### validate\_context

Validates that `source_domain_id` is present in node\_config and is an integer.

#### check\_proof

1. Call `iris_client.get_messages(base_url, source_domain_id, transaction_hash=protocol_ref)`
2. If the response is `None` or messages is empty -> `completed=False` (pending)
3. Select a message:
   * If `message_index` is set, pick the message at that index
   * If not set: pick the only message if there is one; if two or more, raise `MULTIPLE_MESSAGES_AMBIGUOUS`
4. If the `attestation` field is empty -> pending
5. If attestation + message\_bytes are both present -> `completed=True`

**Return on completed**:

```python theme={null}
ProtocolProbeResult(
    completed=True,
    provider_state="attestation_complete",
    provider_refs={
        "protocol_ref": protocol_ref,
        "burn_tx_hash": protocol_ref,
        "attestation_hash": "sha256:...",
        "attestation": "0x...",
        "message_bytes": "0x...",
        "message_nonce": "12345",
        "source_domain_id": 0,
    },
    proof={
        "proof_source": "protocol",
        "protocol": "cctp",
        "observed_at": "...",
        "source_domain_id": 0,
        "message_nonce": "12345",
    },
    generated_artifacts=[
        GeneratedArtifact(
            artifact_type="attestation",
            content_format="hex",
            content_inline="0x...",
            artifact_metadata={...},
            artifact_digest="sha256:...",
        )
    ],
)
```

The attestation is also stored as a `GeneratedArtifact` and persisted to the DB.

### 4.3 CctpIrisClient

**File**: `observe/cctp.py`

Circle IRIS API v2 client:

```python theme={null}
class CctpIrisClient:
    async def get_messages(self, *, base_url, source_domain_id, transaction_hash) -> dict | None:
        # GET {base_url}/v2/messages/{source_domain_id}?transactionHash={tx_hash}
```

HTTP status code handling:

| Status                | Behavior                                                     |
| --------------------- | ------------------------------------------------------------ |
| 200                   | Return JSON                                                  |
| 404                   | Return `None` (attestation not yet produced)                 |
| 403                   | `FatalMovementError(PROTOCOL_ACCESS_DENIED)`                 |
| 429                   | `TemporaryMovementError` + Retry-After header (default 300s) |
| 5xx                   | `TemporaryMovementError` (default 300s)                      |
| Other 4xx             | `FatalMovementError(PROTOCOL_API_ERROR)`                     |
| timeout/connect error | `TemporaryMovementError` (default 300s)                      |

**Note**: on rate limit, the default retry wait is **300 seconds (5 minutes)**, which is quite long (`DEFAULT_RATE_LIMIT_RETRY_AFTER_SECONDS = 300`).

### 4.4 CircleCctpApiClient

**File**: `cctp/api.py`

Looks up CCTP v2 burn fees and fast allowance:

```python theme={null}
class CircleCctpApiClient:
    async def get_burn_fees(self, *, source_domain_id, destination_domain_id) -> list[dict]:
        # GET /v2/burn/USDC/fees/{source_domain_id}/{destination_domain_id}

    async def get_fast_allowance(self) -> Any:
        # GET /v2/fastBurn/USDC/allowance
        # returns response["allowance"]
```

Error handling is similar to `CctpIrisClient`, but the retry default is 15 seconds.

***

## 5. CctpMintExecutor

**File**: `cctp/mint.py`

Submits the `receiveMessage` transaction on the destination chain.

```python theme={null}
@dataclass(slots=True)
class CctpMintExecutor:
    evm_endpoints: dict[str, str]
    rpc_client: EvmJsonRpcClient
```

### 5.1 preflight

Four validation stages:

1. **Check that attestation/message\_bytes are present**: `common.attestation(ctx)`, `common.message_bytes(ctx)` — extracted from provider\_context

2. **Look up signer address**: `resolve_signer_address_from_binding(ctx.resolved_bindings["signer"])` — extracts the address from the signer binding metadata

3. **destination\_caller alignment**: `validate_mint_destination_caller_alignment(ctx, signer_address)` — verifies that the `destination_caller` set at burn matches the signer address that will execute the mint. On mismatch: `FAILED(SIGNER_CALLER_MISMATCH)` or `FAILED(DESTINATION_CALLER_CONFIG_MISMATCH)`.

4. **eth\_call dry-run**: execute `receiveMessage(message, attestation)` calldata via `eth_call` to pre-check for revert + estimate gas

**Security: destination\_caller enforcement**

CCTP's `destination_caller` is set at burn time, and only that address can call `receiveMessage` on the destination chain. MG enforces this twice in preflight:

* Burn stage: `common.destination_caller(ctx)` is extracted from node\_config
* Mint stage: verifies that the signer address matches destination\_caller

### 5.2 prepare

Builds the `receiveMessage` EVM transaction payload.

```python theme={null}
PreparedAction(
    prepared_action_id=f"cctp-mint:{ctx.request_node_id}",
    action_type="cctp_mint",
    payload_format="evm_tx",
    payload=build_evm_tx_payload(
        to=message_transmitter_address,
        data=receiveMessage_calldata,
        chain_id=destination_chain_id,
        nonce=nonce,
        gas=gas,
        gas_price=gas_price,
    ),
    payload_hash=compute_payload_hash(payload),
    signing_required=True,
    provider_hints={
        "method": "receiveMessage",
        "message_nonce": message_nonce,
        "attestation_hash": attestation_hash,
    },
)
```

### 5.3 submit

Broadcasts the signed transaction via `eth_sendRawTransaction`.

**Provider refs on success**:

```python theme={null}
{
    "txid": tx_hash,
    "mint_tx_hash": tx_hash,
    "dest_txid": tx_hash,
}
```

Reason for storing the same tx\_hash under three keys: the next node (finality observe) looks up `txid`, while `mint_tx_hash` and `dest_txid` are kept separately for operational tracking.

### 5.4 recover (idempotency detection)

Mint recover is the most complex recover logic:

1. **When tx\_hash is present**: check via `eth_getTransactionReceipt`
   * receipt missing -> `UNKNOWN(MINT_STATUS_UNKNOWN)`
   * receipt reverted -> `FAILED(TX_REVERTED)`
   * receipt success -> `COMPLETED`

2. **When tx\_hash is absent**: dry-run `receiveMessage` via `eth_call`
   * executes normally -> `FAILED(MINT_NOT_SUBMITTED)` (not yet submitted)
   * revert messages such as `ALREADY_PROCESSED` / `already processed` / `nonce already used` -> `COMPLETED` (mint already completed via another path)
   * Other errors -> `FAILED(MINT_RECOVERY_FAILED)`

This **idempotency detection** is the core CCTP safety mechanism. Once a message nonce has been used, the same message cannot be minted again, so the revert message can confirm that it has already completed.

**Error codes (mint)**:

| Error Code                           | Condition                                         |
| ------------------------------------ | ------------------------------------------------- |
| `MISSING_ATTESTATION`                | attestation missing                               |
| `MISSING_MESSAGE_BYTES`              | message\_bytes missing                            |
| `SIGNER_CALLER_MISMATCH`             | signer address != destination\_caller             |
| `DESTINATION_CALLER_CONFIG_MISMATCH` | destination\_caller of burn and mint do not match |
| `MISSING_EVM_ENDPOINT`               | no RPC endpoint for chain\_id                     |
| `TX_REVERTED`                        | mint tx reverted                                  |
| `MINT_STATUS_UNKNOWN`                | receipt lookup unavailable                        |
| `MINT_NOT_SUBMITTED`                 | tx has not been submitted                         |
| `MINT_RECOVERY_FAILED`               | recovery failed                                   |
| `RPC_TEMPORARY`                      | transient RPC error -> next\_state `UNKNOWN`      |

***

## 6. Destination Chain Receipt (Node 4)

The combination of the generic `DestinationChainReceiveObserveExecutor` and `EvmChainReceiveProbe`. `probe_key: "evm"`, destination `chain_id`, and `match_mode: "txid"` are set in node\_config; the probe verifies the expected destination address, ERC-20 token, and amount before the lane can reach finality.

### 6.1 Fast-mode amount floor

Fast CCTP mode (`min_finality_threshold=1000`) may mint less than the requested amount because `minted = amount - fee_executed`. Since `fee_executed <= max_fee_raw`, the receive node uses `amount_match: "at_least"` and `amount_floor_deduction_raw=max_fee_raw` to require `observed >= scaled(amount) - max_fee_raw`. This is a floor, not an exact fee assertion.

***

## 7. Destination Chain Finality (Node 5)

The combination of the generic `DestinationChainFinalityObserveExecutor` and `EvmChainFinalityProbe`. For details, see [observe-probes.md](/reference/executors/observe-probes).

`probe_key: "evm"`, `chain_id`, and `confirmations_required` must be set in node\_config.

The mint tx\_hash is propagated via `ctx.provider_context["txid"]`; once that block's confirmation count reaches the threshold, the node becomes `COMPLETED`.

***

## 8. CCTP Common Helpers

**File**: `cctp/common.py`

### 8.1 Context extraction functions

| Function                           | Extraction path                                                  | Error when missing                    |
| ---------------------------------- | ---------------------------------------------------------------- | ------------------------------------- |
| `source_address(ctx)`              | `intent.source.address`                                          | `MISSING_SOURCE_ADDRESS`              |
| `recipient_address(ctx)`           | `intent.destination.address` / `input_params.recipient`          | `MISSING_RECIPIENT`                   |
| `destination_caller(ctx)`          | `node_config.destination_caller`                                 | `MISSING_DESTINATION_CALLER`          |
| `token_decimals(ctx)`              | `node_config.token_decimals`                                     | `MISSING_TOKEN_DECIMALS`              |
| `amount_raw(ctx)`                  | `intent.amount` / `input_params.amount` -> scaled by 10^decimals | `MISSING_AMOUNT` / `INVALID_AMOUNT`   |
| `min_finality_threshold(ctx)`      | `node_config.min_finality_threshold`                             | `MISSING_MIN_FINALITY_THRESHOLD`      |
| `max_fee_raw(ctx)`                 | `node_config.max_fee_raw`                                        | `MISSING_MAX_FEE_RAW`                 |
| `source_chain_id(ctx)`             | `node_config.source_chain_id`                                    | `MISSING_SOURCE_CHAIN_ID`             |
| `source_domain_id(ctx)`            | `node_config.source_domain_id`                                   | `MISSING_SOURCE_DOMAIN_ID`            |
| `destination_chain_id(ctx)`        | `node_config.destination_chain_id`                               | `MISSING_DESTINATION_CHAIN_ID`        |
| `destination_domain_id(ctx)`       | `node_config.destination_domain_id`                              | `MISSING_DESTINATION_DOMAIN_ID`       |
| `token_messenger_address(ctx)`     | `node_config.token_messenger_address`                            | `MISSING_TOKEN_MESSENGER_ADDRESS`     |
| `burn_token_address(ctx)`          | `node_config.burn_token_address`                                 | `MISSING_BURN_TOKEN_ADDRESS`          |
| `message_transmitter_address(ctx)` | `node_config.message_transmitter_address`                        | `MISSING_MESSAGE_TRANSMITTER_ADDRESS` |
| `attestation(ctx)`                 | `provider_context.attestation`                                   | `MISSING_ATTESTATION`                 |
| `message_bytes(ctx)`               | `provider_context.message_bytes`                                 | `MISSING_MESSAGE_BYTES`               |
| `message_nonce(ctx)`               | `provider_context.message_nonce`                                 | (None allowed)                        |
| `attestation_hash(ctx)`            | `provider_context.attestation_hash`                              | (None allowed)                        |

### 8.2 Address conversion

```python theme={null}
def validate_evm_address(address: str, *, field_name: str) -> str:
    # 0x + 40 hex chars, returns lowercase

def address_to_bytes32_hex(address: str) -> str:
    # "0x" + "00" * 12 + address[2:]  (20-byte -> 32-byte padding)
```

`address_to_bytes32_hex` is required for CCTP's `mintRecipient` and `destinationCaller` parameters. It left-pads a 20-byte EVM address with zeros to 32 bytes.

### 8.3 ABI encoding

```python theme={null}
def encode_deposit_for_burn_calldata(
    *, selector, amount_raw_value, destination_domain,
    mint_recipient_bytes32, burn_token, destination_caller_bytes32,
    max_fee, min_finality_threshold_value,
) -> str:
    # selector + 7 uint256/bytes32/address words

def encode_receive_message_calldata(*, selector, message_bytes_hex, attestation_hex) -> str:
    # selector + dynamic bytes encoding (message + attestation)
```

`depositForBurn` signature: `depositForBurn(uint256,uint32,bytes32,address,bytes32,uint256,uint32)`
`receiveMessage` signature: `receiveMessage(bytes,bytes)`

### 8.4 EVM utilities

| Function                                | Role                                    |
| --------------------------------------- | --------------------------------------- |
| `eth_call(rpc_client, ...)`             | `eth_call` RPC                          |
| `estimate_gas(rpc_client, ...)`         | `eth_estimateGas` RPC                   |
| `gas_price(rpc_client, ...)`            | `eth_gasPrice` RPC                      |
| `tx_count(rpc_client, ...)`             | `eth_getTransactionCount` RPC (pending) |
| `send_raw_transaction(rpc_client, ...)` | `eth_sendRawTransaction` RPC            |
| `build_evm_tx_payload(...)`             | Build the JSON transaction payload      |
| `compute_payload_hash(payload)`         | SHA-256 hash                            |
| `select_signed_tx(sign_result)`         | Extract the signature from sign\_result |
| `receipt_reverted(receipt)`             | Check `receipt.status == 0x0`           |

### 8.5 Fee computation

```python theme={null}
def select_fee_bps(rows: list[dict], *, threshold: int) -> int:
    # return minimumFee of the row whose finalityThreshold == threshold

def minimum_fee_raw(*, amount_raw_value: int, fee_bps: int) -> int:
    # ceil(amount_raw * fee_bps / 10000)
```

`min_finality_threshold` allows `1000` or `2000`:

* `1000` = fast transfer (lower finality, separate fast allowance check)
* `2000` = standard transfer

***

## 9. Node Config requirements (full)

### burn node (exec.cctp.burn)

| Key                       | Type        | Required | Description                                         |
| ------------------------- | ----------- | -------- | --------------------------------------------------- |
| `source_chain_id`         | int         | required | source chain ID (e.g. 1 = Ethereum)                 |
| `source_domain_id`        | int         | required | CCTP source domain (e.g. 0 = Ethereum)              |
| `destination_domain_id`   | int         | required | CCTP destination domain                             |
| `token_messenger_address` | str (0x...) | required | TokenMessenger contract address                     |
| `burn_token_address`      | str (0x...) | required | USDC token contract address                         |
| `destination_caller`      | str (0x...) | required | Address allowed to call mint (security restriction) |
| `token_decimals`          | int         | required | Token decimals (USDC = 6)                           |
| `min_finality_threshold`  | int         | required | 1000 (fast) or 2000 (standard)                      |
| `max_fee_raw`             | int         | required | Maximum allowed fee (raw units)                     |

### attestation node (exec.observe.protocol)

| Key                | Type | Required | Description                       |
| ------------------ | ---- | -------- | --------------------------------- |
| `probe_key`        | str  | required | `"cctp_iris"`                     |
| `source_domain_id` | int  | required | CCTP source domain                |
| `message_index`    | int  | optional | Index in a multi-message response |

### mint node (exec.cctp.mint)

| Key                           | Type        | Required | Description                                   |
| ----------------------------- | ----------- | -------- | --------------------------------------------- |
| `destination_chain_id`        | int         | required | destination chain ID                          |
| `destination_caller`          | str (0x...) | required | Address that executes mint (= signer address) |
| `message_transmitter_address` | str (0x...) | required | MessageTransmitter contract address           |
| `token_decimals`              | int         | required | Token decimals                                |

### mint\_receive\_observe node (exec.observe.destination\_chain\_receive)

| Key                          | Type        | Required | Description                             |
| ---------------------------- | ----------- | -------- | --------------------------------------- |
| `probe_key`                  | str         | required | `"evm"`                                 |
| `chain_id`                   | str         | required | destination chain ID                    |
| `match_mode`                 | str         | required | `"txid"`                                |
| `token_contract`             | str (0x...) | required | destination USDC token contract address |
| `token_decimals`             | int         | required | USDC decimals                           |
| `amount_match`               | str         | required | `"at_least"`                            |
| `amount_floor_deduction_raw` | int         | required | `max_fee_raw`; receive floor deduction  |

### mint\_finality node (exec.observe.destination\_chain\_finality)

| Key                      | Type | Required | Description                   |
| ------------------------ | ---- | -------- | ----------------------------- |
| `probe_key`              | str  | required | `"evm"`                       |
| `chain_id`               | str  | required | destination chain ID (string) |
| `confirmations_required` | int  | required | Required confirmation count   |

## 10. Canonical template seed CLI

`qtg.interfaces.tools.seed_cctp_templates` seeds the canonical `cctp.single_lane_usdc` 5-node lane (burn -> attestation -> mint -> mint\_receive\_observe -> mint\_finality), with `completion_assurance=destination_finalized`.

```bash theme={null}
# dry-run (default): reports create / noop / add_version, no DB write
uv run python -m qtg.interfaces.tools.seed_cctp_templates \
  --source-chain-id <id> --source-domain-id <domain> \
  --destination-chain-id <id> --destination-domain-id <domain> \
  --token-messenger-address <0x...> --message-transmitter-address <0x...> \
  --burn-token-address <0x...> --destination-token-address <0x...> \
  --destination-caller <0x...>

# persist only after reviewing the dry-run
uv run python -m qtg.interfaces.tools.seed_cctp_templates ... --apply
```

* `--apply`가 없으면 항상 dry-run이다. Standard 기본값은 `--min-finality-threshold 2000`, `--max-fee-raw 0`이다.
* latest version이 동일한 canonical content이면 `noop`; 내용이 다르면 `add_version`으로 `max(version) + 1`을 추가한다. 기존 version row는 갱신하지 않으며 비교에는 `timeout_policy`도 포함된다.
* Fast 조합은 `--min-finality-threshold 1000`와 양수 `--max-fee-raw`를 함께 지정해야 한다. CLI는 `1000`에서 max fee가 `0` 이하인 조합을 거부하고, threshold는 `1000` 또는 `2000`만 허용한다.
* **Operator warning:** a wrong `--token-decimals` scales the burn amount and receive floor identically, so the receive guard cannot detect a decimals misconfiguration; verify `decimals()` on both token contracts before seeding.

***

## 11. Observe intervals

| action\_type                         | Default interval (seconds) |
| ------------------------------------ | -------------------------- |
| `protocol_observe`                   | 15                         |
| `cctp_mint_status`                   | 5                          |
| `destination_chain_receive_observe`  | 10                         |
| `destination_chain_finality_observe` | 15                         |

Attestation generally takes several minutes; on IRIS API rate limiting `retry_after` can rise to as much as 300 seconds.

***

## 12. Operator runbook

### 12.1 `DESTINATION_MISMATCH` / `AMOUNT_MISMATCH`

`MANUAL_INTERVENTION`으로 올라가면 즉시 movement/lane을 pause하고 `.claude/skills/qtg-drill-incident-log` 관례에 따라 incident log를 남긴다. burn 파라미터의 recipient와 amount 인코딩을 감사한다. 자금은 이미 체인 위에 있으며 reservation은 보존된다.

### 12.2 `OBSERVATION_TIMEOUT`

`MANUAL_INTERVENTION`에서 Iris와 destination RPC 상태를 확인한다. 원인을 해소한 뒤 기존 `MANUAL_INTERVENTION -> EXECUTING` resume으로 재관찰하거나, 증거를 검토해 수동 완료 판정을 한다.

### 12.3 Post-completion reorg 의심

완료 후 reorg 감지는 런타임에 없으며 명시적 non-goal이다. 의심 시 즉시 pause하고 incident log를 남긴다. 1차 방어는 `confirmations_required`다.

***

## Related documents

* [Executor Overview](/reference/executors/overview)
* [Observe Probes](/reference/executors/observe-probes) (ProtocolObserveExecutor, EvmChainFinalityProbe details)
* [CEX Lane](/reference/executors/cex-lane)
