Skip to main content

Monaco Protocol SDK v1.0.58

This release covers the consumer-facing changes since v1.0.57, and it is about one thing: self-trade prevention. An order that would fill against one of your own resting orders is now resolved under a mode. Previously the engine silently skipped your resting orders and filled around them, rejecting the whole order only when the remaining third-party depth could not cover it; now the mode decides both halves — and the platform default CANCEL_MAKER cancels your resting order in full and lets the incoming one carry on into the rest of the book — except where the incoming order is a zero-fill MARKET order with no other depth, which is rejected before sequencing and leaves your resting order untouched (below). The whole-order SELF_TRADE_NOT_ALLOWED rejection is removed from the match path, surviving only for a FOK order under CANCEL_BOTH. You choose the mode per order with the new selfTradePreventionMode field, or once for your wallet through new GET / PUT /api/v1/accounts/self-trade-prevention endpoints. “Your own” also widens: it now means your whole wallet family rather than a single account id, so a wallet trading against itself across two applications or two sub-accounts is caught where it previously was not. Cancellation events gain stpCounterpartyOrderId and the terminal reason SELF_TRADE_PREVENTION to name why an order went away. If you quote both sides on one wallet, parse order errors, or branch on a zero-fill order’s status, read the three upgrade notes before you bump.

Changed

Self-trade prevention dispatches by mode

An incoming order that meets one of your own resting orders is resolved by the taker’s effective mode; the modes on resting orders are never consulted. The mode resolves as the order’s own selfTradePreventionMode if it carried one, else your wallet’s stored default, else the platform default CANCEL_MAKER. One thing changed for every caller, whichever mode applies: the former whole-order SELF_TRADE_NOT_ALLOWED rejection — message "Self-trade is not allowed" — is gone from the match path, so a crossing order that used to be refused at its owner’s own touch now resolves under a mode instead. What happens next is the mode’s business, per the table above. On the default CANCEL_MAKER specifically, a crossing GTC order sweeps the owner’s resting order and proceeds, where the same order previously rejected and the resting orders were silently skipped. Post-only is the exception to all of it: the crossing check at admission is ownership-blind and runs before self-trade prevention, so a postOnly order that would cross is still rejected with POST_ONLY_WOULD_CROSS and cancels nothing of yours. One exception, and it is the default mode’s: the cancellation is decided during simulation and only commits if the order itself is sequenced. A zero-fill MARKET order whose only in-band depth was your own resting order is rejected for insufficient liquidity before the sequence is allocated — your own orders are deliberately not counted as available depth — so the simulated cancellation is discarded and your resting order survives. A rejected taker is therefore not evidence that your quote was removed. SKIP will not rest you into your own book: a GTC remainder that would rest while one of your traded-around orders survives in the crossed range is cancelled instead of resting. A remainder that rests without crossing rests normally. SELF_TRADE_NOT_ALLOWED survives in exactly one case — a FOK order under CANCEL_BOTH that meets one of your own orders, rejected at placement, and reported per item in batches. A FOK under CANCEL_TAKER or SKIP is instead cancelled whole with zero fills, and a FOK under CANCEL_MAKER cancels your makers first and then fills or kills as normal. “Your own” is now the wallet family: every account sharing one wallet address, across every application. Previously the check compared a single account id, so one wallet trading against itself through two frontends or two sub-accounts was not detected.

An order cancelled by self-trade prevention is CANCELLED, not REJECTED

A cancellation caused by self-trade prevention persists as a real CANCELLED order and emits OrderCancelled, including when it filled nothing. That is deliberately distinct from an accepted zero-fill IOC / FOK order, which is REJECTED for want of qualifying liquidity. For a FOK that is not the same as an empty book: partial candidate depth is discarded whenever it cannot fill the entire quantity, so a FOK can reject with liquidity plainly visible. A plain zero-fill MARKET submit is refused before acceptance and emits no order event at all. Status alone therefore distinguishes “my own prevention ended this order” from “there was not enough qualifying liquidity”, with no message parsing. Code that treats a zero-fill order as necessarily REJECTED needs to handle CANCELLED too.

Cancellation events name the counterparty

OrderCancelled gains an optional stpCounterpartyOrderId (wire stp_counterparty_order_id), and OrderTerminalReason gains SELF_TRADE_PREVENTION. The two always travel together, so the presence of the key is the whole signal — absent means this was not a self-trade-prevention cancellation. On a resting order swept by an arriving order of the same wallet family it names the arriving order; on a taker whose remainder was cancelled instead it names the first own resting order the walk reached. The reason field reads self_trade_prevention. The field is declared on OrderPartiallyCancelled as well, but nothing emits it there: self-trade prevention cancels a resting order in full, so it never produces a partial cancellation. It is reserved for a future reducing mode and is absent on every frame today. The cancellation is also persisted — the order row records the counterparty id — though no read endpoint exposes it yet, and rows written before the column read null.

A swept resting spot order reports its lock release

When self-trade prevention cancels one of your resting spot orders, that order’s wallet now receives the same movements-channel user_movement frame a user-initiated cancel produces: entry type CREDIT, transaction type OrderCancelled, referencing the cancelled order, with the atomic locked-before and locked-after snapshot. Previously the lock was released but only the order event was emitted, so a client tracking locked spot balances off the movements channel would have drifted out of sync with the exchange. A swept margin maker emits no movement frame. Its reserve release moves no user_balances leg, so there is nothing for the movements channel to report — and a user-initiated cancel of a margin order emits none either, so the parity with an ordinary cancel holds in both modes. Do not wait on a movements frame for a margin order; track it on the orders channel.

Added

Per-order selfTradePreventionMode

sdk.trading.placeLimitOrder, placeMarketOrder, replaceOrder, batchCreate and batchReplace accept an optional selfTradePreventionMode of "CANCEL_MAKER", "CANCEL_TAKER", "CANCEL_BOTH" or "SKIP", overriding your wallet and platform defaults for that order alone. The same field is on POST /api/v1/orders, PUT /api/v1/orders/{orderId} and both batch endpoints, and on the corresponding gRPC OrdersService requests.
A replacement is a new order with a new id, so it carries its own value and inherits nothing from the original — restate it to keep the override. The requested value is echoed back as selfTradePreventionMode on order detail and list reads and on order WebSocket payloads, and is absent when the request carried none; it is not the effective mode, so read the settings endpoint when you need that. In a batch, an item with an unknown value fails on its own with error.code === "INVALID_SELF_TRADE_PREVENTION_MODE" while the batch still returns 2xx / OK — though the TypeScript SDK validates the field in its batch item schema and throws ValidationError first, so that per-item code is a raw REST/gRPC caller’s shape. The React useTrade hook accepts the field on the same calls, batchReplace included — its option types are derived from the core signatures, so no separate change was needed.

Wallet self-trade-prevention default

GET and PUT /api/v1/accounts/self-trade-prevention read and set the authenticated wallet’s default mode, as sdk.profile.getSelfTradePreventionDefault() and setSelfTradePreventionDefault(mode), and as gRPC AccountsService.GetSelfTradePreventionDefault / SetSelfTradePreventionDefault.
Both return { mode, isCustom }, where isCustom is false when the platform default CANCEL_MAKER is being inherited and true once the wallet has chosen explicitly. The setting is wallet-scoped: one default per wallet address, shared by every application and account using it, and a per-order mode always overrides it. Mode tokens are always emitted in upper snake case. Raw REST and gRPC callers may send them in any case; the TypeScript SDK validates against the exact uppercase tokens and throws before sending, so pass those verbatim. The write is sequenced through the matching engine and acknowledged only after the durable-log append, so the wallet’s next order already resolves the new mode. The read serves an asynchronous projection of that same log, so a read issued immediately after a set can briefly lag the setter’s own response. An unknown mode is 400 / InvalidArgument — the TypeScript SDK rejects it before sending. A delegated-agent session can neither read nor set the default: both operations return 403 / PermissionDenied. The setter also returns 503 / Unavailable when the matching engine is unreachable or the sequenced write fails transiently; treat that outcome as unknown rather than as proof nothing was written, since DeadlineExceeded and Cancelled map to it too and the write may have committed before the response was lost. Re-read, or retry the same mode — the call sets an absolute value, so a replay cannot land a different one. The MCP tools place_limit_order and replace_order accept selfTradePreventionMode as well.

Upgrade

Three things to check before upgrading. If you quote both sides on one wallet, the default CANCEL_MAKER now cancels your own resting quote in full where the old engine rejected the incoming order — set your wallet default to SKIP if you want the closest thing to the previous trade-around behaviour, and handle the OrderCancelled that a swept quote now produces. If you match on the message "Self-trade is not allowed" to detect a self-cross, that string no longer appears on the match path; branch on terminalReason === "SELF_TRADE_PREVENTION" or on the presence of stpCounterpartyOrderId instead. And if you assume a zero-fill order is REJECTED, handle CANCELLED as well. The rest is additive. selfTradePreventionMode is optional everywhere it appears and absent on reads when the order carried none, stpCounterpartyOrderId is absent on every event that is not a self-trade-prevention cancellation, and the wallet default needs no action — every wallet inherits CANCEL_MAKER until it sets one. See Self-Trade Prevention.