Skip to main content

Monaco Protocol SDK v1.0.59

This release covers the consumer-facing changes since v1.0.58, and it is a wide one. BuilderCodes becomes a public API surface: an enrolled frontend can now read its own configuration, payout history, per-token totals and claimable balance, and move accrued revenue share into a tradeable balance with sdk.buildercodes.claimBuildercodeRewards. Because a frontend’s payout wallet is often a multisig, wallet login gains a narrow EIP-1271 contract-signature path — offered only to a wallet holding a BuilderCodes payout bucket — which adds a retryable 503 to POST /api/v1/auth/verify and lengthens the challenge window to 30 minutes on any deployment where that path is enabled. Positions gain a durable version on the REST and gRPC position reads and both WebSocket position surfaces — the risk read is the one exception and carries none — and the positions channel now carries riskBucketId, marginMode, maintenanceMarginRequired and initialMarginRequired, so a handler no longer needs a REST read to learn a position’s scope or margin requirement. The matching engine also starts bounding how many orders one account may keep resting — 500 per market and 4,000 per account by default, tunable per deployment — rejecting the excess with the structured code ORDER_CAP_EXCEEDED. If you keep more than a few hundred quotes resting per market, reconcile positions on a version, or authenticate a smart-contract payout wallet, read the upgrade notes before you bump.

Breaking

BridgeRoute gains dstEid, and RouteKind gains Oft

@0xmonaco/contracts: SWEEPER_FACTORY_ABI’s BridgeRoute tuple gains a dstEid field (uint32, between compose and bridgeToken). Encoding a setRoute call, or decoding a route() / RouteSet result, against the previous shape now mismatches. dstEid is the route’s own LayerZero destination endpoint id: 0 uses the chain-wide hub EID, and a non-hub value builds a multi-hop lane — the funds land on the same clone address on the middle chain, whose own route relays them onward. RouteKind gains Oft (4), a vanilla LayerZero OFT/OFTAdapter lane spoken with the standard send rather than Stargate’s sendToken, used for direct WBTC bridging over BitGo’s OFT mesh. SWEEPER_ABI gains the OftCannotUnwrap error: an Oft leg with unwrapNative is refused, because a plain OFT takes value only as fee. Nothing is deployed on-chain with the previous ABI shape, so this only affects code that encodes or decodes those tuples itself.

Added

The BuilderCodes API

sdk.buildercodes is a new authenticated namespace with five methods, matched by REST endpoints and by the gRPC monaco.api.buildercodes.BuildercodeRewardsService. Every one of them is authenticated and caller-scoped: the payout bucket is resolved server-side from the caller’s own wallet and never from the request, so a caller can only ever read or claim their own. The three application reads derive the application from the session and require its current payout wallet; getRewardsBalance derives the authenticated wallet’s own reserved bucket, so a former payout wallet can still see and claim what it accrued before a rotation. Delegated-agent sessions are refused on all five. Payout rows expose tradeId, payoutAddress, token, a RAW-unit amount string, appliedBps and createdAt; internal row, application and user ids are not exposed. The claim is ledger-only — no on-chain transaction — and lands in a trading balance, from where the ordinary withdrawal flow applies. amount is a positive integer in RAW atomic units and token a 0x-prefixed EVM address; the SDK validates both before sending. A 403 means the caller is refused (BuilderCodes disabled, or a delegated session, which is permanently unsupported here rather than a rollout state), and a 409 means the payout balance is insufficient — including a wallet that has never accrued a share. Treat an ambiguous failure on the claim as an unknown outcome, not a rejection — and that is not only 503. The command is admitted to the sequencer before its reply is sent, so a claim that timed out in transit may still commit, and there is no idempotency key, so a blind retry can claim twice. The SDK enforces that by clearing retryable on any status at or above 500 and on a failure carrying no status at all (the response was never seen), against its usual “every 5xx is retryable” rule; a 4xx is a decided outcome and keeps its classification. A balance read does not settle it either, because an admitted claim may still be queued. Two 503s are the exception and are both tagged, because both are refused before admission and committed nothing: a maintenance block (error: "OPERATIONS_BLOCKED") and a sequencer overload (code: "OVERLOADED"), which positively wants a retry with backoff. See BuilderCodes for the revenue waterfall and the rotation rules, and sdk.buildercodes for the method reference.

Resting-order caps

The matching engine now bounds how many orders one account may keep resting on the book. The engine defaults are 500 per market and 4,000 per account, per engine shard, and both are set per deployment through RESTING_ORDER_CAP_PER_MARKET and RESTING_ORDER_CAP_PER_ACCOUNT — development already runs a per-account cap of 8,000 — so read the limit that applies to the environment you trade on rather than assuming these numbers. A new rest-capable order beyond either cap is rejected with 400 and the structured code ORDER_CAP_EXCEEDED (gRPC INVALID_ARGUMENT with the same code, per item in a batch), and the message names the binding scope. This is a book-state guard, not a rate limit. Fills and cancels free slots immediately, so the cap never throttles a balanced quoting loop; MARKET and IOC/FOK orders are always exempt because they never rest; replaces are exempt, so repricing at the cap always works; and batch-cancel-all is exempt from these caps — though it keeps its own separate ceiling of 20,000 matching active orders, unchanged by this release (see Other limits). Conditional (TP/SL) and TWAP orders keep their own dedicated caps and count against these only once triggered onto the book. See Resting-order caps.

A durable version on the position reads

Positions now carry an opaque, non-negative version — a producer-owned row revision — on the REST list and detail reads, the gRPC Position message, the WebSocket positions snapshot, and ranked live position_update frames. Reconcile on positionId + version, comparing only within the same position: higher is newer, and never order by updatedAt. The risk read is not a versioned surface. GetPositionRiskResponse (REST /positions/{positionId}/risk, gRPC GetPositionRisk, sdk.positions.getPositionRisk) carries no version at all — neither a number nor a 0 — so reconcile risk against a position you ranked from a list, detail or WebSocket read rather than expecting the risk payload to rank itself. The merge rule is per field group, not per frame. markPrice, unrealizedPnl, liquidationPrice, leverage, maintenanceMarginRequired and initialMarginRequired are unversioned — they are re-derived on every read rather than served from the versioned row — so on an equal version you skip the versioned fields as an idempotent redelivery but still take those six from the newer payload. Discarding a whole payload on an equal version freezes displayed PnL until the position’s next real mutation. isolatedMargin is the one versioned field that is also exempt: the WebSocket positions snapshot overlays it from an independent live matching-engine read rather than from the versioned row, so it can reflect a later command than the version names — take it from the newer payload too. 0 is the unranked sentinel: treat it exactly as an absent version and fall back to a full field comparison. See version for the full rule and the positions channel for which frames are ranked.

Permissionless sweeps take a LayerZero fee

@0xmonaco/contracts: SWEEPER_ABI’s parameterless sweep(address) and SWEEPER_FACTORY_ABI’s permissionless sweep(applicationId, user, token) are now payable, so any caller can drive a direct plain-OFT or Stargate lane by fronting the LayerZero fee, with excess refunded to the caller; on the factory the value is forwarded to the clone, so a fresh deposit address deploys and sweeps in one call. SWEEPER_ABI gains sweepWithRefund(token, feeRefundAddress), the parameterless sweep with an explicit refund address. Direct Stargate routes join the permissionless path with a guard: the implied pool fee must fit the new owner-set permissionlessBridgeFeeCapBps (setPermissionlessBridgeFeeCapBps), whose default 0 admits only fee-free or rewarded sends, making the path opt-in per chain. New errors, and they are declared on different contracts — decode reverts against the right ABI: BridgeFeeTooHigh (pool fee above the cap) and UnexpectedBridgeDebit (the bridge debited something other than the pre-rounded amount) belong to SWEEPER_ABI, while only InvalidFeeCap (a cap above 100%) is on SWEEPER_FACTORY_ABI. The CCTP, hub and swap branches still refuse value, existing zero-value calls are unaffected, and the executor’s quoted path is unchanged.

Changed

Contract-wallet login for BuilderCodes payout wallets

sdk.auth.signChallenge is no longer documented as signing with the wallet’s private key. It asks the connected wallet to authorize the message: an EOA signs it with EIP-191 personal_sign, while a smart-contract wallet such as a Safe has no private key and returns a signature its own contract accepts, which the server checks under EIP-1271. The wallet client handles the difference and the returned value is submitted unchanged either way — so do not assume the 65-byte EOA shape, because a contract signature is usually longer. This is not general smart-contract-wallet login. Server-side EIP-1271 verification is offered only to a wallet holding a BuilderCodes payout bucket, only while the deployment’s contract-wallet flag is on, and only where it has a chain RPC endpoint configured; an ordinary smart-contract trading account still takes the recovery path and cannot authenticate. An eligible address that turns out to be an ordinary EOA is still verified by recovery — the two paths are mutually exclusive. Three client-visible consequences:
  • POST /api/v1/auth/verify can now answer 503 (gRPC AuthService.Verify → UNAVAILABLE). It covers three contract-verification cases — the chain did not answer, it did not answer within the deadline, or the server was at its concurrent-verification capacity and declined to ask, which is local saturation rather than a chain fault — and one that is not about contract wallets at all: verify reads private-beta feature-flag state before anything else, and a read that cannot be served surfaces as the same retryable 503 for any address, ahead of any signature or eligibility work. Do not diagnose every 503 here as a chain fault. It is retryable and is not a verdict on the signature: a rejected signature is still 401 / UNAUTHENTICATED, and so is a wallet contract that rejects by reverting. An eligible address can see the 503 even when it turns out to be an EOA, because the code lookup precedes mode selection. Addresses that are not eligible never reach the chain and are unaffected.
  • signature has a published length bound. A signature over 8192 decoded bytes (16386 characters with the 0x prefix) is rejected as an invalid signature rather than forwarded to the chain. The limit was already enforced; publishing it means a large valid signature no longer fails against an undocumented constraint. It is far above any realistic owner set — well over a hundred 65-byte slots — and EOA signatures are unaffected.
  • The challenge lifetime is 30 minutes rather than 5 on any deployment where contract-signature login is enabled, because a multisig must collect owner approvals before it can answer. The window is uniform for every address, not eligibility-specific: /auth/challenge is unauthenticated and returns expiresAt, so a per-address lifetime would disclose which addresses hold a BuilderCodes payout bucket. A longer window is a longer-lived offer to authorize one predetermined keypair once, never a longer-lived credential.

The positions channel carries scope and margin requirements

sdk.ws.positions(...) now surfaces riskBucketId, marginMode, maintenanceMarginRequired and initialMarginRequired on both the subscribe-time snapshot rows and the live position_update frames, mirroring the REST fields of the same names. The frame parser built its result from an explicit field list, so these were dropped even once the server sent them; a handler no longer needs a REST read to learn a position’s risk bucket, margin mode, or current requirement at the mark. All four are read leniently — absent stays undefined — rather than required. riskBucketId and marginMode are genuinely optional, since a legacy unbucketed position has neither, and treating the margin requirements as required would throw inside the WebSocket message handler and drop a risk-critical update whenever a frame arrived from an older deployment mid-rollout. Do not infer either scope field from the other: they resolve from different sources, so an id can arrive with no mode. Treat a missing marginMode as unknown and keep the mode you already hold — defaulting it to CROSS renders an isolated position with the wrong backing. The exported MarginMode union from @0xmonaco/types names the two values. updatedAt is unchanged in shape but now reports when the position was last mutated, read from the producer’s own record of the mutation. It previously stamped the serialization instant on live frames and on any REST read served from the live matching engine, so it reported the read rather than the write: no two reads of an unchanged position agreed, and a re-emitted frame was indistinguishable from a real change. Two frames for an unchanged position now repeat one timestamp, and a valuation-only oracle reprice no longer moves it. It remains display metadata — version is still the reconciliation key. That match is exact for an open position served from a reachable matching engine. A terminal row, and any position read after a matching-engine restart, is served from the persistence clock instead and differs by the persistence lag. One case is not lag-bounded: while the matching engine is unreachable, a REST read of an open position falls back to that row’s lifecycle timestamps and can report its opening instant until funding settles, while a live frame stays current. Position.marginMode on a live-served REST or gRPC read is now answered by the matching engine’s own view of the risk bucket rather than by a separate lookup, so a REST read and the position_update frame describing the same position cannot disagree. The value itself is unchanged — both derive from the same bucket.

Rate limits: family tiers everywhere, and a budget on authentication

Account families now cover every metered class. The movement budget (withdrawal initiation, collateral transfers) and the per-account read budget each gain a family tier at 4× the account caps by default — the multiplier is set per deployment through MOVEMENT_RATE_LIMIT_FAMILY_MULTIPLIER and READ_RATE_LIMIT_FAMILY_MULTIPLIER — shared by a master account and all of its sub-accounts, so N sub-accounts no longer mint N full budgets; a family-tier rejection says so in its message. POST /api/v1/auth/challenge and /verify (gRPC AuthService/Challenge and Verify) run before any account exists, so they are now metered per client address: 60 requests/min sustained and burst 120 by default, shared by both endpoints, on public ingress only. Like every other budget these are deployment defaults — AUTH_RATE_LIMIT_PER_MINUTE and AUTH_RATE_LIMIT_BURST override them — so pace against your environment’s published figures rather than these. A login costs two. The check runs before the signature, so a throttled request is never an authentication failure — it is REST 429 carrying Retry-After and details.retryAfter, or gRPC RESOURCE_EXHAUSTED carrying a google.rpc.RetryInfo detail. The hint is transport-specific, so a gRPC caller reads RetryInfo rather than looking for a retryAfter field. Like the other limiters this ships warn-only: until auth_rate_limit_enforced is turned on for the environment, an over-budget request is admitted with a warn log and no 429 is returned at all. Every other authenticated call is unaffected; keep a session alive with POST /api/v1/auth/refresh rather than re-authenticating. See Rate limits.

Fixed

payoutAddress may be null

GetBuildercodesConfigResponseSchema now accepts a null payoutAddress, where it previously allowed only a string or an absent key. This is wire-contract hardening rather than a fix for an observed response: the field is serialized without skip-serializing, so null is a representable shape, and the schema now matches it the way lastCreditedAt already did. A successful getConfig() still always carries an address in the released endpoint — it authorizes the caller as the application’s payout wallet and returns 403 when none is configured — so the practical effect is that a client cannot throw on a nullable payload. The inferred type widens to string | null | undefined.

buildercodesBudgetBps and appliedBps are different numbers

The two BuilderCodes bps fields are documented distinctly across the OpenAPI, TypeScript and Rust surfaces. buildercodesBudgetBps on getConfig() is the configured target, computed from the gross non-negative protocol take and capped by the post-floor remainder. appliedBps on a payout row is the realized whole-bps rate after that clamp, and may be lower. Field types and wire values are unchanged.

Upgrade

Four things to check before upgrading. If you keep large numbers of orders resting, count them per market and per account: past your environment’s caps (500 and 4,000 by default, but tunable per deployment) a new rest-capable LIMIT order is rejected with ORDER_CAP_EXCEEDED — IOC and FOK limit orders are exempt, since they never rest — so handle that code rather than the numbers, and prefer a replace over cancel-then-create, since replaces are exempt. If you reconcile positions, adopt the per-field-group merge rule — taking a whole payload only on a higher version freezes PnL at an unchanged one. If you authenticate a smart-contract payout wallet, treat 503 from /auth/verify as retryable rather than as a rejected signature, and stop assuming a 65-byte signature. And if you decode BridgeRoute yourself, re-encode against the new tuple. The rest is additive. The four new positions fields are all optional, and sdk.buildercodes is a new namespace that no existing call touches. Among the version-bearing reads, version has two unranked representations rather than one: a REST or gRPC position read served from live matching-engine state reports 0, because the engine holds the state but not the version, while an unranked live WebSocket frame and a legacy row omit the field entirely. Handle both — neither ranks, and neither is older than anything. The risk read is separate again: it has no version field to carry either form.