Guardian's review of Orderbook DEX, Round 2 for Ethereal, published July 2025. The report records 45 findings, including 1 critical and 5 high.
- Published
- Review window
- June 11 to 25, 2025
- Language
- Solidity, TypeScript
- Chains
- Ethereum, Converge, Offchain
- Sector
- Perpetuals
- 1 Critical
- 5 High
- 16 Medium
- 9 Low
- 14 Informational
Scope
39 files in scope · 2,769 nSLOC
| File | nSLOC | Lines |
|---|---|---|
src/ActionHandler.sol | 335 | 431 |
src/CollateralManager.sol | 163 | 235 |
src/EtherealProxy.sol | 5 | 8 |
src/ExchangeConfig.sol | 318 | 462 |
src/ExchangeGateway.sol | 357 | 535 |
src/Liquidation.sol | 265 | 389 |
src/PerpEngine.sol | 391 | 526 |
src/Verifier.sol | 13 | 17 |
src/share/Constants.sol | 4 | 6 |
src/share/DecimalMath.sol | 18 | 36 |
src/share/Enums.sol | 23 | 32 |
src/share/Errors.sol | 80 | 206 |
src/share/RegistryRecords.sol | 7 | 9 |
src/share/Signatures.sol | 11 | 16 |
src/share/UserDefinedTypes.sol | 2 | 4 |
src/external/WUSDe.sol | 26 | 56 |
src/interface/IActionHandler.sol | 103 | 134 |
src/interface/ICollateralManager.sol | 11 | 24 |
src/interface/IExchangeConfig.sol | 38 | 99 |
src/interface/IExchangeGateway.sol | 30 | 48 |
src/interface/ILiquidation.sol | 28 | 48 |
src/interface/IPerpEngine.sol | 49 | 71 |
src/interface/IPythLazer.sol | 3 | 6 |
src/interface/IVerifier.sol | 4 | 12 |
src/lib/storage/Account.sol | 92 | 151 |
src/lib/storage/AddressRegistry.sol | 13 | 18 |
src/lib/storage/Deposit.sol | 8 | 14 |
src/lib/storage/Exchange.sol | 45 | 69 |
src/lib/storage/FeeCollector.sol | 24 | 34 |
src/lib/storage/FeeSchedule.sol | 31 | 44 |
src/lib/storage/Liquidator.sol | 14 | 20 |
src/lib/storage/PerpPosition.sol | 13 | 20 |
src/lib/storage/PerpProduct.sol | 45 | 67 |
src/lib/storage/ProductRegistry.sol | 20 | 29 |
src/lib/storage/Token.sol | 29 | 42 |
src/lib/storage/Withdraw.sol | 9 | 15 |
src/share/tokens/ERC20Helpers.sol | 47 | 56 |
src/share/tokens/IERC20Extend.sol | 4 | 8 |
src/lib/external/pyth/PythLazerLib.sol | 91 | 113 |
Findings 45
-
H-01 High WebSocket Rate‑Limiter Does Not Handle Exceeded‑Quota Errors Logical Error Resolved
Description
// src/modules/rate-limit/rate-limit.guard.ts private async wsResponseHandler( client: Socket, key: string, rateLimiter: ChannelRateLimit, points: number ) { try { await Promise.all([ rateLimiter.general.consume(key, points), rateLimiter.burst.consume(key, points), ]); } catch (err: unknown) { handleRateLimitError(client, err, this.logger); // ← logs / emits, but… } // ← …nothing is thrown } // …later in the same file … case 'ws': { const socket = execContext.switchToWs().getClient() as Socket; await this.wsResponseHandler(socket, /*…*/); // may exceed quota return true; // Guard always succeeds }When the Redis token bucket is empty
rateLimiter.consume()throwsRateLimiterRes. The catch block logs and callshandleRateLimitError(), but does not re‑throw and therefore the Guard reports success (return true). Therefore, NestJS proceeds to execute the route handler; the action is sequenced and eventually, put on‑chain, the very thing the limiter is meant to prevent.HTTP follows a different path: its catch block does convert RateLimiterRes into an HTTP 429, correctly aborting the request. The inconsistency makes WebSocket a potential attack vector.
Practical exploitation
const socket = io('wss://sequencer.example.com'); while (true) { socket.emit('submitOrder', orderPayload); // send thousands per second }Even after the quota is exhausted the messages continue to be accepted; at most the attacker sees non‑fatal “rate‑limit” events in the console.
This way, an attacker can bypass account or IP quotas and flood the matching engine. The sequencer still verifies signatures, writes into the database and calls
processActionson‑chain, consuming CPU and relayer gas.Recommendation
Consider failing the Guard when quota is exceeded:
private async wsResponseHandler(/*…*/): Promise<void> { await Promise.all([ rateLimiter.general.consume(key, points), rateLimiter.burst.consume(key, points), ]); }and in the case 'ws' block:
try { await this.wsResponseHandler(/*…*/); return true; } catch (err) { handleRateLimitError(socket, err, this.logger); return false; // Prevents handler execution }Optionally, disconnect or “soft‑ban” the socket inside
handleRateLimitError()to discourage reconnect‑spam:function handleRateLimitError(client: Socket, err: unknown, logger: Logger) { if (err instanceof RateLimiterRes) { client.emit('error', { code: 429, retryAfter: err.msBeforeNext }); client.disconnect(true); // immediate cut‑off } // … } -
H-02 High Multiple OCO’s In Group Filled DoS Resolved
Description
As part of the conditional order feature, an OTO (one trigger other) can have the "other" be another conditional order, such as an OCO (one cancels other).
The OCO can then have an "other" be another OCO and so on. If the OTO executes, the first OCO is expected to execute. If the first OCO is executed, the second, third, and so on conditional orders are expected to be cancelled.
However, when Nekuti encounters a chain of OCO’s and one order in the group obtained a fill, it does not cancel all the remaining OCO’s, which goes against Nekuti documentation that “If any order in the group is partially filled, all other orders in the group are canceled immediately”
Recommendation
Prevent chains of conditional orders from being submitted by users.
-
H-03 High Precision Loss In Close-Only Guard Allows Unintended Exposure Increases Logical Error Resolved
Description
The method
OrderService.verifyOrderReducesPositionis supposed to block any new exposure on products markedCLOSE_ONLY. Instead, it converts both the stored position size and the incoming order quantity from full-precisionbigintinto JavaScript number (IEEE-754 double) before checking:const size = Number(position.size); // bigint → double const sizeDelta = Number(Gwei.fmt(quantity)); // bigint → string → double const isNotReducingPosition = side === OrderSide.BUY ? size > 0 || size + sizeDelta > 0 : size < 0 || size - sizeDelta < 0;IEEE-754 doubles only reliably represent integers up to
2⁵³‒1 (≈ 9.007 × 10¹⁵). Because all quantities are stored in gwei (10⁻⁹ native units), the moment your position or order exceeds roughly 9 million native units, the conversion to number silently rounds, hiding the true delta. A craftedBUYorder can actually flip a short into a net-long position, yetsize + sizeDeltain double-precision still appears non-positive, so the guard mistakenly permits it.Example:
const positionSize = -9007199254740999n; const buyQuantity = 9007199254741000n const size = Number(positionSize); // -9007199254741000 const sizeDelta = Number(buyQuantity); // 9007199254741000 // size + sizeDelta = 0 -- Position appears reduced after precision-loss // positionSize + buyQuantity = 1 -- Position actually increasedRecommendation
Perform the comparison using full-precision integers instead of number casts. Replace the two lines above with:
const size = BigInt(position.size); // keep as bigint const sizeDelta = quantity; // already bigint const wouldIncreaseExposure = side === OrderSide.BUY ? size + sizeDelta > 0n : size - sizeDelta < 0n; if (wouldIncreaseExposure) { throw new BadRequestException(`Product ${ticker} is close only`); } -
H-04 High Order Match DoS With Close Only Product DoS Resolved
Description
Function
_verifyNotReduceOnlyOrProductCloseOnlyis used when settling the positions on-chain to prevent an increase order being processed when the product is set to CLOSE_ONLY mode.Although Sequencer validations such as below attempt to prevent this revert from being triggered on-chain, it does not entirely prevent it due to pending increase limit orders:
if (product.status === ProductStatus.CLOSE_ONLY) { await this.verifyOrderReducesPosition( { productId: product.id, subaccountId: subaccount.id, ticker: product.ticker, side, quantity: dto.data.quantity, }, tx ); }Consider the following scenario:
(1) Trader posts a normal limit order (increase size) while the product is OPEN.
(2) Order sits on the Nekuti order book waiting for a match.
(3) Product is updated to CLOSE_ONLY.
(4) Indexer updates product.status = CLOSE_ONLY in the Sequencer DB.
(5) The dormant limit order finally matches and the match is relayed on-chain
(6)
_verifyNotReduceOnlyOrProductCloseOnlysees an increase order against a CLOSE_ONLY product and reverts, aborting the entire batch.Ultimately, it is possible to DoS all processing of a batch when a product is switched to CLOSE_ONLY.
Recommendation
Consider working with Nekuti's engine to prevent increase orders from being executed upon CLOSE_ONLY mode.
-
H-05 High Silent Precision‑Loss in Mark‑Price Publishing Path Logical Error Acknowledged
Description
The mark‑price obtained from Pyth is stored internally as an 18‑decimal
bigint. Immediately before it is sent to the engine, that integer is coerced into a JavaScript number (IEEE‑754 double) and divided by 1 e18:const productsWithPrice = productFilteredPrices.map(({ product, price }) => ({ product, price: Gwei.toDecimal(price), // <---- CONVERSION TO NUMBER (only 53 bits precision) })); const productSnapshotData = productFilteredPrices.map( ({ product, markPrice, price }) => ({ productId: product.id.toString(), price: Gwei.fmt(Gwei.toGwei(price)), exponent: markPrice.exponent, onchainData: markPrice.onchainData, timestamp: markPrice.timestamp, }) satisfies PriceSnapshotData['prices'][number] );A double carries only 53 bits of integer precision, about 15–17 decimal significant digits. For prices in the 10⁴–10⁵ range, the fractional resolution collapses to the 7th or 8th decimal place (≈10 µUSD). This looks tiny, but when multiplied by large position sizes or used in funding calculations every funding interval it can bias payouts.
Because the cast happens on every publish cycle the sequencer and margin engine are continuously working with a rounded value that diverges from the exact oracle feed. Audit trails will never match the original Pyth data bit‑for‑bit.
Code walkthrough
- Normalization from Pyth (18‑dec
bigint). The 18‑dec integer is cached in Redis:
// File: src/modules/oracle/oracle.service.ts – transformMessageToMarkPriceCache const price = BigInt(priceFeed.price) * 10n ** BigInt(GWEI_BASE + priceFeed.exponent); // exponent = -8 ⇒ multiply by 10^10 ⇒ 18‑dec fixed‑point bigint- Retrieval and float cast:
// File: src/modules/root/price-publisher/price-publisher.worker.ts – inside publish() const productsWithPrice = productFilteredPrices.map(({ product, price }) => ({ product, price: Gwei.toDecimal(price), // <---- precision loss here }));- Transmission to engine:
// File: src/modules/engine/engine.service.ts – updateInstrumentMarkPrice(...) async updateInstrumentMarkPrice( priceSnapshotId: bigint, marks: { product: Selectable<Product>; price: number }[], options?: AxiosRequestConfig ) { const instrumentPrices = marks.map( ({ product, price }) => ({ instrument: this.getInstrumentName(product), // Engine calculates the "first bankruptcy price", marks are capped at this threshold, prevents negative equity // scenarios, liquidation proceeds at the "first bankruptcy price". // // @see: https://nekuti.com/docs/maintenance_margin/markpricecap/ // // This also acts as a protection mechanism if our oracle provider supplies a bad price. It would simply cap it // at the first bankruptcy to avoid cascading liquidations. markPriceCapBehavior: 'CapToFirstBankruptcyPrice', price, }) as const ); try { const updateId = this.getMarkUpdateIdFromPriceSnapshotId(priceSnapshotId); await this.sdk.instrumentUpdateMarkPrices( { instrumentPrices, updateId }, options ? { ...this.requestOptions, ...options } : this.requestOptions ); } catch (err) { this.handleUnexpectedRequestFailure(err, 'Update mark prices failed', getLogContext({ priceSnapshotId })); } }Once the value leaves
price-publisher.worker.tsit is already rounded, every downstream consumer works with the degraded float.Recommendation
Consider retaining full‑precision fixed‑point until the very last serialisation step.
- Normalization from Pyth (18‑dec
-
M-01 Medium Insecure WebSocket Connection Connections Acknowledged
Description
An unencrypted WebSocket
ws://connection is established by the Meridian Sequencer Engine Listener, allowing an attacker on the same network as the server or user to intercept WebSocket communication data in plaintext via a Man-in-the-Middle attack.Recommendation
Establish a WebSocket connection over an encrypted TLS connection using
wss://. -
M-02 Medium HTTP And WebSocket Requests Resolve To The Load‑balancer IP As trustProxy Is false DoS Acknowledged
Description
The sequencer implements two parallel rate‑limit entry points:
- Transport | Key generation path | Relevant code
- HTTP (REST) | request.ip | RateLimitService.getIdentifierHttp()
- WebSocket (Socket.IO) | resolveClientAddress() | RateLimitGuard.wsResponseHandler()
HTTP flow:
// src/modules/rate-limit/rate-limit.service.ts getIdentifierHttp(req: FastifyRequest) { return request.ip; // ← raw peer address }request.ipis rewritten to the first element of X‑Forwarded‑For only if Fastify is started withtrustProxy: true, which is not the case as:// src/modules/config/server-config.ts import dotenv from 'dotenv'; import { Logger } from '@nestjs/common'; import { z } from 'zod'; import { TrustProxySchema } from '../../shared/schema'; import { getDotenvFilename, getEnvironment } from './env.provider'; const DEFAULT_CONFIG = { trustProxy: 'false', } as const; // This is a workaround to allow Fastify to be configured before the DI container is built const ServerConfigSchema = z.object({ trustProxy: TrustProxySchema.default(DEFAULT_CONFIG.trustProxy), }); export type ServerConfig = z.infer<typeof ServerConfigSchema>; export const getServerConfig = () => { const logger = new Logger('ServerConfig'); const nodeEnv = getEnvironment(); const path = getDotenvFilename(nodeEnv); if (path) { dotenv.config({ path }); } const { data, error } = ServerConfigSchema.safeParse({ trustProxy: process.env.HTTP_TRUST_PROXY, }); if (error) { logger.error('Failed server config validation'); throw error; } return data; };Therefore, (client → LB → Sequencer) every request appears to originate from the proxy and all users share a single Redis bucket:
<prefix>:ip:general:203.0.113.7.WebSocket flow
// src/shared/ws/ws-connection.ts export const resolveClientAddress = (handshake, trustProxy) => { if (!trustProxy) { return handshake.address; // ← raw TCP peer (proxy IP) } /* … parse X‑Forwarded‑For when trustProxy > 0 … */ }Because the configuration file sets
sequencer.ws.trustProxy = falseby default, the helper also returns the proxy’s address (handshake.address = socket.remoteAddress):// src/modules/config/dto/sequencer.dto.ts const DEFAULT_CONFIG = { logLevel: 'info', logToFile: 'false', http: { port: 3000, // NOTE: `trustProxy` see `server-config.ts`. }, ws: { port: 3001, // @see: https://fastify.dev/docs/latest/Reference/Server/#trustproxy trustProxy: 'false', }Only when operators manually set
trustProxytotruethe code looks at the rightmost entries ofX‑Forwarded‑Forand recover the real client IP.Resulting behaviour
- Deployment | trustProxy | HTTP key | WS key | Effect
- Direct (no LB) | false | real IP | real IP | Works correctly.
- Behind LB / CDN | false | proxy IP | proxy IP | All users throttled together; attacker can DoS legitimate traffic.
- Behind LB / CDN | HTTP: true, WS: false | real IP | proxy IP | Inconsistent limits, WS remains vulnerable.
- Behind LB / CDN | true (both) | real IP | real IP | Correct / expected.
With the current setup any moderately active user exhausts the shared bucket and triggers
429 Too Many Requestsfor every other client, both over REST and WebSocket APIs. On the other hand, rate‑limit headers (X‑RateLimit‑*) and metrics misrepresent the real distribution of traffic.Recommendation
Consider setting
trustProxyflag to true following the Fastify documentation: https://fastify.dev/docs/latest/Reference/Server/#trustproxyOn the other hand, unit‑test both layers: send requests with
X‑Forwarded‑For: <client>,<proxy>, assert that HTTP and WS produce the same key.Implementing the above ensures that every real end‑user is mapped to its own Redis bucket regardless of the presence of reverse proxies.
-
M-03 Medium Malicious Subaccount Can Prevent Actions Logical Error Acknowledged
Description
When the subaccount performs an action off-chain, the rate limit is consumed on the parent account. For example in function
cancel:await this.rateLimitAccountService.consume(subaccount.account, RateLimitPoints.LOW);This allows a singular malicious subaccount to drain the remaining rate limit points for the entire account, consequently preventing other subaccounts from cancelling or submitting orders which can ultimately lead to losing trades/failed hedges/fund loss.
Recommendation
Consider enforcing a sub-account specific ratelimit as well.
-
M-04 Medium Mixed‑case Ethereum Addresses Bypass Per‑Account Rate‑Limit Buckets DoS Resolved
Description
The account‑level limiter uses the wallet address string as its Redis key:
// src/modules/rate-limit/rate-limit-account.service.ts async consume(account: Address, pointsConsumed: number) { const rateLimiter = this.rateLimitService.getRateLimiter(RateLimitType.ACCOUNT); try { const [result] = await Promise.all([ rateLimiter.general.consume(account, pointsConsumed), rateLimiter.burst.consume(account, pointsConsumed), ]); if ([Environment.TEST, Environment.CI].includes(this.configService.env)) { return; } // This intercepts the underlying server response and not the fastifyReply type const res = this.cls.get(CLS_RES) as ServerResponse | undefined; if (!res) { throw new Error('No response object found in cls'); } res.setHeader('X-RateLimit-Account-Retry-After', Math.ceil(result.msBeforeNext / 1000).toString()); res.setHeader('X-RateLimit-Account-Limit', (result.consumedPoints + result.remainingPoints).toString()); res.setHeader('X-RateLimit-Account-Remaining', result.remainingPoints.toString()); } catch (err) { if (err instanceof RateLimiterRes) { throw new RateLimitLimitError({ retryAfter: Math.ceil(err.msBeforeNext / 1000) }); } throw err; } }Because Ethereum addresses are case‑insensitive, multiple textual variants represent the same on‑chain identity. Example variants (all the same wallet):
0xF3ca3b7127A9E546f69E3C47368Fb51d8F405F40 0xf3CA3B7127a9e546F69e3C47368fB51d8F405f40 0xf3ca3b7127a9e546f69e3c47368fb51d8f405f40 // all‑lowerEach variant becomes a separate Redis key (
<prefix>:account:*:<address>) so the attacker’s true call rate equals the sum of all buckets they create. Therefore, a single user can exceed quota by a factor equal to the number of mixed‑case permutations they generate (millions).Recommendation
Normalise addresses once and consistently:
import { getAddress } from 'viem'; // ensures EIP‑55 checksum function normalise(addr: Address): string { // Option A: force lower‑case // return getAddress(addr).toLowerCase(); // Option B (cleaner): use canonical EIP‑55 checksum return getAddress(addr); // always same casing for same address }Apply the helper function when generating the key:
const key = normalise(account); await Promise.all([ rl.general.consume(key, points), rl.burst.consume(key, points), ]); -
M-06 Medium Clients Can Never Unsubscribe From Liquidation WebSocket Stream Logical Error Resolved
Description
StreamServiceimplements symmetrical helpers to join/leave Socket.IO rooms per stream type. While the subscribe path for liquidations is correct, the unsubscribe path contains a copy‑paste error:// src/modules/stream/stream.service.ts async unsubscribeLiquidation(subaccountExtId: string, socket: Socket) { // ❌ uses Order‑Update helper instead of Liquidation helper await this.updateOrderUpdateSubscription(subaccountExtId, socket, false); }- The call should be
updateLiquidationSubscription. - Because the wrong helper computes a different room key (
ORDER_UPDATE:<id>), the socket.leave() call is executed on a room the client was never in, leaving theSUBACCOUNT_LIQUIDATION:<id>room untouched. - The socket therefore continues to receive liquidation events after the front‑end believes it has unsubscribed.
Because of this, traders may inadvertently receive liquidation data for accounts they no longer track. Dangling room membership keeps extra entries in Redis adapter sets; at scale this increases memory, key‑scans, and broadcast fan‑out, degrading latency.
Recommendation
Fix the helper reference
async unsubscribeLiquidation(subaccountExtId: string, socket: Socket) { await this.updateLiquidationSubscription(subaccountExtId, socket, false); // ✅ correct helper } - The call should be
-
M-07 Medium Precision‑loss In getProductPriceRange Logical Error Acknowledged
Description
The
getProductPriceRange(initialPrice, tickSize, …)function is responsible for defining the minimum and maximum allowed prices for every product:private getProductPriceRange( initialPrice: number, tickSize: number, levelsByDirection = MAX_PRICE_LEVELS_BY_DIRECTION, maxPriceLevels = MAX_PRICE_LEVELS ) { const levelsByPriceTicks = levelsByDirection * tickSize; const minPrice = Math.max(0, Math.round(initialPrice - levelsByPriceTicks)); const maxPrice = Math.round(initialPrice + levelsByPriceTicks); // An instrument can have a maximum of 2,147,483,647 price levels. if ((maxPrice - minPrice) / tickSize > maxPriceLevels) { const logContext = getLogContext({ minPrice, maxPrice, maxPriceLevels, levelsByDirection }); throw new ProductError(`Price range exceeds maxPriceLevels ${logContext}`); } return { minPrice, maxPrice }; }- All arithmetic is performed with JavaScript number (IEEE‑754 binary64).
initialPriceis a plain number parsed from the off‑chain config.tickSizeis converted from on‑chain gwei to a number viaGwei.toDecimal.
Because number has only 53 bits of integer precision:
- Any value
>|2^53|can not be represented precisely. - Small decimal fractions (e.g. 0.000000001) cannot be represented at all.
Consequences
levelsByPriceTicksunder‑ or over‑flows after rounding, sominPricecan be equal tomaxPriceorminPricecan actually become negative.- The guard that enforces the 2 147483 647 level cap can pass even though the true number of ticks is larger.
- Orders outside the rounded range are rejected by the matching engine while orders inside may be mismargined and could lead to undercollateralization or insolvency.
Concrete examples
- Scenario | Input | Observed result
- Large notional / tiny tick |
initialPrice= 1 200 000 000tickSize= 1 e‑9 |levelsByPriceTicksunderflows to 0.01 →Math.rounderases it →minPrice == maxPrice, collapsing the order‑book width to 1 tick. - Value just above 2^53 |
initialPrice= 9 007 199 254 740 993 (2^53 + 1) | JS stores it as 2^53, book range is computed with a wrong price
Recommendation
Migrate the entire helper to fixed‑point or bigint arithmetic. A minimal, backwards‑compatible patch:
const SCALE = 1_000_000_000n; // gwei const toInt = (x: string|number) => BigInt(Math.round(Number(x) * 1e9)); const initial = toInt(initialPrice); const tick = toInt(tickSize); const span = BigInt(levelsByDirection) * tick; const min = initial > span ? initial - span : 0n; const max = initial + span; if ((max - min) / tick > BigInt(MAX_PRICE_LEVELS)) throw new ProductError('Price range exceeds limit'); return { minPrice: (min / SCALE).toString(), maxPrice: (max / SCALE).toString(), };Additional hardening:
- Fail fast if either
initialPriceortickSizeis not a safe integer or iftickSize < 1e6USD. - Lint rule or type alias (type
USD = bigint) forbidding raw number for monetary units project‑wide.
-
M-09 Medium linkSignerExpiryDays Default Value Mismatch Warning Resolved
Description
In the
sequencer.dto.tsfile theDEFAULT_CONFIGis defined as:const DEFAULT_CONFIG = { … account: { linkSignerLimitInPeriod: 5, linkSignerLimitPeriodDays: 7, // ❶ correct: 7 days linkSignerExpiryDays: 30, // ❷ correct: 30 days (intended default) maxPositions: 50, }, … } as const;On the other hand, the Zod schema for the account is defined as:
export const SequencerConfig = z.object({ … account: z.object({ linkSignerLimitInPeriod: z.coerce.number().positive().int() .default(DEFAULT_CONFIG.account.linkSignerLimitInPeriod), linkSignerLimitPeriodDays: z.coerce.number().positive().int() .default(DEFAULT_CONFIG.account.linkSignerLimitPeriodDays), // 🚨 BUG: default points to the *wrong* constant linkSignerExpiryDays: z.coerce.number().positive().int() .default(DEFAULT_CONFIG.account.linkSignerLimitPeriodDays), // should be linkSignerExpiryDays maxPositions: z.coerce.number().positive().int().min(1) .default(DEFAULT_CONFIG.account.maxPositions), }), … });Problem line:
.default(DEFAULT_CONFIG.account.linkSignerLimitPeriodDays)Instead of:
.default(DEFAULT_CONFIG.account.linkSignerExpiryDays)Because of this copy‑&‑paste error, the default
linkSignerExpiryDaysbecomes 7 instead of the intended 30. Therefore, after a week, traders that rely on a linked hot‑wallet suddenly getSIGNER_EXPIREDerrors when submitting orders or cancelling positions.Recommendation
Update the
sequencer.dto.tsfile as shown below:- linkSignerExpiryDays: z.coerce.number().positive().int().default(DEFAULT_CONFIG.account.linkSignerLimitPeriodDays), + linkSignerExpiryDays: z.coerce.number().positive().int().default(DEFAULT_CONFIG.account.linkSignerExpiryDays), -
M-10 Medium safe‑array‑where‑in Plugin Returns Wrong Results For NOT IN () Logical Error Acknowledged
Description
WithSafeArrayWhereInPluginis a custom Kysely plugin that tries to protect developers from the Postgres syntax error“syntax error at or near ‘)’”that happens when anINlist is empty. It rewrites every run‑time empty list to IN (NULL).if (this.#isInOperatorNode(operator) && PrimitiveValueListNode.is(rightOperand)) { if (Array.isArray(values) && values.length === 0) { return { ...node, rightOperand: { ...rightOperand, // 👇 injects a single NULL values: [null], }, }; } }For positive tests (
… WHERE id IN ()) the replacement is harmless:IN (NULL)evaluates toFALSE, yielding the expected empty result set. For negative tests (… WHERE id NOT IN ()) the same rewrite yieldsNOT IN (NULL).In Postgres the expression
NOT IN (NULL)returnsNULL, which theWHEREclause treats asFALSEand therefore all rows are filtered out. Instead of returning every row, the query returns none, breaking business logic or causing subtle data loss in delete/update statements.Because the plugin is injected globally, any repository call that uses array filters and passes an empty array may be affected without the developer noticing.
Recommendation
Refine the transformer so that it emits a boolean literal when the list is empty:
… IN () ⟶ FALSE… NOT IN () ⟶ TRUE
This preserves SQL’s three‑valued logic and avoids the NULL trap. For example:
class WithSafeArrayWhereInTransformer extends OperationNodeTransformer { #isIn(node: OperatorNode) { return node.operator === 'in'; } #isNotIn(node: OperatorNode) { return node.operator === 'not in'; } protected override transformBinaryOperation(node: BinaryOperationNode): BinaryOperationNode { const { operator, rightOperand } = node; if (PrimitiveValueListNode.is(rightOperand) && rightOperand.values.length === 0) { if (OperatorNode.is(operator)) { if (this.#isIn(operator)) { // x IN () -> FALSE return sql`FALSE`.toOperationNode() as BinaryOperationNode; } if (this.#isNotIn(operator)) { // x NOT IN () -> TRUE return sql`TRUE`.toOperationNode() as BinaryOperationNode; } } } return node; } } -
M-11 Medium Precision Loss On Funding Precision Acknowledged
Description
Because the Nekuti engine utilizes IEEE 754 wheras the Sequencer (and contracts) use BigInt, the charged funding can vary 1 gwei between the systems. Although this is not a large discrepancy, this would cause a desync between the two systems that will grow with every funding charge.
For example:
(1) Within
updateFunding, thechargePerUnitUsd = 9007199254740993n-- a BigInt(2) Nekuti engine processes the charge as a double such that
chargePerUnitUsd = 9007199254740992-- IEEE754 format(3)
handleFundingChargereads the stored charge in the DB which is9007199254740993nand not the9007199254740992charged on the engine.(4) UpdatingFunding action is sent on-chain with
9007199254740993n(5) Ultimately there is a desync between engine and Sequencer/engine with the funding charged, and gap can widen over time.
Recommendation
Consider if Nekuti engine can utilize the same precision or consider accounting for the discrepancy with a separate funding charge.
-
M-12 Medium Rate‑limiting Imbalance On POST /order/cancel Warning Resolved
Description
The cancel endpoint is annotated with
@LowRateLimit()inorder.controller.ts, which translates to a singleRateLimitPoints.LOWdebit inside the service layer:@Post('cancel') @HttpCode(HttpStatus.ACCEPTED) @LowRateLimit() // <‑‑ low‑cost bucket async cancel(@Body() dto: CancelOrderDto) { await this.whitelistService.validateAddress(dto.data.sender); return toListOfCancelOrderResultDtos(await this.orderService.cancel(dto)); }At the same time
cancel-order.dto.tspermits an array of up to 256 UUIDs through theMAX_ORDER_CANCEL_BATCH_SIZEconstant:const MAX_ORDER_CANCEL_BATCH_SIZE = 256; @IsUUID('4', { each: true }) @ArrayMinSize(1) @ArrayMaxSize(MAX_ORDER_CANCEL_BATCH_SIZE) // <‑‑ 256 ids readonly orderIds!: string[];During execution,
OrderService.cancelpipes the entire list straight to the matching engine with a single call:const results = await this.engineService.cancel({ subaccount, orderIds: orderIdsToCancel });From the engine’s perspective this represents 256 individual cancellation operations, each triggers order‑book mutation, audit entry creation, nonce update and event emission. Yet the API gateway and rate‑limit accounting treat the whole bundle as one cheap request. An attacker can therefore submit cancel requests in a tight loop and multiply their effective load on the engine by a factor of 256 relative to submit requests, which are billed at
@MediumRateLimit()and only handle one order at a time. This asymmetry creates an avenue for excessive resource consumption or denial‑of‑service against the core matching engine.Recommendation
Re‑align the cost of a cancellation request with the work it imposes. The simplest fix is to debit rate‑limit points proportionally to orderIds.length, for example:
// inside OrderService.cancel await this.rateLimitAccountService.consume( subaccount.account, dto.data.orderIds.length * RateLimitPoints.LOW );Alternatively lower
MAX_ORDER_CANCEL_BATCH_SIZEto a more conservative value (e.g. 32) or introduce a dedicatedHIGHtier for large batches. Whichever strategy is chosen, ensure the rate‑limit schedule for cancellation cannot be abused to produce significantly more engine work per token than order submission or modification calls. -
M-13 Medium Revoked Signer Submits Order With Race Condition Logical Error Resolved
Description
Concurrent
submitandrevokeSignercalls can interleave under the default READ COMMITTED isolation. Consequently, the following race condition is possible with two concurrent requests:- Order submission flow starts and signer appears active, hence
verifySubaccountAllowSignersvalidation passes successfully. - Order flow stalls during async validations (Promise.all, DB checks, etc.).
- Revoke flow sees no pending orders yet, deactivates signer, finishes transaction.
- Order flow continues and inserts the new order
Ultimately, the system now has an order that will be matched for a revoked signer. Because
revokeSigneron-chain excludes the account, once the match is relayed on-chain it will revert withExcludedAccountand the entire batch of actions will be DoS'dRecommendation
Consider enforcing a lock so each flow cannot read stale data until the other flow commits or rolls back.
- Order submission flow starts and signer appears active, hence
-
M-14 Medium Incorrect Cache Refresh Logic in getWithdrawLockout Logical Error Acknowledged
Description
The
getWithdrawLockoutmethod in theSubaccountServiceclass suffers from a logical flaw in its cache-refresh condition, causing the system to rely on outdated withdrawal lockout periods. This method is designed to cache the lockout value, which is retrieved from the exchange contract and refresh it periodically based on a time-to-live (TTL) threshold. However, due to an error in the condition that determines when the cache should be updated, the method refreshes the cache when it is still fresh and fails to do so when it becomes stale. This leads to the system using incorrect lockout values, which in turn affects the accuracy of withdrawal readiness indicators likeisReadyandreadyAt. Therefore, users may encounter failed withdrawal attempts or miss opportunities to access their funds, undermining the platform's reliability.The issue stems from the cache-refresh condition in the current implementation. Consider the following code snippet:
if ( !this.cachedLockout || !this.lastCachedLockoutAt || this.lastCachedLockoutAt > Date.now() - this.LOCKOUT_CACHE_TTL_MS ) { // Refresh logic }In this logic, the third part of the condition
(this.lastCachedLockoutAt > Date.now() - this.LOCKOUT_CACHE_TTL_MS)is where the error lies. This check determines whether the last refresh time is more recent than the staleness threshold, calculated as the current time minus the TTL. However, this is the opposite of the intended behavior. When the cache is fresh, for example, if it was refreshed 1 minute ago and the TTL is 5 minutes, the condition evaluates totruebecausethis.lastCachedLockoutAtis greater thanDate.now() - this.LOCKOUT_CACHE_TTL_MS. This triggers an unnecessary refresh. Conversely, when the cache is stale, for instance, if it was refreshed 10 minutes ago with a 5-minute TTL, the condition evaluates tofalse, and the cache is not refreshed, leaving the system to operate with outdated data.Recommendation
To address this issue, the condition must be updated to correctly identify when the cache is stale and needs updating. The condition should trigger a refresh if the cache is missing, if the last refresh time is untracked, or if the last refresh occurred before the staleness threshold. Here’s the corrected implementation:
private async getWithdrawLockout() { if ( !this.cachedLockout || !this.lastCachedLockoutAt || this.lastCachedLockoutAt < Date.now() - this.LOCKOUT_CACHE_TTL_MS ) { const lockout = await this.rpcService.exchange.read.getExchangeWithdrawLockout(); this.cachedLockout = lockout; this.lastCachedLockoutAt = Date.now(); } return this.cachedLockout; }In this corrected version, the third part of the condition (
this.lastCachedLockoutAt < Date.now() - this.LOCKOUT_CACHE_TTL_MS) now checks whether the last refresh time is older than the staleness threshold. This ensures that the cache is refreshed only when necessary: when it is missing, untracked, or stale. -
M-16 Medium Incorrect NULL Comparison in isLiquidated Filter Logical Error Resolved
Description
In
position.repository.tsthe liquidation filter is implemented as:if (typeof filters.isLiquidated === 'boolean') { q = q.where('liquidationId', '!=', null); }In PostgreSQL (and in most SQL dialects) the comparison
!= NULLis nevertrueas it evaluates toNULL, which behaves as unknown in aWHEREclause and therefore filters nothing. WhenisLiquidatedistruethe query unintentionally returns zero rows, and when it isfalsethe surrounding condition mistakenly applies the same expression, again returning an empty set. Users or downstream services relying on this endpoint will be unable to retrieve liquidated positions, leading to missing risk metrics, misleading dashboards and possible reconciliation failures.Recommendation
Replace the expression with an explicit
IS [NOT] NULLcheck that branches on the requested boolean:if (filters.isLiquidated === true) { q = q.where('liquidationId', 'is not', null); // only liquidated rows } else if (filters.isLiquidated === false) { q = q.where('liquidationId', 'is', null); // only non‑liquidated rows } -
M-17 Medium Signer Revoke Prevented With Unfillable Order DoS Acknowledged
Description
A pre-condition for a successful
revokeSignercall on the API endpoint is that the subaccount does not have any orders that are pending fills, which is checked via functionverifyNoFillableOrders.This allows for a potential DoS opportunity where the linked signer creates an unfillable order e.g. extreme limit price. Because the order is pending and
verifyNoFillableOrderswill throw, the account sender will not be able to revoke the linked signer.Recommendation
Clearly document this risk.
-
M-18 Medium Engine Fill Cost Precision Loss Logical Error Acknowledged
Description
Due to IEEE754 precision from the Nekuti matching engine, the quantity liquidated multiplied by the fill price will not always match the cost of the position, as the cost may be 1 wei larger.
This will ultimately lead to a desync with on-chain liquidation, since the cost is re-calculated with BigInt precision, rounding down:
int128 markCost = (rt.price.mulDecimal(Math.abs(action.products[i].sizeDelta))).toInt128();Because the
markCostmay be smaller 1 wei for each product, in the case of short positions liquidated the cumulative PnL would increase andLiquidateAboveMaintenanceMarginrevert may be triggered even though the liquidation was valid from the Engine's perspective. This will also DoS all other actions that are being processed in the batch.Note that there will also be discrepancy within
_settleLiquidatorPositionwhen calculating theuint256 closeCost = (cost * Math.abs(oldSize)) / Math.abs(sizeDelta);sincecostwhich is from the Nekuti engine may be 1 wei larger due to the IEEE754 precision.Recommendation
The best way to solve this is to have Nekuti use the exact same precision as the Sequencer and on-chain. In general, rounding should be in favor of the protocol rather than the user. Any discrepancy for the Liquidator can be manually accounted for.
-
M-19 Medium Engine And Sequencer Discrepancy For Unfilled Market Order Logical Error Resolved
Description
When a market order is cancelled/rejected upon submission to Nekuti, the order’s status within the Sequencer is
SUBMITTED, but from the EngineService the order’s status isCANCELED. Consequently, the order repository in the Sequencer contains stale data for the order.Recommendation
Consider reading the
/submitresponse and adjusting the order repository based onordStatus, or having Nekuti send a cancel execution group if the Market order is outright cancelled. -
L-01 Low Typo in capActionsUsingEstimatedGasUsage Logical Error Resolved
Description
In the
RelayerServiceclass, thecapActionsUsingEstimatedGasUsagemethod contains a typo in one of its return statements. Specifically, the second return statement usesgsEstimateinstead ofgasEstimate:return { gsEstimate: sumGasEstimate, cappedActions: actions };This should be:
return { gasEstimate: sumGasEstimate, cappedActions: actions };The method is expected to return an object with properties
gasEstimateandcappedActions. Due to the typo, when this return path is executed (i.e., when all actions fit within thegasLimit), the returned object has a key namedgsEstimateinstead of gasEstimate. This inconsistency causes issues in the calling methodgetActionsAndSimulate, where the object is destructured as:const { gasEstimate, cappedActions } = this.capActionsUsingEstimatedGasUsage(actionsBatch, gasLimit);Because
gsEstimateis returned instead ofgasEstimate, thegasEstimatevariable becomes undefined, leading to potential runtime errors or incorrect behavior in subsequent code that relies on this value such as the logger.Recommendation
Correct the typo in the
capActionsUsingEstimatedGasUsagefunction. -
L-02 Low Possible Inaccuracy In Funding Rate Calculation Due to Clamping Mechanism Warning Acknowledged
Description
The current implementation of the funding rate calculation in the
FundingServiceclass employs a clamping mechanism that sets the funding rate to zero when the absolute value of the average basis (the difference between the mid price and the mark price) is less than or equal to a predefined clamp rate, currently set at 0.01%. This mechanism is designed to filter out noise and prevent overcorrections in the funding rate. However, it introduces significant risks that could result in unfair trading conditions and potential financial losses for traders.The clamping logic operates as follows: if the absolute value of the average basis is less than or equal to the clamp rate, the funding rate is set to zero before any baseline rate is added. If the basis exceeds the clamp rate, the full basis is used in the funding rate calculation, adjusted by the baseline rate, and then capped within a maximum range. The relevant code in the
boundFundingRatemethod is shown below:const clampedRate = Gwei.abs(initialRate) <= clampRate ? 0n : initialRate; const fundingRate = clampedRate + baselineRate; const cappedRate = fundingRate > 0n ? Gwei.min(fundingRate, maxRate) : Gwei.max(fundingRate, -maxRate);This approach poses problems in scenarios where small but persistent price differences occur. For instance, if the basis remains consistently at 0.005%, which is below the clamp rate of 0.01%, the funding rate will be set to zero. This allows the price deviation to persist without any corrective action, potentially enabling one side, such as long positions, to benefit disproportionately from the uncorrected drift. Traders on the opposing side, such as short positions, may perceive this as unfair, as the funding mechanism fails to address these small but consistent deviations.
Recommendation
To mitigate the risks and improve the fairness of the funding rate calculation, a possible approach is to replace the hard clamping mechanism where the funding rate is set to zero with a scaled funding rate that applies even when the basis is below the clamp rate. For instance, the funding rate could be calculated as a fraction of the basis relative to the clamp rate, as shown in the following example:
const scaledRate = (initialRate / clampRate) * someFactor;This method ensures that small deviations still contribute to the funding rate, albeit at a reduced magnitude, preventing persistent uncorrected drifts while maintaining the goal of filtering out noise. The funding rate would then be adjusted by the baseline rate and capped as usual.
-
L-03 Low Standalone OCO Orders Not Supported Logical Error Resolved
Description
According to the Nekuti documentation (https://www.nekuti.com/docs/orders/#one-cancels-the-other-oco), standalone OCO orders should be possible without requiring a trigger from an OTO order. This would be useful if a user already has an active position, and would like to submit a take-profit and a stop-loss together, such that hitting one leg cancels the other.
Based on the current validation in the Sequencer, the first order must be OTO and all other orders in the group would be OCO:
@IsValidOtocoTrigger() @IsBoolean() @IsOptional() @TransformDefault(false) @ApiProperty({ description: 'Set if this order should be the OTO order in a new OTOCO group for triggering stop-loss/take-profit orders (precludes otocoGroup)', default: false, required: false, }) readonly otocoTrigger: boolean = false;Recommendation
Clearly document this to users or work with Nekuti to allow for standalone OCO orders.
-
L-04 Low Potential Manipulation of Funding Rate via Strategic Order Placement Validation Acknowledged
Description
The funding rate calculation is based on the mid price, which is the average of the best bid and best ask prices from the order book. This process involves three key functions:
updateFundingBasis,updateFundingandchargeFunding. Each of these functions run on specific schedules as defined in theDEFAULT_CONFIG. An attacker could manipulate the funding rate by strategically placing and canceling large orders to influence the mid price, but they would need to do this multiple times within an hour to significantly skew the final funding rate. Below, we detail the roles of these functions, their execution frequencies, and how an attacker might exploit this setup.Key functions and their schedules
- updateFundingBasis
- This function calculates the funding basis, which is the difference between the mid price (derived from the order book) and the mark price (from an oracle). It records this basis to reflect real-time market conditions. Runs every 5 seconds, as specified by
basisSchedule:CronExpression.EVERY_5_SECONDSin theDEFAULT_CONFIG. This frequent execution ensures the system captures a series of basis snapshots throughout the hour. The basis values collected here are aggregated later to determine the funding rate.
- updateFunding
- This function aggregates the basis data collected by
updateFundingBasisover the previous hour and calculates the funding rate to be applied. Runs hourly, specifically every 3 seconds within the first 15 seconds of each hour, as defined by hourlyFundingSchedule:'0-15/3 0 * * * *'in theDEFAULT_CONFIG. For example, it executes at 00:00:00, 00:00:03, 00:00:06, etc., up to 00:00:15. It finalizes the funding rate based on the hour’s basis data, preparing it for application to traders’ positions.
- chargeFunding
- This function applies the funding rate calculated by
updateFundingto the trading engine, adjusting traders’ balances accordingly. Also runs hourly, within the same 15-second window at the start of each hour (aligned withupdateFunding), as per the hourlyFundingSchedule. It essentially executes the financial impact of the funding rate on all open positions.
Mid Price Calculation
The mid price is computed by the listMidPriceByProductIds method in the OrderRepository class, averaging the best bid and best ask prices from open orders:
listMidPriceByProductIds(productIds: bigint[], tx: TxCtx) { return tx.trx .selectFrom(this.relName) .select([ 'productId', (eb) => { const bestBid = eb.fn.max('price').filterWhere('side', '=', OrderSide.BUY); const bestAsk = eb.fn.min('price').filterWhere('side', '=', OrderSide.SELL); return eb .case() .when(eb.or([eb(bestBid, 'is', null), eb(bestAsk, 'is', null)])) .then(0) .else(eb(eb.parens(bestBid, '+', bestAsk), '/', eb.val(2).$castTo<string>())) .end() .as('midPrice'); }, ]) .where('productId', 'in', productIds) .where('status', 'in', OPEN_ORDER_STATUSES) .groupBy('productId') .execute(); }This method takes a snapshot of the order book at the moment it’s called, making it sensitive to sudden changes in the best bid or ask prices.
How an attacker could manipulate the funding rate?
An attacker could exploit this system by:
- Placing a large buy or sell order just before an
updateFundingBasisexecution (every 5 seconds) to manipulate the mid price. A large buy order would raise the best bid, increasing the mid price, while a large sell order would lower the best ask, decreasing it. - Since
updateFundingBasisruns every 5 seconds (12 times per minute, or 720 times per hour), the attacker would need to repeat this process multiple times within the hour to meaningfully skew the basis data. The funding rate, calculated byupdateFunding, is an aggregate of these basis values, so a single manipulation would have limited impact. - After each
updateFundingBasissnapshot, the attacker could cancel the order to avoid execution, minimizing financial risk while still influencing the basis.
Impact on traders
A manipulated funding rate could:
- Benefit the attacker: A higher rate might increase payments from short holders to long holders, favoring an attacker with a long position. A lower rate could reduce payments for an attacker with a short position.
- Harm other users: Unaware traders could face unexpected costs or reduced profits due to the artificial funding rate, undermining the system’s fairness.
While the frequent basis updates (every 5 seconds) dilute the effect of a single manipulation, a persistent attacker targeting multiple snapshots could still shift the funding rate noticeably.
Recommendation
To protect the funding rate calculation from this type of manipulation, consider introducing randomness into the timing of these samples, making it difficult for an attacker to predict when their order will influence the calculation. Third, establish monitoring to detect suspicious patterns, such as large orders placed and canceled in sync with known cron job schedules, enabling proactive responses to potential attacks. Fourth, enforce price bands that cap how much the mid price can deviate from recent trends or external reference prices, rejecting outliers that suggest manipulation.
-
L-08 Low Race Condition in Signer Linking Allows Exceeding Quota Race Condition Acknowledged
Description
A race condition exists in the
linkSignermethod oflinked-signer.service.ts, allowing an account to exceed its intended signer linking quota. The method first checks the number of linked signers for an account within a specified period using a thecountByAccountLinkedWithinXDaysfunction, then proceeds to insert a new signer if the count is below the limit. In a concurrent execution scenario, two requests from the same account, each attempting to link a distinct signer, can perform this check at the same time. If both requests evaluate the count before either inserts a new signer, they may both find the count below the limit and proceed to add their signers. Although a uniqueness constraint prevents the same signer address from being linked twice, it does not prevent different signers from being added simultaneously. This flaw enables the account to exceed the quota, bypassing the rate-limiting mechanism designed to control signer linking.Recommendation
To resolve this issue, the period-limit check and signer insertion should be made atomic to eliminate concurrency risks. A practical solution is to wrap the operation in a database transaction with a locking mechanism that serializes access for a given account.
-
L-09 Low 64‑byte Signatures Are Rejected Validation Acknowledged
Description
Some wallets allow creating EIP‑2098 “compact” signatures: 64 bytes signatures where the recovery bit (
v) is packed into the top bit ofs. The sequencer’s current validator allows only legacy 65‑byter‖s‖vsignatures:// Verifies `signature` is signed by `signer` using `message` of type `primaryType`. @AsyncMetricTimer(SignatureMetric.VERIFY_TIME) async verifySignature( signature: Hash, signer: Address, message: Record<string, unknown>, primaryType: SignatureType ) { try { if (size(signature) !== SIGNATURE_LENGTH) { // export const SIGNATURE_LENGTH = 65; throw new SignatureError(`Signature must be ${SIGNATURE_LENGTH} bytes`); } return await verifyTypedData({ address: signer, domain: this.rpcService.domainType, types: this.signTypedDataTypes, primaryType, message, signature, }); } catch (err) { this.logger.error(getLogContext({ signer, signature }), `Bad signature detected. Could not verify ${err}`); return false; } }When a user submits an order or signer link produced by a modern wallet the HTTP API returns
401 Unauthorized Bad signature.Since the contracts does accept 64‑byte signatures (
OpenZeppelin ECDSA.tryRecover()) this check is an unnecessary blocker.Recommendation
Consider accepting both 64 and 65‑byte signatures.
-
L-10 Low Unbounded Sub‑account Creation Validation Acknowledged
Description
Inside
SubaccountService.verifySubaccountAllowSigners()the comment// TODO: Cap the # of subaccounts a user can haveacknowledges that the system imposes no upper limit on how many sub‑accounts a user may register. A determined user can therefore script the creation of tens of thousands of sub‑accounts at negligible cost, each row occupies multiple tables. This unchecked growth inflates database size, bloats pagination queries and degrades every endpoint that filters byaccount.Recommendation
Enforce a hard per‑account quota inside the same transaction that inserts a new sub‑account. The easiest approach is a serialized count check:
await tx.trx.selectFrom('account_lock') .where('address', '=', account) .forUpdate() // serialize concurrent sign‑ups .execute(); const { count } = await tx.trx .selectFrom('subaccount') .select(sql`COUNT(*)`.as('count')) .where('account', '=', account) .executeTakeFirstOrThrow(); const MAX_SUBACCOUNTS = 10; // set by config if (Number(count) >= MAX_SUBACCOUNTS) { throw new TooManyRequestsException(`Maximum of ${MAX_SUBACCOUNTS} sub‑accounts reached`); } await subaccountRepository.create({ name, account, ... }, tx); -
L-11 Low Precision Rounding in PennyJar Dust Metrics Warning Acknowledged
Description
In
engine-vacuum.worker.ts, the PennyJar dust balances (usage.total, abigint) are passed to Datadog viaNumber(usage.total). While each individual “dust” amount is typically well below JavaScript’s safe-integer threshold (2⁵³ − 1), over time the accumulated dust can exceed ~0.009 token (for 18-decimal assets) or millions of units for lower-decimal tokens. Casting a largebigintto aJS numbersilently rounds off low-order bits, causing metric spikes or drops that do not reflect the true balance, hiding real dust accumulation or triggering false alerts.Recommendation
Consider introducing a
gaugeBigInthelper in the metrics layer that accepts abigintand only converts-to-number after safe scaling (e.g. divide by10^decimalsto human units, ensuring the result< 2⁵³).Replace all occurrences of
this.metricService.gauge(EngineMetric.PENNYJAR_BALANCE, Number(usage.total), { token: token.name });with:
this.metricService.gaugeBigInt(EngineMetric.PENNYJAR_BALANCE, usage.total, { token: token.name });Enforce via a lint rule (e.g. “
no-bigint-to-number”) thatNumber(bigint)is disallowed outside of controlled scaling logic. -
L-13 Low Assymetrical Validations For Liquidations DoS Resolved
Description
Function
relayLiquidationsis missing multiple validations that are performed withinliquidateSubaccounton-chain, which may result in relaying a liquidation that will revert and DoS the entire batch of actions being processed.Some of the missing validations include:
products.length == openPositionscost > 0position size + sizeDelta == 0- Liquidator has enough balance
Recommendation
Ensure the validations off-chain are symmetrical with the validations on-chain to prevent actions being relayed that will revert.
-
I-01 Informational Non‑standard Rate‑Limit Headers Validation Resolved
Description
The sequencer uses custom rate-limit headers that differ from the emerging industry standard (IETF draft):
- IP-based limiter (limits by internet address):
response.headers({ 'Retry-After' : retrySec, 'X-RateLimit-Limit' : total, 'X-RateLimit-Remaining': remaining, });- Account-based limiter (limits by user account):
res.setHeader('X-RateLimit-Account-Retry-After', retrySec); res.setHeader('X-RateLimit-Account-Limit', total); res.setHeader('X-RateLimit-Account-Remaining', remaining);The standard expects RateLimit-Limit, RateLimit-Remaining and RateLimit-Reset instead. These headers don’t follow this pattern.
Recommendation
Switch to the standard headers following the IETF draft - https://datatracker.ietf.org/doc/html/draft-ietf-httpapi-ratelimit-headers-08:
response.headers({ 'RateLimit-Limit' : total, 'RateLimit-Remaining' : remaining, 'RateLimit-Reset' : Math.ceil(retrySec), }); -
I-02 Informational Incorrect Return Property Typo Resolved
Description
The object returned from function
capActionsUsingEstimatedGasUsageis{ gsEstimate: sumGasEstimate, cappedActions: actions };but propertygasEstimateis expected rather thangsEstimate(typo):const { gasEstimate, cappedActions } = this.capActionsUsingEstimatedGasUsage(actionsBatch, gasLimit);Consequently,gasEstimatewill be set to undefined and the log context will use that inaccurateundefinedvalue.Recommendation
Within function
capActionsUsingEstimatedGasUsage, change the return object key fromgsEstimatetogasEstimate -
I-03 Informational Number Of Positions Opened Are Not Limited At Smart Contract Level Validation Acknowledged
Description
The sequencer enforces a per‑account cap on simultaneously open positions via the off‑chain call made during bootstrap:
// engine-listen.worker.ts — line ~70 await this.engineService.setMaxPositionsLimit(this.configService.sequencer.account.maxPositions); // sequencer.dto.ts account: { linkSignerLimitInPeriod: 5, linkSignerLimitPeriodDays: 7, linkSignerExpiryDays: 30, maxPositions: 50, },That limit lives only in the back‑end as there is no restriction at smart contract level.
Recommendation
Consider enforcing a cap also at smart contract level. For example, add a state constant (e.g.
uint256 public immutable MAX_POSITIONS = 50;) and insert a guard before every code path that calls _openPosition, _increasePosition or creates a fresh Position struct:require( subaccounts[_subId].openPositionCount < MAX_POSITIONS, "MAX_POSITIONS_EXCEEDED" ); -
I-04 Informational Signer Typo Typo Resolved
Description
The log within
linkSigneris currentlythis.logger.debug(logContext, 'Attempting to link singer');where “singer” should be “signer”.Recommendation
Correct the typo.
-
I-06 Informational Stale Oracle Prices Can Be Used For Funding Calculations Warning Acknowledged
Description
In the
funding.service.tsfile, thegetMarkPricesmethod retrieves mark prices from Pyth to use them in the funding calculations, but it does not adequately handle cases where these prices are stale. A price is considered stale if its age exceeds the definedORACLE_PRICE_MAX_AGE_MS. In such cases, the current implementation logs a warning via this.logger.warn(getLogContext({ ticker: product.ticker, priceAge }), 'Stale cached mark price');but proceeds to return the outdated value withreturn { product, price: Gwei.toGwei(markPrice.price), timestamp: markPrice.timestamp };. This means funding calculations may rely on inaccurate or obsolete data.Recommendation
Consider updating the
funding.service.tsfile to robustly handle stale oracle prices by introducing a configurable option that rejects prices older thanORACLE_PRICE_MAX_AGE_MSand for example, simply consider only the mid price for the new funding rate calculation for that specific asset where the price received is stale. -
I-07 Informational Information Leakage About Orders Warning Acknowledged
Description
The API allows for anyone to call endpoints such as /order/trade, /order/fill, and /order which allows a malicious user to view the orders, fills, trades for any user of Ethereal. This information can be used to figure out whether a specific trader is using hidden orders and there is more dormant liquidity waiting and a variety of other trading advantages that come from knowing if a specific account is creating orders.
Recommendation
Clearly document whether this is intended behavior or consider using an AuthGuard
-
I-08 Informational Whitelist Enabled During Market Activity Logical Error Acknowledged
Description
The Config service has the ability to toggle whether whitelisting is enabled:
async isWhitelisted(account: Address) { if (!this.configService.sequencer.whitelist.enabled) { return true; } const whitelist = await this.getWhitelist(); return whitelist.has(account); }A situation may arise where there is ongoing trading activity, whitelisting validation is enabled without adding users actively trading, and then those users are prevented from managing their orders — ultimately leading to losing trades and fund loss.
Recommendation
Clearly document this behavior and how the whitelisting will be operated.
-
I-09 Informational OCO Orders Created After Trigger Logical Error Acknowledged
Description
After the OTO trigger is executed, all the orders within the group become active OCO orders. In most cases users can keep submitting orders to the group and they will be treated as OCO's within that group, and cancel all other orders if executed.
Note that there are some exceptions to this, such that with TimeInForce IOC for the trigger order, if the trigger was cancelled due to ‘UnfilledImmediateOrCancel’, following orders will be rejected with ‘TriggerCanceled’, rather than just being ‘Pending’. For TimeInForce FOK for the trigger order, even if the trigger was outright cancelled upon submission due to ‘UnfilledFillOrKill’, following conditional orders for that group will indeed be ‘Pending’ OCO orders, which shows that there is some inconsistency with Nekuti’s handling of filled/cancelled trigger orders.
Recommendation
Clearly document this behavior to users.
-
I-10 Informational Excluded Signers Should Be Validated Warning Acknowledged
Description
Within function
linkSigner, a series of validations are performed on the provided user parameters. It may also be prudent to verify the intended linked signer against theexcludedSignersmapping on-chain, to prevent any potential cases where aInvalidSigneris thrown and DoS's a batch.Recommendation
Consider also validating that the signer is not part of
excludedSignerson the contracts -
I-11 Informational Action Batch DoS Resiliency Warning Acknowledged
Description
Within function
capToIndexBeforeActionId, if the first action in the batch is the one triggering an error on-chain then an error, then no actions will be present within the simulatedActions array. Every subsequent call to batchActions() will pick up that same invalid transaction, re-simulate and error again. Consequently, one invalid entry will block the Relay from operating.Furthermore, there may be instances where different workers in the Sequencer crash and on restart already processed actions are attempted to be relayed. This has happened more than once within the local development environment, although is difficult to directly reproduce.
Recommendation
Consider having a privileged ability to mark certain actions a different status and move on to subsequent actions. The order of actions is extremely important, so care must be taken when skipping.
-
I-12 Informational Inconsistent Subaccount Throw Handling Best Practices Acknowledged
Description
Within function revokeSigner does not specify an orThrow parameter:
const subaccount = await this.subaccountService.getByExtId(subaccountExtId, tx);In contrast, function linkSigner does specify the parameter when querying the database:
await this.subaccountRepository.getByExtId(subaccountExtId, tx, { orThrow: true });Recommendation
Consider making the database queries to get the subaccount by external ID symmetric.
-
I-13 Informational Leaves Quantity Naming Best Practices Acknowledged
Description
After each fill, the
leavesQuantityis set to the size of the new position. This naming can be confusing since typical FIX nomenclature has 'leaves' referring to the unfilled portion of an order rather than what was filled.Recommendation
Document that the column refers to position size after the current fill.
-
I-14 Informational Table‑wide EXCLUSIVE Lock On relayerAction Warning Acknowledged
Description
This is the normal operational flow for
RelayerActionRepository.createand where it actually sits in the pipeline:- API layer – Every signed endpoint (
/order/match,/withdraw/initiate, …) ends withRelayerActionService.enqueue(). - Engine listener – The engine‑listen module invokes the same
enqueue()for maker/taker fills, funding deltas, etc. - Relay worker – Periodically reads rows where
status='PENDING', bundles them, and calls the SolidityprocessActions.
Thus every state‑changing event passes through
RelayerActionRepository.create()at line 9 ofrelayer-action.repository.ts:async create(action: Insertable<RelayerAction>, tx: TxCtx) { // Table‑wide lock await this.dbService.lockTable(this.relName, 'EXCLUSIVE', tx.trx); return tx.trx .insertInto(this.relName) .values(action) .returning(['id', 'type']) .executeTakeFirstOrThrow(); }However,
LOCK TABLE ... IN EXCLUSIVE MODEtells PostgreSQL: “no other session maySELECT … FOR UPDATE,INSERT,UPDATEorDELETEuntil I commit.” The lock is therefore acquired for every single row insert, both increate()andcreateBatch(). Under normal traffic this serialises all producers into a single‑threaded critical section. Under burst traffic or a deliberate spam attack, legitimate producers (especially the Engine listener) queue behind the attacker and stall, so the relay never receives fresh actions to send on‑chain.This could cause
PENDINGrows to pile up as relay’s batcher has nothing new to process because the listener cannot insert, order‑fill/withdraw/liquidation confirmations to freeze, front‑end shows them as “pending” indefinitely and funding updates and liquidations miss their on‑chain windows, potentially creating bad debt.Recommendation
Merely informational.
- API layer – Every signed endpoint (
-
I-15 Informational Dockerfile Warning Warning Acknowledged
Description
The sequencer’s Dockerfile does not specify a
USERto run commands as, therefore therootuser will be used by default. This poses several security risks, which can lead to privilege escalation if the container is compromised.Furthermore, there is no
HEALTHCHECKinstruction present in the Dockerfile. This instruction helps keep track of the status of the containerized application, and can improve reliability and availability.Recommendation
Specify a
USERin the Dockerfile other thanrootand implement aHEALTHCHECKinstruction in the Dockerfile. -
C-01 Critical Deposit Fee Not Transferred Logical Error Resolved
Description
Function
_verifyDepositreturns thenormalizedNetAmountwhich is then transferred to the system by the user during a deposit.The
normalizedNetAmounthowever does not include thedepositFee. Consider the following scenario:(1) Alice requests to deposit 100 USDe
(2) The token fee is 10 USDe
(3) A pending deposit is created with amount: 90 USDe, fee: 10 USDe and Alice transfers only 90 USDe
(4) Alice is credited with 90 USDe and Fee collector accrues 10 USDe of fees on
_handleFinalizeDeposit(5) The funds for the fees do not exist since the fees were never deposited. Rather they are taken out of user's deposited collateral.
Recommendation
_verifyDepositshould return thenormalizedAmount
No findings match.
More from Ethereal
Put your code through the same review.
This review started with a conversation about scope. Tell us what you are building and we will plan yours with you.
