Portal
Documentation: all sections

Sable Dark Pool

Find the other side before revealing the order.

A participant submits an indication of interest — an asset, a side, a size band, a price band, settlement preferences, an expiry — and Sable privately tells them whether a compatible counterparty exists. The terms are sealed at rest and opened only in memory to run the matcher. They are never shown to a counterparty; a match discloses the agreed overlap, and nothing else.

Read this first: what this is, and what it is not

This is matching infrastructure, and the scope is not a disclaimer bolted onto a trading product — it is the design.

Sable is a facilitator and a witness. Nothing in this product makes it a counterparty, a custodian, or a venue.

The lifecycle

Every transition is timestamped and appended to a per-intent, append-only history you can read at GET /v1/intents/:id.

StateMeaning
createdAccepted and sealed.
activeResting, and visible to the matcher.
matchedLocked into a match with a counterparty.
expiredIts TTL passed. An expired intent is never matchable.
cancelledWithdrawn by its owner.
settlement_pendingBoth parties confirmed; a settlement instruction is signed.
settledThe on-chain transfer was verified.
failedThe settlement window closed without a verified transfer.

Privacy: what is sealed, and what is not

Intent terms — quantity range, price range, settlement preferences, exclusions, your private note — are stored, because you cannot match against an order you did not keep. They are AES-GCM-sealed with the gateway master key, opened in memory only to run the matcher or to answer your own read, and never logged. The database holds a sha256 commitment of your terms plus routing labels (asset, side, status, expiry) and has no column that could hold a price or a size.

This is a deliberate, disclosed exception to Sable's normal rule that content is never persisted — the same posture as hosted-agent code, Relay content and Vault metadata. See the privacy contract.

Matching is deterministic and versioned

The rules are published, and the version in force is stamped on every intent and every match (sable-match/v1). There is no preferential tier: no account is matched ahead of another, and no rule is hidden.

  1. Same asset.
  2. Opposite side.
  3. Price compatibility — the bands must intersect. The match settles at the midpoint of the overlap, rounded down; neither side is favoured, and the overlap is disclosed to both parties so neither has to trust the midpoint blind.
  4. Quantity compatibility — the size bands must intersect. The match takes the largest size both parties said they would do.
  5. Settlement compatibility — at least one shared rail, taken in lexicographic order. v1 verifies evm:<chain_id> rails.
  6. Counterparty eligibility — neither account suspended, the asset on the operator's reviewed allowlist, and neither party excluded by the other.
  7. Beneficial-owner overlap is refused. The same account on both sides, or two accounts sharing a linked wallet, is a wash pair and never matches.
  8. Time priority breaks ties: earliest intent first, then intent id.

Because the engine imposes that total order before pairing anything, the same set of intents produces the same matches regardless of the order they were submitted, read back, or raced into the gateway.

Controlled disclosure

Disclosure is staged, and each stage releases the minimum required for the next step.

  1. A proposal. You learn there is a match, the agreed quantity, the agreed price, the overlap band it came from, and the settlement rail. You learn nothing about who the counterparty is, what their band was, or how large their interest is.
  2. Both parties confirm. Only then does Sable mint and sign a settlement instruction naming the seller's own payee address. The counterparty's account is never disclosed at any stage.
  3. The parties settle. The buyer pays the seller directly on-chain and submits the transaction hash. Sable verifies it and signs a receipt.

Before the pair is locked in, the gateway re-confirms both intents are still active, re-checks eligibility under a row lock, and only then writes the match — so a withdrawal that races a match cannot produce both, and cannot disclose.

Leakage controls

Inferring hidden order flow is the attack this product exists to prevent, so the controls are part of the contract rather than a hardening pass.

Market-abuse controls

API

All routes are session-authed and answer 501 where the operator has not enabled matching. Full shapes in the API reference.

MethodPathWhat it does
POST/v1/intentsSubmit an indication of interest (terms sealed).
GET/v1/intents/:idYour own intent, its terms, and its full history.
POST/v1/intents/:id/cancelWithdraw a resting intent. Idempotent.
POST/v1/matchesAsk whether a compatible counterparty exists.
GET/v1/matches/:idA match you are a party to, at its current disclosure stage.
POST/v1/matches/:id/confirmConfirm your side. Idempotent.
POST/v1/matches/:id/settleVerify the on-chain transfer you made, and get a signed receipt.
GET/v1/markets/:asset_id/statusEligibility rules and your own participation.

Submitting an intent

curl https://api.buildsable.com/v1/intents \
  -H "authorization: Bearer $SABLE_SESSION" \
  -H 'content-type: application/json' \
  -d '{
    "asset_id": "fund-a",
    "side": "buy",
    "quantity_min": 1,
    "quantity_max": 100,
    "price_min_micro_usd": 1000,
    "price_max_micro_usd": 2000,
    "settlement_rails": ["evm:1"],
    "expires_in_secs": 86400
  }'

Prices are per unit, in micro-USD (1,000,000 µ$ = $1). Quantities are integers in the asset's own units; Sable does not interpret the scale, and both parties must mean the same thing by it.

Asking for a match

curl https://api.buildsable.com/v1/matches \
  -H "authorization: Bearer $SABLE_SESSION" \
  -H 'content-type: application/json' \
  -d '{"intent_id": "int_…"}'

A hit discloses the overlap:

{
  "matched": true,
  "match": {
    "id": "mt_…",
    "your_side": "buy",
    "status": "proposed",
    "quantity": 60,
    "price_micro_usd": 1750,
    "price_band_low_micro_usd": 1500,
    "price_band_high_micro_usd": 2000,
    "settlement_rail": "evm:1",
    "counterparty": null,
    "both_confirmed": false
  }
}

A miss is uniform, and carries no reason:

{
  "matched": false,
  "rule_version": "sable-match/v1",
  "checked_at": "2026-09-06T10:47:27Z",
  "note": "No match was made. This response is identical whatever the cause, by design."
}

Confirming and settling

Once both parties confirm, Sable returns a signed settlement instruction that verifies through the public POST /v1/receipts/verify — the same endpoint every other Sable signature uses. Signing an instruction is not settling it:

{
  "settlement_instruction": {
    "payee_address": "0x…",
    "chain_id": 1,
    "amount_micro_usd": 105000,
    "how": "The buyer pays the payee address DIRECTLY on-chain, then POSTs the transaction hash to /v1/matches/{id}/settle. Sable verifies that transfer and signs a receipt. Sable never holds the funds."
  }
}

The buyer makes the transfer from a wallet linked to their account, then:

curl https://api.buildsable.com/v1/matches/mt_…/settle \
  -H "authorization: Bearer $SABLE_SESSION" \
  -H 'content-type: application/json' \
  -d '{"tx_hash": "0x…"}'

Sable reads the transaction from an RPC node, matches it against the instruction it signed, and returns a signed settlement receipt. Read the receipt's two verification fields honestly:

Webhooks

Content-free by construction — ids, the asset, the side, the status, and for a settlement the transaction hash the parties themselves published. Never a quantity, a price, a band, or a counterparty: a webhook body travels to a third-party URL, which is the last place order terms belong.

intent.created · intent.matched · intent.expired · intent.cancelled · match.confirmed · settlement.pending · settlement.completed · settlement.failed

See webhooks.

For operators

Matching is off unless both of these are set, and it is off for every asset not named:

SABLE_INTENT_MATCHING_ENABLED=true
SABLE_INTENT_MATCHING_ASSETS=fund-a,fund-b

The remaining knobs — resting-intent caps, TTL bounds, the match-attempt timing floor, the probing thresholds, and the cancellation-ratio limit — are documented in .env.example. The timing floor in particular is the timing side channel's only defence; do not set it to zero outside a test.