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
155 changes: 155 additions & 0 deletions src/exchange.order-v2.builder.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
import type { JsonRpcSigner } from '@ethersproject/providers';
import type { Wallet } from '@ethersproject/wallet';
import {
EIP712_DOMAIN,
PROTOCOL_NAME,
} from './exchange.order.const.ts';
import {
PROTOCOL_VERSION_V2,
ORDER_V2_STRUCTURE,
} from './exchange.order-v2.const.ts';
import type { EIP712TypedData } from './model/eip712.model.ts';
import { hashTypedData } from 'viem';
import type {
OrderV2,
OrderDataV2,
OrderHashV2,
OrderSignatureV2,
SignedOrderV2,
} from './model/order-v2.model.ts';
import { SignatureType } from './model/signature-types.model.ts';
import { generateOrderSalt } from './utils.ts';

const BYTES32_ZERO = '0x0000000000000000000000000000000000000000000000000000000000000000';

export class ExchangeOrderBuilderV2 {
constructor(
private readonly contractAddress: string,
private readonly chainId: number,
private readonly signer: Wallet | JsonRpcSigner,
private readonly generateSalt = generateOrderSalt,
) {}

/**
* Build an order object including the signature.
* @param orderData
* @returns a SignedOrderV2 object (order + signature)
*/
async buildSignedOrder(orderData: OrderDataV2): Promise<SignedOrderV2> {
const order = await this.buildOrder(orderData);
const orderTypedData = this.buildOrderTypedData(order);
const orderSignature = await this.buildOrderSignature(orderTypedData);

return {
...order,
signature: orderSignature,
} as SignedOrderV2;
}

/**
* Creates an OrderV2 object from order data.
* @param orderData
* @returns an OrderV2 object (not signed)
*/
async buildOrder({
maker,
tokenId,
makerAmount,
takerAmount,
side,
signer,
signatureType,
timestamp,
metadata,
builder,
expiration,
}: OrderDataV2): Promise<OrderV2> {
if (typeof signer == 'undefined' || !signer) {
signer = maker;
}

const resolvedSignatureType = signatureType ?? SignatureType.EOA;

// For POLY_DEPOSIT_WALLET, the order's signer field is the wallet
// contract address, while the actual ECDSA signing is done by the EOA owner
if (resolvedSignatureType !== SignatureType.POLY_DEPOSIT_WALLET) {
const signerAddress = await this.signer.getAddress();
if (signer !== signerAddress) {
throw new Error('signer does not match');
}
}

return {
salt: this.generateSalt(),
maker,
signer,
tokenId,
makerAmount,
takerAmount,
side,
signatureType: resolvedSignatureType,
timestamp: timestamp ?? Date.now().toString(),
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Default timestamp uses milliseconds instead of seconds

High Severity

The default timestamp value uses Date.now().toString(), which returns milliseconds since epoch. In blockchain contexts, timestamps are in seconds (block.timestamp is unix seconds). The sibling expiration field is explicitly documented as "unix seconds, '0' = no expiration", strongly indicating timestamp is also expected in seconds. A millisecond value will be ~1000x too large, causing incorrect order timestamps and likely on-chain validation failures.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit f5c8bcc. Configure here.

metadata: metadata ?? BYTES32_ZERO,
builder: builder ?? BYTES32_ZERO,
expiration: expiration ?? '0',
};
}

/**
* Parses an OrderV2 object to EIP712 typed data
* @param order
* @returns a EIP712TypedData object
*/
buildOrderTypedData(order: OrderV2): EIP712TypedData {
return {
primaryType: 'Order',
types: {
EIP712Domain: EIP712_DOMAIN,
Order: ORDER_V2_STRUCTURE,
},
domain: {
name: PROTOCOL_NAME,
version: PROTOCOL_VERSION_V2,
chainId: this.chainId,
verifyingContract: this.contractAddress,
},
message: {
salt: order.salt,
maker: order.maker,
signer: order.signer,
tokenId: order.tokenId,
makerAmount: order.makerAmount,
takerAmount: order.takerAmount,
side: order.side,
signatureType: order.signatureType,
timestamp: order.timestamp,
metadata: order.metadata,
builder: order.builder,
},
};
}

/**
* Generates order's signature from a EIP712TypedData object + the signer address
* @param typedData
* @returns a OrderSignatureV2 string
*/
buildOrderSignature(typedData: EIP712TypedData): Promise<OrderSignatureV2> {
delete typedData.types.EIP712Domain;
return this.signer._signTypedData(
typedData.domain,
typedData.types,
typedData.message,
);
}

/**
* Generates the hash of the order from a EIP712TypedData object.
* @param orderTypedData
* @returns a OrderHashV2 string
*/
buildOrderHash(orderTypedData: EIP712TypedData): OrderHashV2 {
const digest = hashTypedData(orderTypedData);
return digest;
}
}
21 changes: 21 additions & 0 deletions src/exchange.order-v2.const.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// V2 Exchange constants
// Domain name is shared with V1; only the version changes.
export const PROTOCOL_NAME_V2 = 'Polymarket CTF Exchange';
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Exported PROTOCOL_NAME_V2 constant is never used

Low Severity

PROTOCOL_NAME_V2 is exported but never imported or referenced anywhere in the codebase. The V2 builder imports PROTOCOL_NAME from the V1 constants file instead. The comment notes the domain name is shared with V1, making this constant redundant with the identically-valued PROTOCOL_NAME. Having both creates a maintenance risk where they could diverge unintentionally.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit f5c8bcc. Configure here.

export const PROTOCOL_VERSION_V2 = '2';

// V2 Order EIP-712 struct.
// Note: `expiration` is intentionally absent — it is an API-level field,
// not part of the on-chain EIP-712 signature.
export const ORDER_V2_STRUCTURE = [
{ name: 'salt', type: 'uint256' },
{ name: 'maker', type: 'address' },
{ name: 'signer', type: 'address' },
{ name: 'tokenId', type: 'uint256' },
{ name: 'makerAmount', type: 'uint256' },
{ name: 'takerAmount', type: 'uint256' },
{ name: 'side', type: 'uint8' },
{ name: 'signatureType', type: 'uint8' },
{ name: 'timestamp', type: 'uint256' },
{ name: 'metadata', type: 'bytes32' },
{ name: 'builder', type: 'bytes32' },
];
3 changes: 3 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
export * from './exchange.order.builder.ts';
export * from './exchange.order.const.ts';
export * from './exchange.order-v2.builder.ts';
export * from './exchange.order-v2.const.ts';

export * from './model/abi.model.ts';
export * from './model/eip712.model.ts';
export * from './model/order.model.ts';
export * from './model/order-v2.model.ts';
export * from './model/order-side.model.ts';
export * from './model/signature-types.model.ts';
137 changes: 137 additions & 0 deletions src/model/order-v2.model.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import type { EIP712Object } from './eip712.model.ts';
import type { Side } from './order-side.model.ts';
import type { SignatureType } from './signature-types.model.ts';

export type OrderSignatureV2 = string;

export type OrderHashV2 = string;

export interface OrderDataV2 {
/**
* Maker of the order, i.e the source of funds for the order
*/
maker: string;

/**
* Token Id of the CTF ERC1155 asset to be bought or sold.
* If BUY, this is the tokenId of the asset to be bought, i.e the makerAssetId
* If SELL, this is the tokenId of the asset to be sold, i.e the takerAssetId
*/
tokenId: string;

/**
* Maker amount, i.e the max amount of tokens to be sold
*/
makerAmount: string;

/**
* Taker amount, i.e the minimum amount of tokens to be received
*/
takerAmount: string;

/**
* The side of the order, BUY or SELL
*/
side: Side;

/**
* Signer of the order. Optional, if it is not present the signer is the maker of the order.
*/
signer?: string;

/**
* Signature type used by the Order. Default value 'EOA'
*/
signatureType?: SignatureType;

/**
* Timestamp of the order
*/
timestamp?: string;

/**
* Metadata of the order (bytes32)
*/
metadata?: string;

/**
* Builder of the order (bytes32)
*/
builder?: string;

/**
* Expiration timestamp of the order (unix seconds, "0" = no expiration)
*/
expiration?: string;
}

export interface OrderV2 extends EIP712Object {
/**
* Unique salt to ensure entropy
*/
readonly salt: string;

/**
* Maker of the order, i.e the source of funds for the order
*/
readonly maker: string;

/**
* Signer of the order
*/
readonly signer: string;

/**
* Token Id of the CTF ERC1155 asset to be bought or sold.
* If BUY, this is the tokenId of the asset to be bought, i.e the makerAssetId
* If SELL, this is the tokenId of the asset to be sold, i.e the takerAssetId
*/
readonly tokenId: string;

/**
* Maker amount, i.e the max amount of tokens to be sold
*/
readonly makerAmount: string;

/**
* Taker amount, i.e the minimum amount of tokens to be received
*/
readonly takerAmount: string;

/**
* The side of the order, BUY or SELL
*/
readonly side: Side;

/**
* Signature type used by the Order
*/
readonly signatureType: SignatureType;

/**
* Timestamp of the order
*/
readonly timestamp: string;

/**
* Metadata of the order (bytes32)
*/
readonly metadata: string;

/**
* Builder of the order (bytes32)
*/
readonly builder: string;

/**
* Expiration timestamp of the order (unix seconds, "0" = no expiration)
*/
readonly expiration: string;
}

export interface SignedOrderV2 extends OrderV2 {
/**
* The order signature
*/
readonly signature: OrderSignatureV2;
}
6 changes: 6 additions & 0 deletions src/model/signature-types.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,10 @@ export enum SignatureType {
* EIP712 signatures signed by EOAs that own Polymarket Gnosis safes
*/
POLY_GNOSIS_SAFE,

/**
* EIP712 signatures signed by EOAs that own Polymarket Deposit Wallets.
* Validated on-chain via ERC-1271 isValidSignature() with ERC-7739 nested domain wrapping.
*/
POLY_DEPOSIT_WALLET,
}
Loading