# Coinbase

Source module: `app/connectors/coinbase.py`. Kind: **facts** only. Display name: "Coinbase".

## Purpose

Reads the orders and fills of a Coinbase account through the Advanced Trade API, so that a trade an agent
places (through the Coinbase MCP server of Coinbase for Agents, `orders_create`, or the API directly) can be
verified under the `coinbase_agent_trade` library contract: did an order on the product the agent was told to
trade exist, once, for the notional asked, did it fill within the window, and was it not cancelled, rejected,
busted or corrected afterwards.

Coinbase's own controls run **before** the order: the portfolio the agent's key is scoped to, the permissions
on the key, the spending cap on x402 payments. This connector adds nothing there. It reads Coinbase's record
**after** the order, so that the agent's report of what it traded is checked against what Coinbase says it
traded.

## Auth and scopes

| Item | Value |
|---|---|
| Method | CDP API key (Coinbase Developer Platform, "Secret API Keys"); every request carries a two-minute ES256 JWT signed with the key's EC private key, as Coinbase's reference signs it (`sub` and `kid` the key name, `iss` `cdp`, `nbf`, `exp` now + 120 s, `uri` `GET api.coinbase.com/<path>`, a nonce header) |
| Credentials stored (encrypted) | `key_name`, `private_key` (PEM); optional `portfolio_id`; optional `base_url` |
| Least-privilege permissions for the key | **View** only. No Trade, no Transfer. Create the key with the signature algorithm set to **ECDSA** (Advanced Trade does not accept Ed25519 keys; the connector refuses one with the reason) and, when the portal offers it, restricted to the portfolio the agent trades in |
| Connection test | `GET /api/v3/brokerage/orders/historical/batch?limit=1` |
| Non-secret config | `portfolio_id`: when set, only orders and fills whose `retail_portfolio_id` matches are read. Coinbase for Agents keeps each agent in its own portfolio; naming it keeps a person's own trades out of the agent's evidence |

The private key is stored only as ciphertext and is used only to sign tokens for read requests. It is never
logged and never leaves the API process.

## Fields pulled and why

| Endpoint | Fields | Used for |
|---|---|---|
| `GET /api/v3/brokerage/orders/historical/batch?start_date=since-7d&limit=100` (pages by `cursor` while `has_next`) | `order_id`, `client_order_id`, `product_id`, `side`, `status`, `order_type`, `order_configuration` (`quote_size`, `base_size`, `limit_price`), `filled_size`, `filled_value`, `average_filled_price`, `total_fees`, `number_of_fills`, `created_time`, `last_fill_time`, `last_update_time`, `retail_portfolio_id`, `order_placement_source`, `reject_reason` | Order facts, subject id, watermark |
| `GET /api/v3/brokerage/orders/historical/fills?start_sequence_timestamp=since-7d&limit=100` (pages by `cursor`) | `trade_id`, `order_id`, `product_id`, `side`, `trade_type`, `price`, `size`, `commission`, `trade_time`, `retail_portfolio_id` | Reversal facts |

Orders are re-read seven days behind the watermark on every sync: an order placed days ago can still fill, be
cancelled or have a fill reversed, and the facts for those are keyed on the order, so the re-read is idempotent.
A fills list the key cannot read (`403`) is noted on the sync and does not fail it; reversals are then not
observed, and the connection's notes say so.

## The order's notional

Agents are asked to trade an amount in the quote currency ("buy $50 of TSLA"). The connector derives one
number, `quote_value`, from the order: the `quote_size` it was placed with when it was; otherwise
`filled_value`; otherwise, for a limit order placed by base size, `base_size × limit_price`. The contract
compares it with the action's `amount`.

## What is hashed / never stored

- Nothing in these two lists identifies a person; no hashing is needed.
- Account balances, deposit and withdrawal history, payment methods, addresses, names, tax forms and anything
  outside the two endpoints above are never requested.

## Incremental sync and watermark

- Orders: `start_date = watermark − 7 days` (2020-01-01 on first sync), page size 100, `cursor` while `has_next`.
- Fills: `start_sequence_timestamp = watermark − 7 days`, page size 100, `cursor` while pages are non-empty.
- Watermark = maximum order `created_time`.
- Upserts are idempotent (by `raw_ref`).

## Rate-limit handling

Shared `HttpClient` (Retry-After, exponential backoff on `429`/`5xx`, 6 attempts, immediate `reauth` failure
on `401`/`403` for the orders list).

## Fact types produced

Subject id is Coinbase's product id (`BTC-USD`, `TSLA-USD`), subject kind `product`: the thing the agent was
told to trade, which is what its recorded action names. Every fact carries `order_id`, `client_order_id`,
`side`, `order_type`, `quote_value` and `portfolio_id`.

| `fact_type` | When | Extra attributes | `raw_ref` |
|---|---|---|---|
| `order_created` | Every order, at `created_time`; attributes refresh on every sync | `status` (current), `placement_source` | `coinbase:order:{order_id}` |
| `order_filled` | Status `FILLED`, at `last_fill_time` | `filled_size`, `filled_value`, `average_filled_price`, `total_fees`, `number_of_fills` | `coinbase:order:{order_id}:filled` |
| `order_failed` | Status `CANCELLED`, `EXPIRED` or `FAILED`, at `last_update_time` | `status`, `reject_reason` | `coinbase:order:{order_id}:{status}` |
| `order_reversed` | A fill whose `trade_type` is `REVERSAL` or `CORRECTION`, at `trade_time` | `trade_id`, `trade_type`, `price`, `size`, `commission` | `coinbase:fill:{trade_id}` |

Ordinary fills (`trade_type` `FILL`) do not become facts of their own: the order's fill is already
`order_filled`.

## Claims produced

None.

## Subject id format

The product id as Coinbase spells it. An agent that records a `coinbase_agent_trade` action uses the same
subject (the `product_id` it passes to `orders_create`); the recorder mapping in
`recorder/python/examples/coinbase.json` does this.

## Known limitations

- A product the account has never traded has no record at all, so an agent's claim to have traded it, when
  nothing was created, answers UNKNOWN (`identity_unmatched`, "subject not found") rather than FAIL; once any
  order on the product exists, a missing one is a FAIL. The sandbox's broker seeds a `product_status` record per
  product for this reason; a production workspace can push one through the facts API for the products its
  agents are allowed to trade.
- The side (`BUY` or `SELL`) is limited on the action's parameter and shown in the evidence; the engine's
  value comparisons are numeric, so the order's side is not compared with the request.
- `order_placement_source` is read and shown but its values for orders placed through the Coinbase MCP server
  are not documented (listed under `unknown_fields` on the contract); nothing is keyed on it.
- Balances and positions are not read: whether the account could afford the order is Coinbase's check before
  the order, not evidence of it afterwards.
- The connector has been tested against recorded responses in the shape of the reference, not yet against a
  live Coinbase account; the first live sync is on the backlog.
