Offchain backend for the matching contracts.
Initial scope:
- one order type: limit order
- one module path:
TradeModule - one executor
- one matching loop
This repo is intentionally narrow. It is not a generic exchange backend.
- expose a minimal API for order entry and book inspection
- run a price-time matching loop
- submit executor payloads for
Matching.verifyAndMatch(...)
- RFQ
- liquidation
- multi-market support
- websocket market data
- a full frontend
- direct onchain execution from Go
cmd/
api/ HTTP API for orders and health checks
matcher/ background matching worker
internal/
api/ HTTP server wiring and handlers
config/ environment configuration
db/ Postgres connection helpers
instruments/ instrument metadata and registry
matching/ matching loop and orchestration
orders/ order model and repository contracts
migrations/ database schema
Copy .env.example into your own environment and set the required values.
Important values:
DATABASE_URLAPI_ADDRMATCHER_POLL_INTERVALCHAIN_IDMATCHING_ADDRESSTRADE_MODULE_ADDRESSCNGN_JUN30_2026_FUTURE_ASSET_ADDRESSCNGN_JUN30_2026_FUTURE_SUB_ID- optionally
EXPECTED_ORDER_OWNER - optionally
EXPECTED_ORDER_SIGNER
For the physically delivered USDC-cNGN-JUN30-2026 future, the market is only enabled when both
CNGN_JUN30_2026_FUTURE_ASSET_ADDRESS and CNGN_JUN30_2026_FUTURE_SUB_ID are set. The registry
resolves this instrument by exact (asset_address, sub_id) and exposes the canonical market symbol
USDCcNGN-JUN30-2026. Human-readable pair formatting remains in display fields such as
display_name and display_label.
contract_type=deliverable_fx_futuresettlement_type=physical_deliverybase_asset_symbol=USDCquote_asset_symbol=cNGN
If EXPECTED_ORDER_OWNER or EXPECTED_ORDER_SIGNER are set, the API rejects orders whose declared owner/signer do not match those configured addresses. The API also validates that action_json.owner, action_json.signer, action_json.subaccount_id, and action_json.nonce match the stored order fields.
With ENFORCE_ACTION_DATA_INVARIANTS=true (default), the API also rejects orders unless:
action_json.data.assetmatchesasset_addressaction_json.data.subIdmatchessub_idaction_json.data.isBidmatchessideaction_json.data.limitPriceandaction_json.data.desiredAmountare on the same canonical scale as normalized engine fields
Custody requirement for onchain execution:
- Orders submitted for
verifyAndMatchmust reference subaccounts already deposited intoMatching. - API pre-submit guard (enabled by default) checks both:
SubAccounts.ownerOf(subaccount_id) == MATCHING_ADDRESSMatching.subAccountToOwner(subaccount_id) != 0x0000000000000000000000000000000000000000- If these checks fail, order submit is rejected before persistence/executor.
Relevant env:
ENFORCE_MATCHING_CUSTODY=trueENFORCE_ACTION_DATA_INVARIANTS=trueMATCHING_ADDRESS=0x...CHAIN_RPC_URL=https://...(required when custody guard is enabled and matching is configured)
EXECUTOR_URL is the endpoint for a separate executor process, likely implemented in
TypeScript with viem, that performs simulation and submits verifyAndMatch(...).
EXECUTOR_MANAGER_DATA lets the matcher attach the exact manager_data hex required by the
executor call. If the blob is too large for an env var, set EXECUTOR_MANAGER_DATA_FILE
instead. That file may contain either the raw hex string or a JSON object with a
manager_data field.
Expected request body:
{
"market": "BTCUSDC-CVXPERP",
"asset_address": "0x...",
"module_address": "0x...",
"maker_order_id": "maker-order-id",
"taker_order_id": "taker-order-id",
"actions": [
{
"subaccount_id": "123",
"nonce": "1",
"module": "0x...",
"data": "0x...",
"expiry": "1710000000",
"owner": "0x...",
"signer": "0x..."
}
],
"signatures": ["0x..."],
"order_data": {
"taker_account": "123",
"taker_fee": "0",
"fill_details": [
{
"filled_account": "456",
"amount_filled": "1000000000000000000",
"price": "78000000000000000000",
"fee": "0"
}
],
"manager_data": "0x..."
}
}The executor may return an empty 2xx response or JSON like:
{
"accepted": true,
"tx_hash": "0x..."
}Expected local stack:
- Go 1.24+
- PostgreSQL 16+
Suggested flow:
- Start Postgres.
- Apply migrations:
go run ./cmd/migrate- Run the API:
env $(cat .env.example | xargs) go run ./cmd/api- Run the matcher:
env $(cat .env.example | xargs) go run ./cmd/matcherFor a cleaner local env, export the variables from .env.example or use your usual dotenv tooling.
Production deploys are expected to run database migrations before the API starts.
This repository encodes that in railway.toml:
- Railway builds both the API binary and the migration binary.
- Railway runs
./migrateas the pre-deploy command. - Railway starts the service only after the migration step succeeds.
DATABASE_URL in Railway should be a reference variable to the Postgres service, for example
${{Postgres.DATABASE_URL}}, rather than a copied literal URL.
For an EOA-owned deployment, set:
EXPECTED_ORDER_OWNER=0xC7bE60b228b997c23094DdfdD71e22E2DE6C9310
EXPECTED_ORDER_SIGNER=0xC7bE60b228b997c23094DdfdD71e22E2DE6C9310Then submit orders whose top-level fields and action_json agree on:
owner_address/action_json.ownersigner_address/action_json.signersubaccount_id/action_json.subaccount_idnonce/action_json.nonce
Service-tagged cancels (/v1/orders/cancel requests with service) are blocked for protected
order namespaces so bot sweeps cannot cancel manual/smoke/validation orders.
CANCEL_PROTECTED_ORDER_ID_PREFIXES=validation:,smoke:,manual:
Manual cancels without a service tag are still allowed.
Use the built-in smoke script to run the exact deposited cross flow (ask 0.001 @ 1390,
buy 0.001 @ 1391) with real signed orders and assert /v1/trades increments:
PRIVATE_KEY=0x... \
./scripts/smoke_deposited_cross.shThe script submits namespaced order IDs (smoke:jun:...) so they stay separated from bot order
namespaces and cancel sweeps, and then verifies terminal order state through GET /v1/orders/{order_id}.