Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/harden-rfq-invalid-frames.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@polymarket/client": patch
---

Harden RFQ quoter WebSocket handling for unknown and malformed inbound frames.
245 changes: 125 additions & 120 deletions packages/bindings/src/rfq.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,45 +165,60 @@ export type RfqAuthMessage = {
};
};

export const RfqAuthResponseMessageSchema = z.object({
type: z.literal('auth'),
success: z.boolean(),
address: EvmAddressSchema.optional(),
role: z.string().optional(),
error: z.string().optional(),
export enum RfqKnownInboundType {
Auth = 'auth',
QuoteRequest = 'RFQ_REQUEST',
QuoteAck = 'ACK_RFQ_QUOTE',
QuoteCancelAck = 'ACK_RFQ_QUOTE_CANCEL',
ConfirmationRequest = 'RFQ_CONFIRMATION_REQUEST',
ConfirmationAck = 'ACK_RFQ_CONFIRMATION_RESPONSE',
ExecutionUpdate = 'RFQ_EXECUTION_UPDATE',
Error = 'RFQ_ERROR',
}

export const RfqKnownInboundMessageSchema = z.object({
type: z.enum(RfqKnownInboundType),
});

export const RfqAuthResponseMessageSchema = RfqKnownInboundMessageSchema.extend(
{
type: z.literal(RfqKnownInboundType.Auth),
success: z.boolean(),
address: EvmAddressSchema.optional(),
role: z.string().optional(),
error: z.string().optional(),
},
);

export type RfqAuthResponseMessage = z.infer<
typeof RfqAuthResponseMessageSchema
>;

export const RfqQuoteRequestSchema = z
.object({
type: z.literal('RFQ_REQUEST'),
rfq_id: RfqIdSchema,
requestor_public_id: RfqRequestorPublicIdSchema,
leg_position_ids: z.array(PositionIdSchema),
condition_id: ConditionIdSchema,
yes_position_id: PositionIdSchema,
no_position_id: PositionIdSchema,
direction: RfqDirectionSchema,
side: RfqSideSchema,
requested_size: RfqRequestedSizeSchema,
submission_deadline: EpochMillisecondsSchema,
})
.transform((message) => ({
conditionId: message.condition_id,
direction: message.direction,
legPositionIds: message.leg_position_ids,
noPositionId: message.no_position_id,
requestorPublicId: message.requestor_public_id,
requestedSize: message.requested_size,
rfqId: message.rfq_id,
side: message.side,
submissionDeadline: message.submission_deadline,
type: 'quote_request' as const,
yesPositionId: message.yes_position_id,
}));
export const RfqQuoteRequestSchema = RfqKnownInboundMessageSchema.extend({
type: z.literal(RfqKnownInboundType.QuoteRequest),
rfq_id: RfqIdSchema,
requestor_public_id: RfqRequestorPublicIdSchema,
leg_position_ids: z.array(PositionIdSchema),
condition_id: ConditionIdSchema,
yes_position_id: PositionIdSchema,
no_position_id: PositionIdSchema,
direction: RfqDirectionSchema,
side: RfqSideSchema,
requested_size: RfqRequestedSizeSchema,
submission_deadline: EpochMillisecondsSchema,
}).transform((message) => ({
conditionId: message.condition_id,
direction: message.direction,
legPositionIds: message.leg_position_ids,
noPositionId: message.no_position_id,
requestorPublicId: message.requestor_public_id,
requestedSize: message.requested_size,
rfqId: message.rfq_id,
side: message.side,
submissionDeadline: message.submission_deadline,
type: 'quote_request' as const,
yesPositionId: message.yes_position_id,
}));

export type RfqQuoteRequest = z.infer<typeof RfqQuoteRequestSchema>;

Expand All @@ -223,37 +238,33 @@ export type RfqQuoteCancelMessage = {
maker_address: EvmAddress;
};

export const RfqQuoteAckSchema = z
.object({
type: z.literal('ACK_RFQ_QUOTE'),
rfq_id: RfqIdSchema,
quote_id: RfqQuoteIdSchema,
})
.transform((message) => ({
quoteId: message.quote_id,
rfqId: message.rfq_id,
type: 'quote_ack' as const,
}));
export const RfqQuoteAckSchema = RfqKnownInboundMessageSchema.extend({
type: z.literal(RfqKnownInboundType.QuoteAck),
rfq_id: RfqIdSchema,
quote_id: RfqQuoteIdSchema,
}).transform((message) => ({
quoteId: message.quote_id,
rfqId: message.rfq_id,
type: 'quote_ack' as const,
}));

export type RfqQuoteAck = z.infer<typeof RfqQuoteAckSchema>;

export const RfqQuoteCancelAckSchema = z
.object({
type: z.literal('ACK_RFQ_QUOTE_CANCEL'),
rfq_id: RfqIdSchema,
quote_id: RfqQuoteIdSchema,
})
.transform((message) => ({
quoteId: message.quote_id,
rfqId: message.rfq_id,
type: 'quote_cancel_ack' as const,
}));
export const RfqQuoteCancelAckSchema = RfqKnownInboundMessageSchema.extend({
type: z.literal(RfqKnownInboundType.QuoteCancelAck),
rfq_id: RfqIdSchema,
quote_id: RfqQuoteIdSchema,
}).transform((message) => ({
quoteId: message.quote_id,
rfqId: message.rfq_id,
type: 'quote_cancel_ack' as const,
}));

export type RfqQuoteCancelAck = z.infer<typeof RfqQuoteCancelAckSchema>;

export const RfqConfirmationRequestSchema = z
.object({
type: z.literal('RFQ_CONFIRMATION_REQUEST'),
export const RfqConfirmationRequestSchema = RfqKnownInboundMessageSchema.extend(
{
type: z.literal(RfqKnownInboundType.ConfirmationRequest),
rfq_id: RfqIdSchema,
quote_id: RfqQuoteIdSchema,
signer_address: EvmAddressSchema,
Expand All @@ -268,24 +279,24 @@ export const RfqConfirmationRequestSchema = z
fill_size_e6: BigIntStringToDecimalStringSchema,
price_e6: BigIntStringToDecimalStringSchema,
confirm_by: EpochMillisecondsSchema,
})
.transform((message) => ({
conditionId: message.condition_id,
confirmBy: message.confirm_by,
direction: message.direction,
fillSize: message.fill_size_e6,
legPositionIds: message.leg_position_ids,
makerAddress: message.maker_address,
noPositionId: message.no_position_id,
price: message.price_e6,
quoteId: message.quote_id,
rfqId: message.rfq_id,
side: message.side,
signatureType: message.signature_type,
signerAddress: message.signer_address,
type: 'confirmation_request' as const,
yesPositionId: message.yes_position_id,
}));
},
).transform((message) => ({
conditionId: message.condition_id,
confirmBy: message.confirm_by,
direction: message.direction,
fillSize: message.fill_size_e6,
legPositionIds: message.leg_position_ids,
makerAddress: message.maker_address,
noPositionId: message.no_position_id,
price: message.price_e6,
quoteId: message.quote_id,
rfqId: message.rfq_id,
side: message.side,
signatureType: message.signature_type,
signerAddress: message.signer_address,
type: 'confirmation_request' as const,
yesPositionId: message.yes_position_id,
}));

export type RfqConfirmationRequest = z.infer<
typeof RfqConfirmationRequestSchema
Expand All @@ -298,56 +309,50 @@ export type RfqConfirmationResponseMessage = {
decision: RfqConfirmationDecision;
};

export const RfqConfirmationAckSchema = z
.object({
type: z.literal('ACK_RFQ_CONFIRMATION_RESPONSE'),
rfq_id: RfqIdSchema,
quote_id: RfqQuoteIdSchema,
decision: RfqConfirmationDecisionSchema,
})
.transform((message) => ({
decision: message.decision,
quoteId: message.quote_id,
rfqId: message.rfq_id,
type: 'confirmation_ack' as const,
}));
export const RfqConfirmationAckSchema = RfqKnownInboundMessageSchema.extend({
type: z.literal(RfqKnownInboundType.ConfirmationAck),
rfq_id: RfqIdSchema,
quote_id: RfqQuoteIdSchema,
decision: RfqConfirmationDecisionSchema,
}).transform((message) => ({
decision: message.decision,
quoteId: message.quote_id,
rfqId: message.rfq_id,
type: 'confirmation_ack' as const,
}));

export type RfqConfirmationAck = z.infer<typeof RfqConfirmationAckSchema>;

export const RfqExecutionUpdateSchema = z
.object({
type: z.literal('RFQ_EXECUTION_UPDATE'),
rfq_id: RfqIdSchema,
status: RfqExecutionStatusSchema,
tx_hash: TxHashSchema.optional(),
})
.transform((message) => ({
rfqId: message.rfq_id,
status: message.status,
...(message.tx_hash === undefined ? {} : { txHash: message.tx_hash }),
type: 'execution_update' as const,
}));
export const RfqExecutionUpdateSchema = RfqKnownInboundMessageSchema.extend({
type: z.literal(RfqKnownInboundType.ExecutionUpdate),
rfq_id: RfqIdSchema,
status: RfqExecutionStatusSchema,
tx_hash: TxHashSchema.optional(),
}).transform((message) => ({
rfqId: message.rfq_id,
status: message.status,
...(message.tx_hash === undefined ? {} : { txHash: message.tx_hash }),
type: 'execution_update' as const,
}));

export type RfqExecutionUpdate = z.infer<typeof RfqExecutionUpdateSchema>;

export const RfqErrorMessageSchema = z
.object({
type: z.literal('RFQ_ERROR'),
request_type: z.string().optional(),
rfq_id: RfqIdSchema.optional(),
quote_id: RfqQuoteIdSchema.optional(),
code: RfqErrorCodeSchema,
error: z.string(),
request: z.unknown().optional(),
})
.transform((message) => ({
code: message.code,
message: message.error,
quoteId: message.quote_id,
requestType: message.request_type,
rfqId: message.rfq_id,
type: 'rfq_error' as const,
}));
export const RfqErrorMessageSchema = RfqKnownInboundMessageSchema.extend({
type: z.literal(RfqKnownInboundType.Error),
request_type: z.string().optional(),
rfq_id: RfqIdSchema.optional(),
quote_id: RfqQuoteIdSchema.optional(),
code: RfqErrorCodeSchema,
error: z.string(),
request: z.unknown().optional(),
}).transform((message) => ({
code: message.code,
message: message.error,
quoteId: message.quote_id,
requestType: message.request_type,
rfqId: message.rfq_id,
type: 'rfq_error' as const,
}));

export type RfqErrorMessage = z.infer<typeof RfqErrorMessageSchema>;

Expand Down
25 changes: 23 additions & 2 deletions packages/client/src/websockets/rfq/quoter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
type RfqConfirmationDecision,
type RfqErrorMessage,
type RfqId,
RfqKnownInboundMessageSchema,
type RfqQuoteId,
type RfqQuoteRequest,
RfqQuoterInboundMessageSchema,
Expand Down Expand Up @@ -47,6 +48,7 @@ import { createRfqQuote, parseRfqQuoteResponse } from './quote';

const AUTH_TIMEOUT_MS = 30_000;
const ACK_TIMEOUT_MS = 30_000;
const RFQ_WEBSOCKET_CLOSED_ERROR = 'RFQ quoter websocket closed.';

export type RfqQuoterWebSocketManagerOptions = {
account: AccountIdentity;
Expand Down Expand Up @@ -223,23 +225,42 @@ class RfqWebSocketSession implements RfqSession, RfqEventController {
}

async #shutdown(): Promise<void> {
this.#pending.rejectAll(new TransportError('RFQ quoter websocket closed.'));
const error = new TransportError(RFQ_WEBSOCKET_CLOSED_ERROR);
await this.#shutdownWithError(error);
}

async #fail(error: Error): Promise<void> {
if (this.#closing === undefined) {
this.#closing = this.#shutdownWithError(error);
}
await this.#closing;
}

async #shutdownWithError(error: Error): Promise<void> {
this.#failPending(error);
this.#queue.end();
await this.#connection.close();
this.#onClose();
}

#failPending(error: Error): void {
this.#auth?.reject(error);
this.#pending.rejectAll(error);
}

#sendAuthMessage(): void {
this.#connection.send(createAuthMessage(this.#account, this.#credentials));
}

#handleMessage(rawMessage: unknown): void {
if (!RfqKnownInboundMessageSchema.safeParse(rawMessage).success) return;

const parsed = RfqQuoterInboundMessageSchema.safeParse(rawMessage);
if (!parsed.success) {
const error = new TransportError('Invalid RFQ quoter message.', {
cause: parsed.error,
});
this.#pending.rejectAll(error);
void this.#fail(error);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Frames handled after shutdown

Medium Severity

When a malformed known inbound frame triggers #fail, the session marks #closing, rejects pending work, and ends the event queue, but #handleMessage never checks that state. Additional frames can still arrive and be parsed until the WebSocket finishes closing, so events may be pushed or acks resolved after the session was supposed to be dead.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 1220e8c. Configure here.

return;
}

Expand Down
14 changes: 14 additions & 0 deletions packages/client/tests/integration/rfq-frames.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,20 @@ export function confirmationAckMessage(decision: string) {
return JSON.stringify(confirmationAckFrame(decision));
}

export function malformedQuoteAckMessage() {
return JSON.stringify({
rfq_id: RFQ_ID,
type: 'ACK_RFQ_QUOTE',
});
}

export function unknownRfqMessage() {
return JSON.stringify({
payload: 'ignored',
type: 'RFQ_FUTURE_MESSAGE',
});
}

function executionUpdateFrame() {
return {
rfq_id: RFQ_ID,
Expand Down
Loading
Loading