From 6516128848fc1896b62d1019d576aa19c83b599a Mon Sep 17 00:00:00 2001 From: Cesare Naldi <3353250+cesarenaldi@users.noreply.github.com> Date: Fri, 5 Jun 2026 10:49:11 +0200 Subject: [PATCH 1/4] feat(client): support combo position lifecycle --- .changeset/combo-split-merge-legs.md | 5 + .changeset/list-combo-positions.md | 6 + AGENTS.md | 1 + packages/bindings/src/data/portfolio.ts | 162 +++++++ packages/bindings/src/shared.ts | 8 +- packages/client/src/abis.ts | 135 ++++++ packages/client/src/actions/portfolio.ts | 111 ++++- packages/client/src/actions/positions.ts | 456 ++++++++++++++++-- packages/client/src/decorators/account.ts | 41 ++ packages/client/src/decorators/wallet.ts | 32 +- packages/client/src/environments.ts | 15 + packages/client/src/protocol.test.ts | 56 +++ packages/client/src/protocol.ts | 119 +++++ .../tests/integration/positions.test.ts | 262 +++++++--- packages/types/src/helpers.ts | 2 + 15 files changed, 1301 insertions(+), 110 deletions(-) create mode 100644 .changeset/combo-split-merge-legs.md create mode 100644 .changeset/list-combo-positions.md create mode 100644 packages/client/src/protocol.test.ts create mode 100644 packages/client/src/protocol.ts diff --git a/.changeset/combo-split-merge-legs.md b/.changeset/combo-split-merge-legs.md new file mode 100644 index 0000000..0226ae1 --- /dev/null +++ b/.changeset/combo-split-merge-legs.md @@ -0,0 +1,5 @@ +--- +"@polymarket/client": patch +--- + +Add support for splitting and merging combo positions by legs. diff --git a/.changeset/list-combo-positions.md b/.changeset/list-combo-positions.md new file mode 100644 index 0000000..d180b88 --- /dev/null +++ b/.changeset/list-combo-positions.md @@ -0,0 +1,6 @@ +--- +"@polymarket/bindings": patch +"@polymarket/client": patch +--- + +Add `listComboPositions` for fetching combo positions with typed response bindings and SDK-owned pagination. diff --git a/AGENTS.md b/AGENTS.md index 0ba81a5..55c6dbe 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -54,6 +54,7 @@ - When translating one public error into another at an action boundary, prefer `ResultAsync.mapErr(...)` on the request pipeline over `try`/`catch` around `unwrap(...)` when the remap can stay inside the result chain. - When an action starts calling another action, awaiting a workflow step, waiting on a transaction handle, or otherwise adding a new operation that can throw, validate the containing action's public `...Error` union and runtime `makeErrorGuard(...)`. Add any newly propagated public errors unless they are caught, remapped, or intentionally handled before crossing the action boundary. - Prefer TypeScript enums with `z.enum(MyEnum)` over `z.union([z.literal(...), ...])` for string-valued sets. This gives consumers dot-notation access, keeps the schema and type in sync, and avoids `z.nativeEnum` which is deprecated in Zod v4. +- Document abstractions at their own layer. Lower-level types, helpers, and modules should describe their own contract, invariants, and direct behavior, not higher-level consumers that happen to compose them. - In TSDoc `@example` blocks, do not include import statements. Keep examples focused on usage only. - Public TSDoc must not mention underlying service boundaries such as Gamma, CLOB, Data API, or relayer. Public docs should describe the unified SDK surface, while tests may mention the underlying services when useful. - For any public SDK function export, including actions and client methods, document the public thrown-error surface explicitly. Export a flattened `...Error` union of the concrete public error types the function can throw through its public contract, dedupe the union, and do not include internal assertion-style errors such as `InvariantError` in that union. diff --git a/packages/bindings/src/data/portfolio.ts b/packages/bindings/src/data/portfolio.ts index a4e5a84..0909ba9 100644 --- a/packages/bindings/src/data/portfolio.ts +++ b/packages/bindings/src/data/portfolio.ts @@ -4,11 +4,23 @@ import { DecimalishSchema, EpochSecondsToMillisecondsSchema, IsoCalendarDateStringSchema, + IsoDateTimeStringSchema, MixedDateTimeStringSchema, + PaginationCursorSchema, + PositionIdSchema, TokenIdSchema, } from '../shared'; import { AddressSchema } from './common'; +export enum ComboPositionStatus { + Open = 'OPEN', + Partial = 'PARTIAL', + ResolvedWin = 'RESOLVED_WIN', + ResolvedLoss = 'RESOLVED_LOSS', +} + +export const ComboPositionStatusSchema = z.enum(ComboPositionStatus); + export const PositionSchema = z .object({ proxyWallet: AddressSchema.nullish(), @@ -107,6 +119,147 @@ export const MetaMarketPositionSchema = z.object({ positions: z.array(MarketPositionSchema).nullish(), }); +export const ComboPositionMarketEventSchema = z + .object({ + event_id: z.string().nullish(), + event_slug: z.string().nullish(), + event_title: z.string().nullish(), + event_image: z.string().nullish(), + }) + .transform(({ event_id, event_slug, event_title, event_image }) => ({ + eventId: event_id, + eventSlug: event_slug, + eventTitle: event_title, + eventImage: event_image, + })); + +export const ComboPositionMarketSchema = z + .object({ + market_id: z.string().nullish(), + slug: z.string().nullish(), + title: z.string().nullish(), + outcome: z.string().nullish(), + image_url: z.string().nullish(), + icon_url: z.string().nullish(), + category: z.string().nullish(), + subcategory: z.string().nullish(), + tags: z.array(z.string()).nullish(), + end_date: IsoDateTimeStringSchema.nullish(), + event: ComboPositionMarketEventSchema.nullish(), + }) + .transform(({ market_id, image_url, icon_url, end_date, ...rest }) => ({ + ...rest, + marketId: market_id, + imageUrl: image_url, + iconUrl: icon_url, + endDate: end_date, + })); + +export const ComboPositionLegSchema = z + .object({ + leg_index: z.number().int(), + leg_position_id: PositionIdSchema, + leg_condition_id: ConditionIdSchema, + leg_outcome_index: z.number().int(), + leg_outcome_label: z.string().nullish(), + leg_status: ComboPositionStatusSchema, + leg_resolved_at: IsoDateTimeStringSchema.nullish(), + leg_current_price: DecimalishSchema.nullish(), + market: ComboPositionMarketSchema.nullish(), + }) + .transform( + ({ + leg_index, + leg_position_id, + leg_condition_id, + leg_outcome_index, + leg_outcome_label, + leg_status, + leg_resolved_at, + leg_current_price, + ...rest + }) => ({ + ...rest, + legIndex: leg_index, + legPositionId: leg_position_id, + legConditionId: leg_condition_id, + legOutcomeIndex: leg_outcome_index, + legOutcomeLabel: leg_outcome_label, + legStatus: leg_status, + legResolvedAt: leg_resolved_at, + legCurrentPrice: leg_current_price, + }), + ); + +export const ComboPositionSchema = z + .object({ + combo_condition_id: ConditionIdSchema, + combo_position_id: PositionIdSchema, + module_id: z.number().int(), + user_address: AddressSchema, + shares_balance: DecimalishSchema, + entry_avg_price_usdc: DecimalishSchema.nullish(), + entry_cost_usdc: DecimalishSchema.nullish(), + status: ComboPositionStatusSchema, + first_entry_at: IsoDateTimeStringSchema, + resolved_at: IsoDateTimeStringSchema.nullish(), + legs_total: z.number().int(), + legs_resolved: z.number().int(), + legs_pending: z.number().int(), + legs: z.array(ComboPositionLegSchema), + }) + .transform( + ({ + combo_condition_id, + combo_position_id, + module_id, + user_address, + shares_balance, + entry_avg_price_usdc, + entry_cost_usdc, + first_entry_at, + resolved_at, + legs_total, + legs_resolved, + legs_pending, + ...rest + }) => ({ + ...rest, + conditionId: combo_condition_id, + positionId: combo_position_id, + moduleId: module_id, + userAddress: user_address, + shares: shares_balance, + entryAvgPriceUsdc: entry_avg_price_usdc, + entryCostUsdc: entry_cost_usdc, + firstEntryAt: first_entry_at, + resolvedAt: resolved_at, + legsTotal: legs_total, + legsResolved: legs_resolved, + legsPending: legs_pending, + }), + ); + +export const ListComboPositionsResponseSchema = z + .object({ + combos: z.array(ComboPositionSchema), + pagination: z.object({ + limit: z.number().int(), + offset: z.number().int(), + has_more: z.boolean(), + next_cursor: PaginationCursorSchema.nullish(), + }), + }) + .transform(({ pagination, ...rest }) => ({ + ...rest, + pagination: { + limit: pagination.limit, + offset: pagination.offset, + hasMore: pagination.has_more, + nextCursor: pagination.next_cursor, + }, + })); + export const ListPositionsResponseSchema = z.array(PositionSchema); export const ListClosedPositionsResponseSchema = z.array(ClosedPositionSchema); export const FetchPortfolioValueResponseSchema = z.array(ValueSchema); @@ -119,6 +272,12 @@ export type ClosedPosition = z.infer; export type Value = z.infer; export type MarketPosition = z.infer; export type MetaMarketPosition = z.infer; +export type ComboPositionMarketEvent = z.infer< + typeof ComboPositionMarketEventSchema +>; +export type ComboPositionMarket = z.infer; +export type ComboPositionLeg = z.infer; +export type ComboPosition = z.infer; export type ListPositionsResponse = z.infer; export type ListClosedPositionsResponse = z.infer< typeof ListClosedPositionsResponseSchema @@ -129,3 +288,6 @@ export type FetchPortfolioValueResponse = z.infer< export type ListMarketPositionsResponse = z.infer< typeof ListMarketPositionsResponseSchema >; +export type ListComboPositionsResponse = z.infer< + typeof ListComboPositionsResponseSchema +>; diff --git a/packages/bindings/src/shared.ts b/packages/bindings/src/shared.ts index b25b3d8..7ff1182 100644 --- a/packages/bindings/src/shared.ts +++ b/packages/bindings/src/shared.ts @@ -118,7 +118,13 @@ export function toCommentId(value: string): CommentId { } export function toConditionId(value: string): ConditionId { - return to32ByteHexString(value) as ConditionId; + if (!isHexString(value) || (value.length !== 64 && value.length !== 66)) { + throw new TypeError( + `Expected a 31-byte or 32-byte hex string, received: ${value}`, + ); + } + + return value as ConditionId; } export function toCollectionId(value: string): CollectionId { diff --git a/packages/client/src/abis.ts b/packages/client/src/abis.ts index 08382b9..71ab518 100644 --- a/packages/client/src/abis.ts +++ b/packages/client/src/abis.ts @@ -2,8 +2,12 @@ import type { ConditionId } from '@polymarket/bindings'; import { type EvmAddress, type HexString, invariant } from '@polymarket/types'; import { AbiFunction, AbiParameters } from 'ox'; import { makeErrorGuard, UserInputError } from './errors'; +import type { CanonicalComboLegs } from './protocol'; import type { TransactionCall } from './types'; +const BYTES31_HEX_LENGTH = 64; +const PROTOCOL_V2_CONDITION_ID_BYTES31_PATTERN = /^0x[0-9a-fA-F]{62}$/; +const PROTOCOL_V2_CONDITION_ID_BYTES32_PATTERN = /^0x[0-9a-fA-F]{64}$/; const ZERO_BYTES32 = '0x0000000000000000000000000000000000000000000000000000000000000000'; const ERC20_APPROVE_FUNCTION = AbiFunction.from( @@ -30,6 +34,18 @@ const CTF_MERGE_POSITIONS_FUNCTION = AbiFunction.from( const CTF_REDEEM_POSITIONS_FUNCTION = AbiFunction.from( 'function redeemPositions(address collateralToken, bytes32 parentCollectionId, bytes32 conditionId, uint256[] indexSets)', ); +const ROUTER_SPLIT_FUNCTION = AbiFunction.from( + 'function split(bytes31 conditionId, uint256 amount)', +); +const ROUTER_MERGE_FUNCTION = AbiFunction.from( + 'function merge(bytes31 conditionId, uint256 amount)', +); +const ROUTER_REDEEM_FUNCTION = AbiFunction.from( + 'function redeem(bytes31 conditionId, uint256 outcomeIndex, uint256 amount)', +); +const COMBINATORIAL_MODULE_PREPARE_CONDITION_FUNCTION = AbiFunction.from( + 'function prepareCondition(uint256[] legs) returns (bytes31)', +); const SAFE_MULTISEND_FUNCTION = AbiFunction.from('function multiSend(bytes)'); const PROXY_FACTORY_FUNCTION = AbiFunction.from( 'function proxy((uint8 typeCode, address to, uint256 value, bytes data)[] calls) returns (bytes[])', @@ -221,6 +237,100 @@ export function ctfRedeemPositionsCall( }; } +export type SplitV2CallError = UserInputError; +export const SplitV2CallError = makeErrorGuard(UserInputError); + +/** + * Creates a transaction call for protocol v2 Router `split(bytes31,uint256)`. + * + * @throws {@link SplitV2CallError} + * Thrown when the condition ID or amount is invalid. + */ +export function splitV2Call( + routerAddress: EvmAddress, + conditionId: ConditionId, + amount: bigint, +): TransactionCall { + return { + data: AbiFunction.encodeData(ROUTER_SPLIT_FUNCTION, [ + normalizeProtocolV2ConditionId(conditionId), + expectUint256(amount, 'Split amount'), + ]), + to: routerAddress, + }; +} + +export type MergeV2CallError = UserInputError; +export const MergeV2CallError = makeErrorGuard(UserInputError); + +/** + * Creates a transaction call for protocol v2 Router `merge(bytes31,uint256)`. + * + * @throws {@link MergeV2CallError} + * Thrown when the condition ID or amount is invalid. + */ +export function mergeV2Call( + routerAddress: EvmAddress, + conditionId: ConditionId, + amount: bigint, +): TransactionCall { + return { + data: AbiFunction.encodeData(ROUTER_MERGE_FUNCTION, [ + normalizeProtocolV2ConditionId(conditionId), + expectUint256(amount, 'Merge amount'), + ]), + to: routerAddress, + }; +} + +export type RedeemV2CallError = UserInputError; +export const RedeemV2CallError = makeErrorGuard(UserInputError); + +/** + * Creates a transaction call for protocol v2 Router `redeem(bytes31,uint256,uint256)`. + * + * @throws {@link RedeemV2CallError} + * Thrown when the condition ID, outcome index, or amount is invalid. + */ +export function redeemV2Call( + routerAddress: EvmAddress, + conditionId: ConditionId, + outcomeIndex: 0 | 1, + amount: bigint, +): TransactionCall { + return { + data: AbiFunction.encodeData(ROUTER_REDEEM_FUNCTION, [ + normalizeProtocolV2ConditionId(conditionId), + BigInt(expectProtocolV2OutcomeIndex(outcomeIndex)), + expectUint256(amount, 'Redeem amount'), + ]), + to: routerAddress, + }; +} + +export type CombinatorialPrepareConditionCallError = UserInputError; +export const CombinatorialPrepareConditionCallError = + makeErrorGuard(UserInputError); + +/** + * Creates a transaction call for CombinatorialModule `prepareCondition(uint256[])`. + * + * @throws {@link CombinatorialPrepareConditionCallError} + * Thrown when any leg position ID is invalid. + */ +export function combinatorialPrepareConditionCall( + combinatorialModuleAddress: EvmAddress, + legs: CanonicalComboLegs, +): TransactionCall { + return { + data: AbiFunction.encodeData( + COMBINATORIAL_MODULE_PREPARE_CONDITION_FUNCTION, + [legs.map((leg) => expectUint256(leg, 'Leg position ID'))], + ), + to: combinatorialModuleAddress, + }; +} + function encodeErc20ApproveCall( spender: EvmAddress, amount: bigint, @@ -299,6 +409,31 @@ function expectUint256(value: bigint, label: string): bigint { return value; } +function normalizeProtocolV2ConditionId(conditionId: string): HexString { + if (PROTOCOL_V2_CONDITION_ID_BYTES31_PATTERN.test(conditionId)) { + return conditionId.toLowerCase() as HexString; + } + + if (PROTOCOL_V2_CONDITION_ID_BYTES32_PATTERN.test(conditionId)) { + const normalized = conditionId.toLowerCase(); + if (normalized.endsWith('00')) { + return normalized.slice(0, BYTES31_HEX_LENGTH) as HexString; + } + } + + throw new UserInputError( + 'Protocol v2 condition ID must be bytes31, or bytes32 with a zero outcome byte', + ); +} + +function expectProtocolV2OutcomeIndex(outcomeIndex: 0 | 1): 0 | 1 { + if (outcomeIndex !== 0 && outcomeIndex !== 1) { + throw new UserInputError('Protocol v2 outcome index must be 0 or 1'); + } + + return outcomeIndex; +} + /** @internal */ export function encodeProxyCall(calls: readonly TransactionCall[]): HexString { return AbiFunction.encodeData(PROXY_FACTORY_FUNCTION, [ diff --git a/packages/client/src/actions/portfolio.ts b/packages/client/src/actions/portfolio.ts index 79a984a..989913e 100644 --- a/packages/client/src/actions/portfolio.ts +++ b/packages/client/src/actions/portfolio.ts @@ -1,8 +1,11 @@ import { PaginationCursorSchema } from '@polymarket/bindings'; import { type ClosedPosition, + type ComboPosition, + ComboPositionStatusSchema, FetchPortfolioValueResponseSchema, ListClosedPositionsResponseSchema, + ListComboPositionsResponseSchema, ListPositionsResponseSchema, type Position, type Traded, @@ -29,7 +32,9 @@ import { paginate, } from '../pagination'; import { readBlob, validateWith } from '../response'; -import { toDataSearchParams } from './params'; +import { snakeCase, toDataSearchParams, toSearchParams } from './params'; + +export { ComboPositionStatus } from '@polymarket/bindings/data'; const PositionSortBySchema = z.enum([ 'CURRENT', @@ -88,6 +93,15 @@ const ListClosedPositionsRequestSchema = z path: ['eventId'], }); +const ListComboPositionsRequestSchema = z.object({ + cursor: PaginationCursorSchema.optional(), + user: z.string(), + pageSize: PageSizeSchema.default(20), + status: ComboPositionStatusSchema.optional(), + conditionId: z.string().optional(), + positionId: z.string().optional(), +}); + const FetchPortfolioValueRequestSchema = z.object({ user: z.string(), market: z.array(z.string()).optional(), @@ -105,6 +119,9 @@ export type ListPositionsRequest = z.input; export type ListClosedPositionsRequest = z.input< typeof ListClosedPositionsRequestSchema >; +export type ListComboPositionsRequest = z.input< + typeof ListComboPositionsRequestSchema +>; export type FetchPortfolioValueRequest = z.input< typeof FetchPortfolioValueRequestSchema >; @@ -219,6 +236,20 @@ export const ListClosedPositionsError = makeErrorGuard( UserInputError, ); +export type ListComboPositionsError = + | RateLimitError + | RequestRejectedError + | TransportError + | UnexpectedResponseError + | UserInputError; +export const ListComboPositionsError = makeErrorGuard( + RateLimitError, + RequestRejectedError, + TransportError, + UnexpectedResponseError, + UserInputError, +); + /** * Lists closed positions for a wallet. * @@ -295,6 +326,84 @@ export function listClosedPositions( }, cursor); } +/** + * Lists combo positions for a wallet. + * + * @remarks + * This is a low-level function. Most SDK consumers should prefer the client instance API. + * + * @throws {@link ListComboPositionsError} + * Thrown on failure. + * + * @example + * Fetch the first page of results: + * ```ts + * const result = listComboPositions(client, { + * user: '0x7c3db723f1d4d8cb9c550095203b686cb11e5c6b', + * pageSize: 10, + * }); + * + * const firstPage = await result.firstPage(); + * + * // Optionally, fetch additional pages: + * for await (const page of result.from(firstPage.nextCursor)) { + * // page.items: ComboPosition[] + * } + * ``` + * + * @example + * Filter to open combo positions: + * ```ts + * const result = listComboPositions(client, { + * user: '0x7c3db723f1d4d8cb9c550095203b686cb11e5c6b', + * status: ComboPositionStatus.Open, + * }); + * ``` + */ +export function listComboPositions( + client: BaseClient, + request: ListComboPositionsRequest, +): Paginated { + const { cursor, pageSize, ...params } = parseUserInput( + request, + ListComboPositionsRequestSchema, + ); + + return paginate((cursor) => { + const decoded = decodeOffsetCursor(cursor, pageSize); + + return client.data + .get('/v1/positions/combos', { + params: toSearchParams( + { + ...params, + limit: decoded.pageSize + 1, + offset: decoded.offset, + }, + snakeCase({ + conditionId: 'combo_condition_id', + positionId: 'combo_position_id', + }), + ), + }) + .andThen(validateWith(ListComboPositionsResponseSchema)) + .map((response) => { + const hasMore = response.combos.length > decoded.pageSize; + + return { + items: response.combos.slice(0, decoded.pageSize), + hasMore, + nextCursor: hasMore + ? encodeOffsetCursor({ + offset: decoded.offset + decoded.pageSize, + pageSize: decoded.pageSize, + }) + : undefined, + }; + }); + }, cursor); +} + export type FetchPortfolioValueError = | RateLimitError | RequestRejectedError diff --git a/packages/client/src/actions/positions.ts b/packages/client/src/actions/positions.ts index 43175d2..cf5674f 100644 --- a/packages/client/src/actions/positions.ts +++ b/packages/client/src/actions/positions.ts @@ -1,5 +1,9 @@ -import type { ConditionId, DecimalString } from '@polymarket/bindings'; -import { ConditionIdSchema } from '@polymarket/bindings'; +import type { + ConditionId, + DecimalString, + PositionId, +} from '@polymarket/bindings'; +import { ConditionIdSchema, PositionIdSchema } from '@polymarket/bindings'; import type { Position } from '@polymarket/bindings/data'; import { WalletType } from '@polymarket/bindings/gamma'; import { @@ -11,9 +15,12 @@ import { } from '@polymarket/types'; import { z } from 'zod'; import { + combinatorialPrepareConditionCall, ctfRedeemPositionsCall, mergePositionsCall, + mergeV2Call, splitPositionCall, + splitV2Call, } from '../abis'; import type { BaseSecureClient } from '../clients'; import { @@ -22,11 +29,18 @@ import { RateLimitError, RequestRejectedError, SigningError, + TimeoutError, + TransactionFailedError, TransportError, UnexpectedResponseError, UserInputError, } from '../errors'; import { parseUserInput } from '../input'; +import { + type CanonicalComboLegs, + canonicalizeComboLegs, + deriveComboConditionId, +} from '../protocol'; import { expectTransactionHandle, type SignerTransactionRequest, @@ -43,6 +57,7 @@ import { GaslessTransactionMetadataSchema, type GaslessWorkflowRequest, prepareGaslessTransaction, + type WaitForGaslessTransactionError, } from './gasless'; import { listMarkets } from './markets'; import { listPositions } from './portfolio'; @@ -53,17 +68,81 @@ type BinaryPositions = type PositiveAmount = bigint; -const PrepareSplitPositionRequestSchema = z.object({ +/** + * Parameters for preparing a market position split. + */ +export type PrepareSplitMarketPositionRequest = { + /** Amount of collateral to convert into market positions. */ + amount: bigint; + /** Existing market condition ID that identifies the positions to mint. */ + conditionId: string | ConditionId; + /** Optional transaction metadata for workflows that support metadata. */ + metadata?: string; +}; + +/** + * Parameters for preparing a combo position split. + */ +export type PrepareSplitComboPositionRequest = { + /** Amount of collateral to convert into combo positions. */ + amount: bigint; + /** Protocol v2 leg position IDs that define the combo condition. */ + legs: string[] | PositionId[]; + /** Optional transaction metadata for workflows that support metadata. */ + metadata?: string; +}; + +/** + * Parameters for preparing either supported position split workflow. + * + * @remarks + * Provide either a market `conditionId` or combo `legs`. + */ +export type PrepareSplitPositionRequest = + | PrepareSplitMarketPositionRequest + | PrepareSplitComboPositionRequest; + +const PrepareSplitMarketPositionRequestSchema = z.object({ amount: z.bigint().min(0n), conditionId: ConditionIdSchema, metadata: GaslessTransactionMetadataSchema.optional(), -}); +}) satisfies z.ZodType; -const PrepareMergePositionsRequestSchema = z.object({ - amount: z.union([z.bigint().positive(), z.literal('max')]), - conditionId: ConditionIdSchema, +const PrepareSplitComboPositionInputSchema = z.object({ + amount: z.bigint().positive(), + legs: z.array(PositionIdSchema).min(1).max(50), metadata: GaslessTransactionMetadataSchema.optional(), -}); +}) satisfies z.ZodType; + +type ParsedSplitComboPositionRequest = { + amount: bigint; + legs: CanonicalComboLegs; + metadata?: string; +}; + +const CanonicalComboLegsSchema = z + .array(PositionIdSchema) + .min(1) + .max(50) + .transform(canonicalizeComboLegs); + +const PrepareSplitComboPositionRequestSchema = z.object({ + amount: z.bigint().positive(), + legs: CanonicalComboLegsSchema, + metadata: GaslessTransactionMetadataSchema.optional(), +}) satisfies z.ZodType< + ParsedSplitComboPositionRequest, + PrepareSplitComboPositionRequest +>; + +const PrepareSplitPositionRequestSchema = z.union([ + PrepareSplitMarketPositionRequestSchema.extend({ + legs: z.never().optional(), + }), + PrepareSplitComboPositionInputSchema.extend({ + conditionId: z.never().optional(), + }), +]) satisfies z.ZodType; const PrepareRedeemPositionsRequestSchema = z.union([ z.object({ @@ -108,20 +187,16 @@ export type RedeemPositionsWorkflow = AsyncGenerator< EvmAddress | EvmSignature | TransactionHandle >; -export type PrepareSplitPositionRequest = z.input< - typeof PrepareSplitPositionRequestSchema ->; - -export type PrepareMergePositionsRequest = z.input< - typeof PrepareMergePositionsRequestSchema ->; - export type PrepareRedeemPositionsRequest = z.input< typeof PrepareRedeemPositionsRequestSchema >; export type PrepareSplitPositionError = UserInputError; export const PrepareSplitPositionError = makeErrorGuard(UserInputError); +export type PrepareSplitMarketPositionError = PrepareSplitPositionError; +export const PrepareSplitMarketPositionError = PrepareSplitPositionError; +export type PrepareSplitComboPositionError = PrepareSplitPositionError; +export const PrepareSplitComboPositionError = PrepareSplitPositionError; export type PrepareMergePositionsError = | RateLimitError | RequestRejectedError @@ -135,6 +210,10 @@ export const PrepareMergePositionsError = makeErrorGuard( UnexpectedResponseError, UserInputError, ); +export type PrepareMergeMarketPositionError = PrepareMergePositionsError; +export const PrepareMergeMarketPositionError = PrepareMergePositionsError; +export type PrepareMergeComboPositionError = PrepareMergePositionsError; +export const PrepareMergeComboPositionError = PrepareMergePositionsError; export type PrepareRedeemPositionsError = | RateLimitError | RequestRejectedError @@ -157,21 +236,24 @@ export const PrepareRedeemPositionsError = makeErrorGuard( * * @example * ```ts - * const workflow = await prepareSplitPosition(client, { + * const workflow = await prepareSplitMarketPosition(client, { * amount: 1n, * conditionId: * '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', * }); * ``` * - * @throws {@link PrepareSplitPositionError} + * @throws {@link PrepareSplitMarketPositionError} * Thrown on failure. */ -export async function prepareSplitPosition( +export async function prepareSplitMarketPosition( client: BaseSecureClient, - request: PrepareSplitPositionRequest, + request: PrepareSplitMarketPositionRequest, ): Promise { - const params = parseUserInput(request, PrepareSplitPositionRequestSchema); + const params = parseUserInput( + request, + PrepareSplitMarketPositionRequestSchema, + ); const negativeRisk = await resolveMarketNegativeRiskFlag( client, params.conditionId, @@ -201,15 +283,109 @@ export async function prepareSplitPosition( }.call(null); } +/** + * Starts a split workflow for a combo position from leg position IDs. + * + * @remarks + * This is a low-level function. Most SDK consumers should prefer the client instance API. + * + * @example + * ```ts + * const workflow = await prepareSplitComboPosition(client, { + * amount: 1n, + * legs: ['123', '456'], + * }); + * ``` + * + * @throws {@link PrepareSplitComboPositionError} + * Thrown on failure. + */ +export async function prepareSplitComboPosition( + client: BaseSecureClient, + request: PrepareSplitComboPositionRequest, +): Promise { + const params = parseUserInput( + request, + PrepareSplitComboPositionRequestSchema, + ); + const prepareConditionCall = combinatorialPrepareConditionCall( + client.environment.combinatorialModule, + params.legs, + ); + const conditionId = deriveComboConditionId(params.legs); + const splitCall = splitV2Call( + client.environment.protocolV2Router, + conditionId, + params.amount, + ); + + return async function* (): SplitPositionWorkflow { + if (client.account.walletType === WalletType.EOA) { + const prepareHandle = expectTransactionHandle( + yield sendSplitPositionTransaction( + signerTransactionRequest( + client.environment.chainId, + prepareConditionCall, + ), + ), + ); + await prepareHandle.wait(); + + return expectTransactionHandle( + yield sendSplitPositionTransaction( + signerTransactionRequest(client.environment.chainId, splitCall), + ), + ); + } + + return yield* await prepareGaslessTransaction(client, { + calls: [prepareConditionCall, splitCall], + metadata: + params.metadata ?? + `Split ${params.amount} combo positions for condition ${conditionId}`, + }); + }.call(null); +} + +/** + * Starts a split workflow for market or combo positions. + * + * @throws {@link PrepareSplitPositionError} + * Thrown on failure. + */ +export async function prepareSplitPosition( + client: BaseSecureClient, + request: PrepareSplitPositionRequest, +): Promise { + const params = parseUserInput(request, PrepareSplitPositionRequestSchema); + + if (params.legs !== undefined) { + return prepareSplitComboPosition(client, params); + } + + return prepareSplitMarketPosition(client, params); +} + export type SplitPositionError = | PrepareSplitPositionError | CancelledSigningError - | SigningError; + | SigningError + | WaitForGaslessTransactionError; export const SplitPositionError = makeErrorGuard( CancelledSigningError, + RateLimitError, + RequestRejectedError, SigningError, + TimeoutError, + TransactionFailedError, + TransportError, + UnexpectedResponseError, UserInputError, ); +export type SplitMarketPositionError = SplitPositionError; +export const SplitMarketPositionError = SplitPositionError; +export type SplitComboPositionError = SplitPositionError; +export const SplitComboPositionError = SplitPositionError; /** * Splits collateral into market positions. @@ -217,6 +393,39 @@ export const SplitPositionError = makeErrorGuard( * @remarks * This is a low-level function. Most SDK consumers should prefer the client instance API. * + * @throws {@link SplitMarketPositionError} + * Thrown on failure. + */ +export function splitMarketPosition( + client: BaseSecureClient, + request: PrepareSplitMarketPositionRequest, +): Promise { + return prepareSplitMarketPosition(client, request).then( + completeWith(client.signer), + ); +} + +/** + * Splits collateral into combo positions from leg position IDs. + * + * @remarks + * This is a low-level function. Most SDK consumers should prefer the client instance API. + * + * @throws {@link SplitComboPositionError} + * Thrown on failure. + */ +export function splitComboPosition( + client: BaseSecureClient, + request: PrepareSplitComboPositionRequest, +): Promise { + return prepareSplitComboPosition(client, request).then( + completeWith(client.signer), + ); +} + +/** + * Splits collateral into market or combo positions. + * * @throws {@link SplitPositionError} * Thrown on failure. */ @@ -229,6 +438,76 @@ export function splitPosition( ); } +/** + * Parameters for preparing a market position merge. + */ +export type PrepareMergeMarketPositionRequest = { + /** Amount per complementary market position to merge. */ + amount: bigint | 'max'; + /** Existing market condition ID that identifies the positions to merge. */ + conditionId: string | ConditionId; + /** Optional transaction metadata for workflows that support metadata. */ + metadata?: string; +}; + +/** + * Parameters for preparing a combo position merge. + */ +export type PrepareMergeComboPositionRequest = { + /** Amount per complementary combo position to merge. */ + amount: bigint; + /** Protocol v2 leg position IDs that define the combo condition. */ + legs: string[] | PositionId[]; + /** Optional transaction metadata for workflows that support metadata. */ + metadata?: string; +}; + +/** + * Parameters for preparing either supported position merge workflow. + * + * @remarks + * Provide either a market `conditionId` or combo `legs`. + */ +export type PrepareMergePositionsRequest = + | PrepareMergeMarketPositionRequest + | PrepareMergeComboPositionRequest; + +type ParsedMergeComboPositionRequest = { + amount: bigint; + legs: CanonicalComboLegs; + metadata?: string; +}; + +const PrepareMergeMarketPositionRequestSchema = z.object({ + amount: z.union([z.bigint().positive(), z.literal('max')]), + conditionId: ConditionIdSchema, + metadata: GaslessTransactionMetadataSchema.optional(), +}) satisfies z.ZodType; + +const PrepareMergeComboPositionInputSchema = z.object({ + amount: z.bigint().positive(), + legs: z.array(PositionIdSchema).min(1).max(50), + metadata: GaslessTransactionMetadataSchema.optional(), +}) satisfies z.ZodType; + +const PrepareMergeComboPositionRequestSchema = z.object({ + amount: z.bigint().positive(), + legs: CanonicalComboLegsSchema, + metadata: GaslessTransactionMetadataSchema.optional(), +}) satisfies z.ZodType< + ParsedMergeComboPositionRequest, + PrepareMergeComboPositionRequest +>; + +const PrepareMergePositionsRequestSchema = z.union([ + PrepareMergeMarketPositionRequestSchema.extend({ + legs: z.never().optional(), + }), + PrepareMergeComboPositionInputSchema.extend({ + conditionId: z.never().optional(), + }), +]) satisfies z.ZodType; + /** * Starts a workflow to merge complementary positions in a market back into collateral. * @@ -237,21 +516,24 @@ export function splitPosition( * * @example * ```ts - * const workflow = await prepareMergePositions(client, { + * const workflow = await prepareMergeMarketPosition(client, { * amount: 'max', * conditionId: * '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', * }); * ``` * - * @throws {@link PrepareMergePositionsError} + * @throws {@link PrepareMergeMarketPositionError} * Thrown on failure. */ -export async function prepareMergePositions( +export async function prepareMergeMarketPosition( client: BaseSecureClient, - request: PrepareMergePositionsRequest, + request: PrepareMergeMarketPositionRequest, ): Promise { - const params = parseUserInput(request, PrepareMergePositionsRequestSchema); + const params = parseUserInput( + request, + PrepareMergeMarketPositionRequestSchema, + ); const positions = await listPositions(client, { user: client.account.wallet, market: [params.conditionId], @@ -287,6 +569,89 @@ export async function prepareMergePositions( }.call(null); } +/** + * Starts a workflow to merge complementary combo positions back into collateral. + * + * @remarks + * This is a low-level function. Most SDK consumers should prefer the client instance API. + * + * @example + * ```ts + * const workflow = await prepareMergeComboPosition(client, { + * amount: 1n, + * legs: ['123', '456'], + * }); + * ``` + * + * @throws {@link PrepareMergeComboPositionError} + * Thrown on failure. + */ +export async function prepareMergeComboPosition( + client: BaseSecureClient, + request: PrepareMergeComboPositionRequest, +): Promise { + const params = parseUserInput( + request, + PrepareMergeComboPositionRequestSchema, + ); + const prepareConditionCall = combinatorialPrepareConditionCall( + client.environment.combinatorialModule, + params.legs, + ); + const conditionId = deriveComboConditionId(params.legs); + const mergeCall = mergeV2Call( + client.environment.protocolV2Router, + conditionId, + params.amount, + ); + + return async function* (): MergePositionsWorkflow { + if (client.account.walletType === WalletType.EOA) { + const prepareHandle = expectTransactionHandle( + yield sendMergePositionsTransaction( + signerTransactionRequest( + client.environment.chainId, + prepareConditionCall, + ), + ), + ); + await prepareHandle.wait(); + + return expectTransactionHandle( + yield sendMergePositionsTransaction( + signerTransactionRequest(client.environment.chainId, mergeCall), + ), + ); + } + + return yield* await prepareGaslessTransaction(client, { + calls: [prepareConditionCall, mergeCall], + metadata: + params.metadata ?? + `Merge ${params.amount} combo positions for condition ${conditionId}`, + }); + }.call(null); +} + +/** + * Starts a merge workflow for market or combo positions. + * + * @throws {@link PrepareMergePositionsError} + * Thrown on failure. + */ +export async function prepareMergePositions( + client: BaseSecureClient, + request: PrepareMergePositionsRequest, +): Promise { + const params = parseUserInput(request, PrepareMergePositionsRequestSchema); + + if (params.legs !== undefined) { + return prepareMergeComboPosition(client, params); + } + + return prepareMergeMarketPosition(client, params); +} + export type MergePositionsError = | PrepareMergePositionsError | CancelledSigningError @@ -300,6 +665,10 @@ export const MergePositionsError = makeErrorGuard( UnexpectedResponseError, UserInputError, ); +export type MergeMarketPositionError = MergePositionsError; +export const MergeMarketPositionError = MergePositionsError; +export type MergeComboPositionError = MergePositionsError; +export const MergeComboPositionError = MergePositionsError; /** * Merges complementary market positions back into collateral. @@ -307,6 +676,39 @@ export const MergePositionsError = makeErrorGuard( * @remarks * This is a low-level function. Most SDK consumers should prefer the client instance API. * + * @throws {@link MergeMarketPositionError} + * Thrown on failure. + */ +export function mergeMarketPosition( + client: BaseSecureClient, + request: PrepareMergeMarketPositionRequest, +): Promise { + return prepareMergeMarketPosition(client, request).then( + completeWith(client.signer), + ); +} + +/** + * Merges complementary combo positions back into collateral. + * + * @remarks + * This is a low-level function. Most SDK consumers should prefer the client instance API. + * + * @throws {@link MergeComboPositionError} + * Thrown on failure. + */ +export function mergeComboPosition( + client: BaseSecureClient, + request: PrepareMergeComboPositionRequest, +): Promise { + return prepareMergeComboPosition(client, request).then( + completeWith(client.signer), + ); +} + +/** + * Merges complementary market or combo positions back into collateral. + * * @throws {@link MergePositionsError} * Thrown on failure. */ diff --git a/packages/client/src/decorators/account.ts b/packages/client/src/decorators/account.ts index d755d3e..670fbab 100644 --- a/packages/client/src/decorators/account.ts +++ b/packages/client/src/decorators/account.ts @@ -5,6 +5,7 @@ import type { import type { Activity, ClosedPosition, + ComboPosition, MetaMarketPosition, Position, Traded, @@ -25,11 +26,13 @@ import { type ListAccountTradesRequest, type ListActivityRequest, type ListClosedPositionsRequest, + type ListComboPositionsRequest, type ListMarketPositionsRequest, type ListPositionsRequest, listAccountTrades, listActivity, listClosedPositions, + listComboPositions, listMarketPositions, listPositions, } from '../actions'; @@ -55,6 +58,8 @@ export type SecureListPositionsRequest = DefaultAccountWallet; export type SecureListClosedPositionsRequest = DefaultAccountWallet; +export type SecureListComboPositionsRequest = + DefaultAccountWallet; export type SecureFetchPortfolioValueRequest = DefaultAccountWallet; export type SecureFetchTradedMarketCountRequest = @@ -181,6 +186,26 @@ export type PublicAccountActions = Prettify< listClosedPositions( request: ListClosedPositionsRequest, ): Paginated; + /** + * Lists combo positions for a wallet. + * + * @throws {@link ListComboPositionsError} + * Thrown on failure. + * + * @example + * Fetch the first page of results: + * ```ts + * const paginator = client.listComboPositions({ + * user: '0x7c3db723f1d4d8cb9c550095203b686cb11e5c6b', + * pageSize: 10, + * }); + * + * const firstPage = await paginator.firstPage(); + * ``` + */ + listComboPositions( + request: ListComboPositionsRequest, + ): Paginated; /** * Fetches the total value for a wallet's positions. * @@ -298,6 +323,17 @@ export type SecureAccountActions = Prettify< listClosedPositions( request?: SecureListClosedPositionsRequest, ): Paginated; + /** + * Lists combo positions for a wallet. + * + * Defaults to the authenticated account's wallet when `user` is omitted. + * + * @throws {@link ListComboPositionsError} + * Thrown on failure. + */ + listComboPositions( + request?: SecureListComboPositionsRequest, + ): Paginated; /** * Fetches the total value for a wallet's positions. * @@ -421,6 +457,7 @@ function publicAccountActions(client: BaseClient): PublicAccountActions { return { listPositions: listPositions.bind(null, client), listClosedPositions: listClosedPositions.bind(null, client), + listComboPositions: listComboPositions.bind(null, client), fetchPortfolioValue: fetchPortfolioValue.bind(null, client), fetchTradedMarketCount: fetchTradedMarketCount.bind(null, client), downloadAccountingSnapshot: downloadAccountingSnapshot.bind(null, client), @@ -456,6 +493,8 @@ export function accountActions( listPositions(client, withAccountWallet(client, request)), listClosedPositions: (request?: SecureListClosedPositionsRequest) => listClosedPositions(client, withAccountWallet(client, request)), + listComboPositions: (request?: SecureListComboPositionsRequest) => + listComboPositions(client, withAccountWallet(client, request)), fetchPortfolioValue: (request?: SecureFetchPortfolioValueRequest) => fetchPortfolioValue(client, withAccountWallet(client, request)), fetchTradedMarketCount: (request?: SecureFetchTradedMarketCountRequest) => @@ -485,6 +524,8 @@ export { ListAccountTradesError, ListActivityError, ListClosedPositionsError, + ListComboPositionsError, ListMarketPositionsError, ListPositionsError, } from '../actions'; +export { ComboPositionStatus } from '../actions/portfolio'; diff --git a/packages/client/src/decorators/wallet.ts b/packages/client/src/decorators/wallet.ts index 4766f01..ee7925a 100644 --- a/packages/client/src/decorators/wallet.ts +++ b/packages/client/src/decorators/wallet.ts @@ -102,12 +102,12 @@ export type SecureWalletActions = { request: PrepareErc20TransferRequest, ): Promise; /** - * Splits collateral into market positions. + * Splits collateral into market or combo positions. * * @throws {@link SplitPositionError} * Thrown on failure. * - * @example + * @example Split a market by condition ID. * ```ts * const handle = await client.splitPosition({ * amount: 1n, @@ -119,17 +119,29 @@ export type SecureWalletActions = { * * // outcome.transactionHash: TxHash * ``` + * + * @example Split a combo by legs. + * ```ts + * const handle = await client.splitPosition({ + * amount: 1n, + * legs: ['123', '456'], + * }); + * + * const outcome = await handle.wait(); + * + * // outcome.transactionHash: TxHash + * ``` */ splitPosition( request: PrepareSplitPositionRequest, ): Promise; /** - * Merges complementary market positions back into collateral. + * Merges complementary market or combo positions back into collateral. * * @throws {@link MergePositionsError} * Thrown on failure. * - * @example + * @example Merge a market by condition ID. * ```ts * const handle = await client.mergePositions({ * amount: 'max', @@ -141,6 +153,18 @@ export type SecureWalletActions = { * * // outcome.transactionHash: TxHash * ``` + * + * @example Merge a combo by legs. + * ```ts + * const handle = await client.mergePositions({ + * amount: 1n, + * legs: ['123', '456'], + * }); + * + * const outcome = await handle.wait(); + * + * // outcome.transactionHash: TxHash + * ``` */ mergePositions( request: PrepareMergePositionsRequest, diff --git a/packages/client/src/environments.ts b/packages/client/src/environments.ts index 99b6b14..b9d55c0 100644 --- a/packages/client/src/environments.ts +++ b/packages/client/src/environments.ts @@ -37,6 +37,8 @@ export type EnvironmentConfig = { /** @internal */ protocolV2Router: EvmAddress; /** @internal */ + combinatorialModule: EvmAddress; + /** @internal */ positionManager: EvmAddress; /** @internal */ autoRedeemOperator: EvmAddress; @@ -122,6 +124,9 @@ export const production: EnvironmentConfig = { protocolV2Router: expectEvmAddress( '0x12121212006e4CD160D18e3f00711DA5c3372600', ), + combinatorialModule: expectEvmAddress( + '0x30000034706c7d8e12009dab006be20000c031a8', + ), positionManager: expectEvmAddress( '0x006F54F7f9A22e0000CC2AB60031000000ae9fEF', ), @@ -142,3 +147,13 @@ export const production: EnvironmentConfig = { relayerMaxPolls: 100, relayerPollFrequencyMs: 2000, }; + +/** @internal */ +export const preproduction = { + ...production, + name: 'preproduction', + clob: 'https://clob-preprod-int-v2.polymarket.com', + data: 'https://data-api-preprod-int.polymarket.com', + gamma: 'https://gamma-api-preprod-int.polymarket.com', + relayer: 'https://relayer-v2-preprod-int.polymarket.com', +}; diff --git a/packages/client/src/protocol.test.ts b/packages/client/src/protocol.test.ts new file mode 100644 index 0000000..19636c5 --- /dev/null +++ b/packages/client/src/protocol.test.ts @@ -0,0 +1,56 @@ +import { type PositionId, toPositionId } from '@polymarket/bindings'; +import { describe, expect, it } from 'vitest'; +import { + type CanonicalComboLegs, + canonicalizeComboLegs, + deriveComboConditionId, +} from './protocol'; + +const CONDITION_ID = + '0x032def24bfb0c5c57fb236fac08b94236a0000000000000000000000000000'; + +describe('Protocol helpers', () => { + describe('canonicalizeComboLegs', () => { + it('sorts unordered legs', () => { + const legs = canonicalizeComboLegs([ + legPosition(2, 1), + legPosition(1, 0), + ]); + + expect(legs.map((leg) => leg.toString())).toEqual([ + legPosition(1, 0), + legPosition(2, 1), + ]); + }); + + it('rejects combo legs with both outcomes from one condition', () => { + expect(() => + canonicalizeComboLegs([legPosition(1, 0), legPosition(1, 1)]), + ).toThrow(/both outcomes/); + }); + }); + + describe('deriveComboConditionId', () => { + it('derives a combo condition ID from canonical legs', () => { + const legs = [ + BigInt(legPosition(1, 0)), + BigInt(legPosition(2, 1)), + ] as unknown as CanonicalComboLegs; + + expect(deriveComboConditionId(legs)).toBe(CONDITION_ID); + }); + }); +}); + +function legPosition(marker: number, outcome: number): PositionId { + const bytes = new Uint8Array(32); + bytes[0] = 1; + bytes[30] = marker; + bytes[31] = outcome; + + return toPositionId( + BigInt( + `0x${Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('')}`, + ).toString(), + ); +} diff --git a/packages/client/src/protocol.ts b/packages/client/src/protocol.ts new file mode 100644 index 0000000..29d5fa0 --- /dev/null +++ b/packages/client/src/protocol.ts @@ -0,0 +1,119 @@ +import type { ConditionId, PositionId } from '@polymarket/bindings'; +import type { Tagged } from '@polymarket/types'; +import { AbiParameters, Hash } from 'ox'; +import { UserInputError } from './errors'; + +const UINT256_BYTE_LENGTH = 32; +const COMBINATORIAL_MODULE_ID = 3n; +const MAX_COMBO_LEGS = 50; +const UINT256_MAX = (1n << 256n) - 1n; + +export type CanonicalComboLegs = Tagged< + readonly bigint[], + 'CanonicalComboLegs' +>; + +/** + * Derives a combo condition ID from canonical combo legs. + */ +export function deriveComboConditionId(legs: CanonicalComboLegs): ConditionId { + const encodedLegs = AbiParameters.encode( + [{ name: 'legs', type: 'uint256[]' }], + [legs], + ); + const baseHash = Hash.keccak256( + AbiParameters.encode( + [ + { name: 'moduleId', type: 'uint256' }, + { name: 'data', type: 'bytes' }, + ], + [COMBINATORIAL_MODULE_ID, encodedLegs], + ), + ); + + return `0x03${baseHash.slice(34)}0000000000000000000000000000` as ConditionId; +} + +/** + * Validates and canonicalizes combo leg position IDs. + * + * @throws {@link UserInputError} + * Thrown when the legs do not form a valid combo condition. + */ +export function canonicalizeComboLegs( + legs: readonly PositionId[], +): CanonicalComboLegs { + if (legs.length === 0 || legs.length > MAX_COMBO_LEGS) { + throw new UserInputError( + `Combo legs must include 1 to ${MAX_COMBO_LEGS} position IDs`, + ); + } + + const positions = legs.map((leg) => { + const value = parsePositionId(leg); + const hex = value.toString(16).padStart(UINT256_BYTE_LENGTH * 2, '0'); + const moduleId = Number.parseInt(hex.slice(0, 2), 16); + const outcomeIndex = Number.parseInt(hex.slice(-2), 16); + + if ((moduleId !== 1 && moduleId !== 2) || outcomeIndex > 1) { + throw new UserInputError( + 'Combo legs must be binary or neg-risk YES/NO position IDs', + ); + } + + return { + value, + conditionId: hex.slice(0, -2), + }; + }); + + positions.sort((left, right) => + left.value < right.value ? -1 : left.value > right.value ? 1 : 0, + ); + + for (let index = 1; index < positions.length; index += 1) { + const previous = positions[index - 1]; + const current = positions[index]; + + if (previous === undefined || current === undefined) { + throw new UserInputError('Invalid combo leg set'); + } + + if (previous.value === current.value) { + throw new UserInputError( + 'Combo legs must not contain duplicate position IDs', + ); + } + + if (previous.conditionId === current.conditionId) { + throw new UserInputError( + 'Combo legs must not contain both outcomes for the same condition', + ); + } + } + + return positions.map( + (position) => position.value, + ) as unknown as CanonicalComboLegs; +} + +function parsePositionId(positionId: string): bigint { + const value = positionId.trim(); + let parsed: bigint; + + if (value.length === 0) { + throw new UserInputError('Position ID must be a uint256 value'); + } + + try { + parsed = BigInt(value); + } catch { + throw new UserInputError('Position ID must be a uint256 value'); + } + + if (parsed < 0n || parsed > UINT256_MAX) { + throw new UserInputError('Position ID must be a uint256 value'); + } + + return parsed; +} diff --git a/packages/client/tests/integration/positions.test.ts b/packages/client/tests/integration/positions.test.ts index 6a9a0f9..698ac7d 100644 --- a/packages/client/tests/integration/positions.test.ts +++ b/packages/client/tests/integration/positions.test.ts @@ -1,9 +1,21 @@ -import { createSecureClient, WalletType } from '@polymarket/client'; +import { + createSecureClient, + preproduction, + type SecureClient, + WalletType, +} from '@polymarket/client'; import { expectPresent } from '@polymarket/types'; import { vi } from 'vitest'; import { describe, expect, it, publicClient } from './fixtures'; const TEST_MARKET_SLUG = 'eth-flipped-in-2026'; +const TEST_COMBO_AMOUNT = 1_000_000n; +const TEST_COMBO_CONDITION_ID = + '0x034eabdeca272641d98717d8ca2f8e5f330000000000000000000000000000'; +const TEST_COMBO_LEGS = [ + '920454018917169090762848014984037642864617754825717966757321143422977835520', + '1012585296795354377868537359137497102116066671623168081060942028909450362880', +]; const conditionId = await publicClient .fetchMarket({ slug: TEST_MARKET_SLUG, @@ -11,84 +23,180 @@ const conditionId = await publicClient .then((market) => expectPresent(market.conditionId)); describe('Positions', () => { - // Pending an investigation into split positions not being returned by the Data API - it('splits a market position', async ({ - depositWalletAddress, - depositWalletSigner, - relayerAuthentication, - }) => { - const secureClient = await createSecureClient({ - apiKey: relayerAuthentication, - signer: depositWalletSigner, - wallet: depositWalletAddress, + describe('and a CLOB market', () => { + it('splits a position by condition ID', async ({ + depositWalletAddress, + depositWalletSigner, + relayerAuthentication, + }) => { + const secureClient = await createSecureClient({ + apiKey: relayerAuthentication, + signer: depositWalletSigner, + wallet: depositWalletAddress, + }); + + expect(secureClient.account.walletType).toBe(WalletType.DEPOSIT_WALLET); + + await secureClient + .splitPosition({ + amount: 1_000_000n, + conditionId, + }) + .then((handle) => handle.wait()); + + await vi.waitFor( + async () => { + const positions = await secureClient + .listPositions({ + user: secureClient.account.wallet, + market: [conditionId], + }) + .firstPage(); + expect(positions.items).toHaveLength(2); + }, + { timeout: 15_000 }, + ); }); - expect(secureClient.account.walletType).toBe(WalletType.DEPOSIT_WALLET); - - await secureClient - .splitPosition({ - amount: 1_000_000n, - conditionId, - }) - .then((handle) => handle.wait()); - - await vi.waitFor( - async () => { - const positions = await secureClient - .listPositions({ - user: secureClient.account.wallet, - market: [conditionId], - }) - .firstPage(); - expect(positions.items).toHaveLength(2); - }, - { timeout: 15_000 }, - ); - }, 20_000); - - it('merges complementary positions', async ({ - depositWalletAddress, - depositWalletSigner, - relayerAuthentication, - skip, - }) => { - const secureClient = await createSecureClient({ - apiKey: relayerAuthentication, - signer: depositWalletSigner, - wallet: depositWalletAddress, + it('merges complementary positions by condition ID', async ({ + depositWalletAddress, + depositWalletSigner, + relayerAuthentication, + skip, + }) => { + const secureClient = await createSecureClient({ + apiKey: relayerAuthentication, + signer: depositWalletSigner, + wallet: depositWalletAddress, + }); + + expect(secureClient.account.walletType).toBe(WalletType.DEPOSIT_WALLET); + + const { items: positions } = await secureClient + .listPositions({ + user: secureClient.account.wallet, + market: [conditionId], + }) + .firstPage(); + + if (positions.length < 2) { + skip('Not enough positions to merge'); + } + + await secureClient + .mergePositions({ + amount: 'max', + conditionId, + }) + .then((handle) => handle.wait()); + + await vi.waitFor( + async () => { + const positions = await secureClient + .listPositions({ + user: secureClient.account.wallet, + market: [conditionId], + }) + .firstPage(); + expect(positions.items).toHaveLength(0); + }, + { timeout: 15_000 }, + ); }); + }); + + describe('and a Combo', () => { + it('splits a combo position by legs', async ({ + depositWalletAddress, + depositWalletSigner, + relayerAuthentication, + }) => { + const secureClient = await createSecureClient({ + apiKey: relayerAuthentication, + signer: depositWalletSigner, + wallet: depositWalletAddress, + environment: preproduction, + }); + + expect(secureClient.account.walletType).toBe(WalletType.DEPOSIT_WALLET); - expect(secureClient.account.walletType).toBe(WalletType.DEPOSIT_WALLET); - - const { items: positions } = await secureClient - .listPositions({ - user: secureClient.account.wallet, - market: [conditionId], - }) - .firstPage(); - - if (positions.length < 2) { - skip('Not enough positions to merge'); - } - - await secureClient - .mergePositions({ - amount: 'max', - conditionId, - }) - .then((handle) => handle.wait()); - - await vi.waitFor( - async () => { - const positions = await secureClient - .listPositions({ - user: secureClient.account.wallet, - market: [conditionId], - }) - .firstPage(); - expect(positions.items).toHaveLength(0); - }, - { timeout: 15_000 }, - ); - }, 20_000); + const initialShares = await fetchComboShares(secureClient); + + await secureClient + .splitPosition({ + amount: TEST_COMBO_AMOUNT, + legs: TEST_COMBO_LEGS, + }) + .then((handle) => handle.wait()); + + await vi.waitFor( + async () => { + await expect(fetchComboShares(secureClient)).resolves.toBeGreaterThan( + initialShares, + ); + }, + { timeout: 15_000 }, + ); + }); + + it('merges a combo position by legs', async ({ + depositWalletAddress, + depositWalletSigner, + relayerAuthentication, + skip, + }) => { + const secureClient = await createSecureClient({ + apiKey: relayerAuthentication, + signer: depositWalletSigner, + wallet: depositWalletAddress, + environment: preproduction, + }); + + expect(secureClient.account.walletType).toBe(WalletType.DEPOSIT_WALLET); + + const initialShares = await fetchComboShares(secureClient); + + if (initialShares === 0) { + skip('No combo position to merge'); + } + + await secureClient + .mergePositions({ + amount: TEST_COMBO_AMOUNT, + legs: TEST_COMBO_LEGS, + }) + .then((handle) => handle.wait()); + + await vi.waitFor( + async () => { + await expect(fetchComboShares(secureClient)).resolves.toBeLessThan( + initialShares, + ); + }, + { timeout: 15_000 }, + ); + }); + }); }); + +async function fetchComboShares(client: SecureClient): Promise { + const page = await client + .listComboPositions({ + conditionId: TEST_COMBO_CONDITION_ID, + pageSize: 5, + }) + .firstPage(); + const combo = page.items.find( + (position) => String(position.conditionId) === TEST_COMBO_CONDITION_ID, + ); + + if (combo === undefined) { + return 0; + } + + const shares = Number(combo.shares); + + expect(Number.isFinite(shares)).toBe(true); + + return shares; +} diff --git a/packages/types/src/helpers.ts b/packages/types/src/helpers.ts index 8e03b9d..c40583f 100644 --- a/packages/types/src/helpers.ts +++ b/packages/types/src/helpers.ts @@ -1,5 +1,7 @@ import { InvariantError } from './errors'; +export type { Tagged } from 'type-fest'; + /** * Flattens an object type for clearer IDE hovers and inferred signatures. */ From d134853cde198e6a878e895b2f6609c43a8b4ea7 Mon Sep 17 00:00:00 2001 From: Cesare Naldi <3353250+cesarenaldi@users.noreply.github.com> Date: Fri, 5 Jun 2026 12:04:23 +0200 Subject: [PATCH 2/4] feat(client): support combo position redeem --- .changeset/combo-redeem-position-id.md | 5 + packages/client/src/abis.ts | 23 +++ packages/client/src/actions/positions.ts | 217 ++++++++++++++++++++--- packages/client/src/decorators/wallet.ts | 21 ++- packages/client/src/protocol.test.ts | 33 ++++ packages/client/src/protocol.ts | 39 +++- 6 files changed, 311 insertions(+), 27 deletions(-) create mode 100644 .changeset/combo-redeem-position-id.md diff --git a/.changeset/combo-redeem-position-id.md b/.changeset/combo-redeem-position-id.md new file mode 100644 index 0000000..aab98e6 --- /dev/null +++ b/.changeset/combo-redeem-position-id.md @@ -0,0 +1,5 @@ +--- +"@polymarket/client": patch +--- + +Add support for redeeming full combo position balances by position ID. diff --git a/packages/client/src/abis.ts b/packages/client/src/abis.ts index 71ab518..8901e78 100644 --- a/packages/client/src/abis.ts +++ b/packages/client/src/abis.ts @@ -25,6 +25,9 @@ const ERC1155_SET_APPROVAL_FOR_ALL_FUNCTION = AbiFunction.from( const ERC1155_IS_APPROVED_FOR_ALL_FUNCTION = AbiFunction.from( 'function isApprovedForAll(address account, address operator) view returns (bool)', ); +const ERC1155_BALANCE_OF_FUNCTION = AbiFunction.from( + 'function balanceOf(address account, uint256 id) view returns (uint256)', +); const CTF_SPLIT_POSITION_FUNCTION = AbiFunction.from( 'function splitPosition(address collateralToken, bytes32 parentCollectionId, bytes32 conditionId, uint256[] partition, uint256 amount)', ); @@ -135,6 +138,26 @@ export function decodeErc1155IsApprovedForAllResult(data: HexString): boolean { return AbiFunction.decodeResult(ERC1155_IS_APPROVED_FOR_ALL_FUNCTION, data); } +/** @internal */ +export function erc1155BalanceOfCall( + tokenAddress: EvmAddress, + owner: EvmAddress, + positionId: bigint, +): TransactionCall { + return { + data: AbiFunction.encodeData(ERC1155_BALANCE_OF_FUNCTION, [ + owner, + expectUint256(positionId, 'Position ID'), + ]), + to: tokenAddress, + }; +} + +/** @internal */ +export function decodeErc1155BalanceOfResult(data: HexString): bigint { + return AbiFunction.decodeResult(ERC1155_BALANCE_OF_FUNCTION, data); +} + export type Erc20TransferCallError = UserInputError; export const Erc20TransferCallError = makeErrorGuard(UserInputError); diff --git a/packages/client/src/actions/positions.ts b/packages/client/src/actions/positions.ts index cf5674f..e26fb7e 100644 --- a/packages/client/src/actions/positions.ts +++ b/packages/client/src/actions/positions.ts @@ -17,8 +17,11 @@ import { z } from 'zod'; import { combinatorialPrepareConditionCall, ctfRedeemPositionsCall, + decodeErc1155BalanceOfResult, + erc1155BalanceOfCall, mergePositionsCall, mergeV2Call, + redeemV2Call, splitPositionCall, splitV2Call, } from '../abis'; @@ -39,6 +42,7 @@ import { parseUserInput } from '../input'; import { type CanonicalComboLegs, canonicalizeComboLegs, + decodeComboPositionId, deriveComboConditionId, } from '../protocol'; import { @@ -144,19 +148,6 @@ const PrepareSplitPositionRequestSchema = z.union([ }), ]) satisfies z.ZodType; -const PrepareRedeemPositionsRequestSchema = z.union([ - z.object({ - conditionId: ConditionIdSchema, - marketId: z.never().optional(), - metadata: GaslessTransactionMetadataSchema.optional(), - }), - z.object({ - conditionId: z.never().optional(), - marketId: z.string().min(1), - metadata: GaslessTransactionMetadataSchema.optional(), - }), -]); - export type SplitPositionWorkflowRequest = | GaslessWorkflowRequest | SendSplitPositionTransactionRequest; @@ -187,10 +178,6 @@ export type RedeemPositionsWorkflow = AsyncGenerator< EvmAddress | EvmSignature | TransactionHandle >; -export type PrepareRedeemPositionsRequest = z.input< - typeof PrepareRedeemPositionsRequestSchema ->; - export type PrepareSplitPositionError = UserInputError; export const PrepareSplitPositionError = makeErrorGuard(UserInputError); export type PrepareSplitMarketPositionError = PrepareSplitPositionError; @@ -227,6 +214,10 @@ export const PrepareRedeemPositionsError = makeErrorGuard( UnexpectedResponseError, UserInputError, ); +export type PrepareRedeemMarketPositionsError = PrepareRedeemPositionsError; +export const PrepareRedeemMarketPositionsError = PrepareRedeemPositionsError; +export type PrepareRedeemComboPositionError = PrepareRedeemPositionsError; +export const PrepareRedeemComboPositionError = PrepareRedeemPositionsError; /** * Starts a split workflow for a market condition. @@ -722,14 +713,103 @@ export function mergePositions( } /** - * Starts a redemption workflow for resolved positions. + * Parameters for preparing a market position redemption by condition ID. + */ +export type PrepareRedeemMarketPositionsByConditionIdRequest = { + /** Existing market condition ID that identifies the positions to redeem. */ + conditionId: string | ConditionId; + marketId?: never; + amount?: never; + positionId?: never; + /** Optional transaction metadata for workflows that support metadata. */ + metadata?: string; +}; + +/** + * Parameters for preparing a market position redemption by market ID. + */ +export type PrepareRedeemMarketPositionsByMarketIdRequest = { + conditionId?: never; + /** Existing market ID that identifies the positions to redeem. */ + marketId: string; + amount?: never; + positionId?: never; + /** Optional transaction metadata for workflows that support metadata. */ + metadata?: string; +}; + +/** + * Parameters for preparing a combo position redemption. + */ +export type PrepareRedeemComboPositionRequest = { + /** Protocol v2 combo YES/NO position ID to redeem. */ + positionId: string | PositionId; + conditionId?: never; + marketId?: never; + /** Optional transaction metadata for workflows that support metadata. */ + metadata?: string; +}; + +/** + * Parameters for preparing either supported position redemption workflow. + * + * @remarks + * Provide either a market `conditionId` or `marketId`, or a combo `positionId`. + */ +export type PrepareRedeemPositionsRequest = + | PrepareRedeemMarketPositionsByConditionIdRequest + | PrepareRedeemMarketPositionsByMarketIdRequest + | PrepareRedeemComboPositionRequest; + +export type PrepareRedeemMarketPositionsRequest = Extract< + PrepareRedeemPositionsRequest, + | PrepareRedeemMarketPositionsByConditionIdRequest + | PrepareRedeemMarketPositionsByMarketIdRequest +>; + +const PrepareRedeemMarketPositionsByConditionIdRequestSchema = z.object({ + conditionId: ConditionIdSchema, + marketId: z.never().optional(), + amount: z.never().optional(), + positionId: z.never().optional(), + metadata: GaslessTransactionMetadataSchema.optional(), +}) satisfies z.ZodType; + +const PrepareRedeemMarketPositionsByMarketIdRequestSchema = z.object({ + conditionId: z.never().optional(), + marketId: z.string().min(1), + amount: z.never().optional(), + positionId: z.never().optional(), + metadata: GaslessTransactionMetadataSchema.optional(), +}) satisfies z.ZodType; + +const PrepareRedeemMarketPositionsRequestSchema = z.union([ + PrepareRedeemMarketPositionsByConditionIdRequestSchema, + PrepareRedeemMarketPositionsByMarketIdRequestSchema, +]) satisfies z.ZodType; + +const PrepareRedeemComboPositionRequestSchema = z.object({ + positionId: PositionIdSchema, + conditionId: z.never().optional(), + marketId: z.never().optional(), + metadata: GaslessTransactionMetadataSchema.optional(), +}) satisfies z.ZodType; + +const PrepareRedeemPositionsRequestSchema = z.union([ + PrepareRedeemMarketPositionsByConditionIdRequestSchema, + PrepareRedeemMarketPositionsByMarketIdRequestSchema, + PrepareRedeemComboPositionRequestSchema, +]) satisfies z.ZodType; + +/** + * Starts a redemption workflow for resolved market positions. * * @remarks * This is a low-level function. Most SDK consumers should prefer the client instance API. * * @example * ```ts - * const workflow = await prepareRedeemPositions(client, { + * const workflow = await prepareRedeemMarketPositions(client, { * conditionId: * '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', * }); @@ -737,19 +817,22 @@ export function mergePositions( * * @example * ```ts - * const workflow = await prepareRedeemPositions(client, { + * const workflow = await prepareRedeemMarketPositions(client, { * marketId: '12345', * }); * ``` * - * @throws {@link PrepareRedeemPositionsError} + * @throws {@link PrepareRedeemMarketPositionsError} * Thrown on failure. */ -export async function prepareRedeemPositions( +export async function prepareRedeemMarketPositions( client: BaseSecureClient, - request: PrepareRedeemPositionsRequest, + request: PrepareRedeemMarketPositionsRequest, ): Promise { - const params = parseUserInput(request, PrepareRedeemPositionsRequestSchema); + const params = parseUserInput( + request, + PrepareRedeemMarketPositionsRequestSchema, + ); const positions = await listPositions(client, { user: client.account.wallet, market: [params.conditionId ?? params.marketId], @@ -783,6 +866,90 @@ export async function prepareRedeemPositions( }.call(null); } +/** + * Starts a redemption workflow for a resolved combo position. + * + * @remarks + * This is a low-level function. Most SDK consumers should prefer the client instance API. + * + * @example + * ```ts + * const workflow = await prepareRedeemComboPosition(client, { + * positionId: '123', + * }); + * ``` + * + * @throws {@link PrepareRedeemComboPositionError} + * Thrown on failure. + */ +export async function prepareRedeemComboPosition( + client: BaseSecureClient, + request: PrepareRedeemComboPositionRequest, +): Promise { + const params = parseUserInput( + request, + PrepareRedeemComboPositionRequestSchema, + ); + const decoded = decodeComboPositionId(params.positionId); + const balance = decodeErc1155BalanceOfResult( + await client.rpc.ethCall( + erc1155BalanceOfCall( + client.environment.positionManager, + client.account.wallet, + decoded.positionId, + ), + ), + ); + + if (balance === 0n) { + throw new UserInputError('Combo position has no balance to redeem'); + } + + const call = redeemV2Call( + client.environment.protocolV2Router, + decoded.conditionId, + decoded.outcomeIndex, + balance, + ); + + return async function* (): RedeemPositionsWorkflow { + if (client.account.walletType === WalletType.EOA) { + return expectTransactionHandle( + yield sendRedeemPositionsTransaction( + signerTransactionRequest(client.environment.chainId, call), + ), + ); + } + + return yield* await prepareGaslessTransaction(client, { + calls: [call], + metadata: params.metadata ?? `Redeem combo position ${params.positionId}`, + }); + }.call(null); +} + +/** + * Starts a redemption workflow for resolved market or combo positions. + * + * @remarks + * This is a low-level function. Most SDK consumers should prefer the client instance API. + * + * @throws {@link PrepareRedeemPositionsError} + * Thrown on failure. + */ +export async function prepareRedeemPositions( + client: BaseSecureClient, + request: PrepareRedeemPositionsRequest, +): Promise { + const params = parseUserInput(request, PrepareRedeemPositionsRequestSchema); + + if (params.positionId !== undefined) { + return prepareRedeemComboPosition(client, params); + } + + return prepareRedeemMarketPositions(client, params); +} + export type RedeemPositionsError = | PrepareRedeemPositionsError | CancelledSigningError @@ -798,7 +965,7 @@ export const RedeemPositionsError = makeErrorGuard( ); /** - * Redeems resolved market positions. + * Redeems resolved market or combo positions. * * @remarks * This is a low-level function. Most SDK consumers should prefer the client instance API. diff --git a/packages/client/src/decorators/wallet.ts b/packages/client/src/decorators/wallet.ts index ee7925a..1730e6e 100644 --- a/packages/client/src/decorators/wallet.ts +++ b/packages/client/src/decorators/wallet.ts @@ -170,18 +170,26 @@ export type SecureWalletActions = { request: PrepareMergePositionsRequest, ): Promise; /** - * Redeems resolved market positions. + * Redeems resolved market or combo positions. * * @throws {@link RedeemPositionsError} * Thrown on failure. * * @example * ```ts + * // Redeem a market by condition ID. * const handle = await client.redeemPositions({ * conditionId: * '0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', * }); * + * const outcome = await handle.wait(); + * + * // outcome.transactionHash: TxHash + * ``` + * + * @example Redeem a market by market ID. + * ```ts * const handle = await client.redeemPositions({ * marketId: '12345', * }); @@ -190,6 +198,17 @@ export type SecureWalletActions = { * * // outcome.transactionHash: TxHash * ``` + * + * @example Redeem a combo by position ID. + * ```ts + * const handle = await client.redeemPositions({ + * positionId: '123', + * }); + * + * const outcome = await handle.wait(); + * + * // outcome.transactionHash: TxHash + * ``` */ redeemPositions( request: PrepareRedeemPositionsRequest, diff --git a/packages/client/src/protocol.test.ts b/packages/client/src/protocol.test.ts index 19636c5..165a5bc 100644 --- a/packages/client/src/protocol.test.ts +++ b/packages/client/src/protocol.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest'; import { type CanonicalComboLegs, canonicalizeComboLegs, + decodeComboPositionId, deriveComboConditionId, } from './protocol'; @@ -40,6 +41,30 @@ describe('Protocol helpers', () => { expect(deriveComboConditionId(legs)).toBe(CONDITION_ID); }); }); + + describe('decodeComboPositionId', () => { + it('decodes a combo position ID into a condition ID and outcome index', () => { + const positionId = comboPosition(CONDITION_ID, 1); + + expect(decodeComboPositionId(positionId)).toEqual({ + conditionId: CONDITION_ID, + outcomeIndex: 1, + positionId: BigInt(positionId), + }); + }); + + it('rejects non-combo position IDs', () => { + expect(() => decodeComboPositionId(legPosition(1, 0))).toThrow( + /combinatorial module/, + ); + }); + + it('rejects combo position IDs with non-binary outcomes', () => { + expect(() => + decodeComboPositionId(comboPosition(CONDITION_ID, 2)), + ).toThrow(/YES\/NO/); + }); + }); }); function legPosition(marker: number, outcome: number): PositionId { @@ -54,3 +79,11 @@ function legPosition(marker: number, outcome: number): PositionId { ).toString(), ); } + +function comboPosition(conditionId: string, outcome: number): PositionId { + return toPositionId(BigInt(`${conditionId}${byteHex(outcome)}`).toString()); +} + +function byteHex(value: number): string { + return value.toString(16).padStart(2, '0'); +} diff --git a/packages/client/src/protocol.ts b/packages/client/src/protocol.ts index 29d5fa0..c8f5945 100644 --- a/packages/client/src/protocol.ts +++ b/packages/client/src/protocol.ts @@ -13,6 +13,12 @@ export type CanonicalComboLegs = Tagged< 'CanonicalComboLegs' >; +export type DecodedComboPositionId = { + conditionId: ConditionId; + outcomeIndex: 0 | 1; + positionId: bigint; +}; + /** * Derives a combo condition ID from canonical combo legs. */ @@ -97,7 +103,38 @@ export function canonicalizeComboLegs( ) as unknown as CanonicalComboLegs; } -function parsePositionId(positionId: string): bigint { +/** + * Decodes a combo YES/NO position ID into its condition ID and outcome index. + * + * @throws {@link UserInputError} + * Thrown when the position ID is not a valid combo YES/NO position ID. + */ +export function decodeComboPositionId( + positionId: PositionId, +): DecodedComboPositionId { + const value = parsePositionId(positionId); + const hex = value.toString(16).padStart(UINT256_BYTE_LENGTH * 2, '0'); + const moduleId = BigInt(`0x${hex.slice(0, 2)}`); + const outcomeIndex = Number.parseInt(hex.slice(-2), 16); + + if (moduleId !== COMBINATORIAL_MODULE_ID) { + throw new UserInputError( + 'Combo position ID must use the combinatorial module', + ); + } + + if (outcomeIndex !== 0 && outcomeIndex !== 1) { + throw new UserInputError('Combo position ID must be a YES/NO position ID'); + } + + return { + conditionId: `0x${hex.slice(0, -2)}` as ConditionId, + outcomeIndex, + positionId: value, + }; +} + +function parsePositionId(positionId: PositionId): bigint { const value = positionId.trim(); let parsed: bigint; From 61754591b4a147f61c9b19e418531c04a1f3105c Mon Sep 17 00:00:00 2001 From: Cesare Naldi <3353250+cesarenaldi@users.noreply.github.com> Date: Fri, 5 Jun 2026 22:12:36 +0200 Subject: [PATCH 3/4] feat(client): support balance-backed position lifecycle --- .changeset/combo-split-merge-legs.md | 2 +- packages/client/src/abis.ts | 31 +- packages/client/src/actions/positions.ts | 370 ++++++++---------- packages/client/src/decorators/wallet.ts | 2 +- packages/client/src/protocol.test.ts | 23 +- packages/client/src/protocol.ts | 37 +- .../tests/integration/positions.test.ts | 11 +- 7 files changed, 232 insertions(+), 244 deletions(-) diff --git a/.changeset/combo-split-merge-legs.md b/.changeset/combo-split-merge-legs.md index 0226ae1..81a13e7 100644 --- a/.changeset/combo-split-merge-legs.md +++ b/.changeset/combo-split-merge-legs.md @@ -2,4 +2,4 @@ "@polymarket/client": patch --- -Add support for splitting and merging combo positions by legs. +Add support for splitting and merging combo positions by legs, including `amount: 'max'` for combo merge. diff --git a/packages/client/src/abis.ts b/packages/client/src/abis.ts index 8901e78..6525132 100644 --- a/packages/client/src/abis.ts +++ b/packages/client/src/abis.ts @@ -1,4 +1,4 @@ -import type { ConditionId } from '@polymarket/bindings'; +import type { ConditionId, PositionId, TokenId } from '@polymarket/bindings'; import { type EvmAddress, type HexString, invariant } from '@polymarket/types'; import { AbiFunction, AbiParameters } from 'ox'; import { makeErrorGuard, UserInputError } from './errors'; @@ -28,6 +28,9 @@ const ERC1155_IS_APPROVED_FOR_ALL_FUNCTION = AbiFunction.from( const ERC1155_BALANCE_OF_FUNCTION = AbiFunction.from( 'function balanceOf(address account, uint256 id) view returns (uint256)', ); +const ERC1155_BALANCE_OF_BATCH_FUNCTION = AbiFunction.from( + 'function balanceOfBatch(address[] accounts, uint256[] ids) view returns (uint256[])', +); const CTF_SPLIT_POSITION_FUNCTION = AbiFunction.from( 'function splitPosition(address collateralToken, bytes32 parentCollectionId, bytes32 conditionId, uint256[] partition, uint256 amount)', ); @@ -142,12 +145,12 @@ export function decodeErc1155IsApprovedForAllResult(data: HexString): boolean { export function erc1155BalanceOfCall( tokenAddress: EvmAddress, owner: EvmAddress, - positionId: bigint, + id: PositionId, ): TransactionCall { return { data: AbiFunction.encodeData(ERC1155_BALANCE_OF_FUNCTION, [ owner, - expectUint256(positionId, 'Position ID'), + expectUint256(BigInt(id), 'Position ID'), ]), to: tokenAddress, }; @@ -158,6 +161,28 @@ export function decodeErc1155BalanceOfResult(data: HexString): bigint { return AbiFunction.decodeResult(ERC1155_BALANCE_OF_FUNCTION, data); } +/** @internal */ +export function erc1155BalanceOfBatchCall( + tokenAddress: EvmAddress, + owner: EvmAddress, + ids: readonly (PositionId | TokenId)[], +): TransactionCall { + return { + data: AbiFunction.encodeData(ERC1155_BALANCE_OF_BATCH_FUNCTION, [ + ids.map(() => owner), + ids.map((id) => expectUint256(BigInt(id), 'id')), + ]), + to: tokenAddress, + }; +} + +/** @internal */ +export function decodeErc1155BalanceOfBatchResult( + data: HexString, +): readonly bigint[] { + return AbiFunction.decodeResult(ERC1155_BALANCE_OF_BATCH_FUNCTION, data); +} + export type Erc20TransferCallError = UserInputError; export const Erc20TransferCallError = makeErrorGuard(UserInputError); diff --git a/packages/client/src/actions/positions.ts b/packages/client/src/actions/positions.ts index e26fb7e..8afbcdf 100644 --- a/packages/client/src/actions/positions.ts +++ b/packages/client/src/actions/positions.ts @@ -1,23 +1,28 @@ import type { ConditionId, - DecimalString, + MarketId, PositionId, + TokenId, } from '@polymarket/bindings'; -import { ConditionIdSchema, PositionIdSchema } from '@polymarket/bindings'; -import type { Position } from '@polymarket/bindings/data'; -import { WalletType } from '@polymarket/bindings/gamma'; +import { + ConditionIdSchema, + MarketIdSchema, + PositionIdSchema, +} from '@polymarket/bindings'; +import { type Market, WalletType } from '@polymarket/bindings/gamma'; import { type EvmAddress, type EvmSignature, invariant, - isNullish, isPresent, } from '@polymarket/types'; import { z } from 'zod'; import { combinatorialPrepareConditionCall, ctfRedeemPositionsCall, + decodeErc1155BalanceOfBatchResult, decodeErc1155BalanceOfResult, + erc1155BalanceOfBatchCall, erc1155BalanceOfCall, mergePositionsCall, mergeV2Call, @@ -42,8 +47,8 @@ import { parseUserInput } from '../input'; import { type CanonicalComboLegs, canonicalizeComboLegs, - decodeComboPositionId, - deriveComboConditionId, + decodeComboOutcomePositionId, + deriveComboPositionContext, } from '../protocol'; import { expectTransactionHandle, @@ -64,13 +69,6 @@ import { type WaitForGaslessTransactionError, } from './gasless'; import { listMarkets } from './markets'; -import { listPositions } from './portfolio'; - -type BinaryPositions = - | readonly [yes: Position, no: Position | undefined] - | readonly [yes: undefined, no: Position]; - -type PositiveAmount = bigint; /** * Parameters for preparing a market position split. @@ -245,14 +243,13 @@ export async function prepareSplitMarketPosition( request, PrepareSplitMarketPositionRequestSchema, ); - const negativeRisk = await resolveMarketNegativeRiskFlag( - client, - params.conditionId, - ); + const context = await resolveMarketClobContext(client, { + conditionId: params.conditionId, + }); const call = splitPositionCall( - resolveLifecycleTargetAddress(client, negativeRisk), + context.adapterAddress, client.environment.collateralToken, - params.conditionId, + context.conditionId, params.amount, ); @@ -269,7 +266,7 @@ export async function prepareSplitMarketPosition( calls: [call], metadata: params.metadata ?? - `Split ${params.amount} positions for condition ${params.conditionId}`, + `Split ${params.amount} positions for market ${context.marketId} (condition ${context.conditionId})`, }); }.call(null); } @@ -303,10 +300,10 @@ export async function prepareSplitComboPosition( client.environment.combinatorialModule, params.legs, ); - const conditionId = deriveComboConditionId(params.legs); + const combo = deriveComboPositionContext(params.legs); const splitCall = splitV2Call( client.environment.protocolV2Router, - conditionId, + combo.conditionId, params.amount, ); @@ -333,7 +330,7 @@ export async function prepareSplitComboPosition( calls: [prepareConditionCall, splitCall], metadata: params.metadata ?? - `Split ${params.amount} combo positions for condition ${conditionId}`, + `Split ${params.amount} combo positions for condition ${combo.conditionId}`, }); }.call(null); } @@ -446,7 +443,7 @@ export type PrepareMergeMarketPositionRequest = { */ export type PrepareMergeComboPositionRequest = { /** Amount per complementary combo position to merge. */ - amount: bigint; + amount: bigint | 'max'; /** Protocol v2 leg position IDs that define the combo condition. */ legs: string[] | PositionId[]; /** Optional transaction metadata for workflows that support metadata. */ @@ -464,7 +461,7 @@ export type PrepareMergePositionsRequest = | PrepareMergeComboPositionRequest; type ParsedMergeComboPositionRequest = { - amount: bigint; + amount: bigint | 'max'; legs: CanonicalComboLegs; metadata?: string; }; @@ -476,13 +473,13 @@ const PrepareMergeMarketPositionRequestSchema = z.object({ }) satisfies z.ZodType; const PrepareMergeComboPositionInputSchema = z.object({ - amount: z.bigint().positive(), + amount: z.union([z.bigint().positive(), z.literal('max')]), legs: z.array(PositionIdSchema).min(1).max(50), metadata: GaslessTransactionMetadataSchema.optional(), }) satisfies z.ZodType; const PrepareMergeComboPositionRequestSchema = z.object({ - amount: z.bigint().positive(), + amount: z.union([z.bigint().positive(), z.literal('max')]), legs: CanonicalComboLegsSchema, metadata: GaslessTransactionMetadataSchema.optional(), }) satisfies z.ZodType< @@ -525,20 +522,27 @@ export async function prepareMergeMarketPosition( request, PrepareMergeMarketPositionRequestSchema, ); - const positions = await listPositions(client, { - user: client.account.wallet, - market: [params.conditionId], - sizeThreshold: 0, - }) - .firstPage() - .then((page) => page.items); - const binaryPositions = expectBinaryPositions(positions); - const negativeRisk = expectNegativeRiskFlag(binaryPositions); - const amount = resolveMergeAmount(binaryPositions, params.amount); + const context = await resolveMarketClobContext(client, { + conditionId: params.conditionId, + }); + const balances = decodeErc1155BalanceOfBatchResult( + await client.rpc.ethCall( + erc1155BalanceOfBatchCall( + context.positionErc1155Address, + client.account.wallet, + context.tokenIds, + ), + ), + ); + const amount = resolveMergeAmount( + context.conditionId, + balances, + params.amount, + ); const call = mergePositionsCall( - resolveLifecycleTargetAddress(client, negativeRisk), + context.adapterAddress, client.environment.collateralToken, - params.conditionId, + context.conditionId, amount, ); @@ -555,7 +559,7 @@ export async function prepareMergeMarketPosition( calls: [call], metadata: params.metadata ?? - `Merge ${amount} positions for condition ${params.conditionId}`, + `Merge ${amount} positions for market ${context.marketId} (condition ${context.conditionId})`, }); }.call(null); } @@ -569,7 +573,7 @@ export async function prepareMergeMarketPosition( * @example * ```ts * const workflow = await prepareMergeComboPosition(client, { - * amount: 1n, + * amount: 'max', * legs: ['123', '456'], * }); * ``` @@ -589,11 +593,21 @@ export async function prepareMergeComboPosition( client.environment.combinatorialModule, params.legs, ); - const conditionId = deriveComboConditionId(params.legs); + const combo = deriveComboPositionContext(params.legs); + const balances = decodeErc1155BalanceOfBatchResult( + await client.rpc.ethCall( + erc1155BalanceOfBatchCall( + client.environment.positionManager, + client.account.wallet, + combo.positionIds, + ), + ), + ); + const amount = resolveMergeAmount(combo.conditionId, balances, params.amount); const mergeCall = mergeV2Call( client.environment.protocolV2Router, - conditionId, - params.amount, + combo.conditionId, + amount, ); return async function* (): MergePositionsWorkflow { @@ -619,7 +633,7 @@ export async function prepareMergeComboPosition( calls: [prepareConditionCall, mergeCall], metadata: params.metadata ?? - `Merge ${params.amount} combo positions for condition ${conditionId}`, + `Merge ${amount} combo positions for condition ${combo.conditionId}`, }); }.call(null); } @@ -644,14 +658,22 @@ export async function prepareMergePositions( } export type MergePositionsError = - | PrepareMergePositionsError | CancelledSigningError - | SigningError; + | RateLimitError + | RequestRejectedError + | SigningError + | TimeoutError + | TransactionFailedError + | TransportError + | UnexpectedResponseError + | UserInputError; export const MergePositionsError = makeErrorGuard( CancelledSigningError, RateLimitError, RequestRejectedError, SigningError, + TimeoutError, + TransactionFailedError, TransportError, UnexpectedResponseError, UserInputError, @@ -761,11 +783,9 @@ export type PrepareRedeemPositionsRequest = | PrepareRedeemMarketPositionsByMarketIdRequest | PrepareRedeemComboPositionRequest; -export type PrepareRedeemMarketPositionsRequest = Extract< - PrepareRedeemPositionsRequest, +export type PrepareRedeemMarketPositionsRequest = | PrepareRedeemMarketPositionsByConditionIdRequest - | PrepareRedeemMarketPositionsByMarketIdRequest ->; + | PrepareRedeemMarketPositionsByMarketIdRequest; const PrepareRedeemMarketPositionsByConditionIdRequestSchema = z.object({ conditionId: ConditionIdSchema, @@ -777,7 +797,7 @@ const PrepareRedeemMarketPositionsByConditionIdRequestSchema = z.object({ const PrepareRedeemMarketPositionsByMarketIdRequestSchema = z.object({ conditionId: z.never().optional(), - marketId: z.string().min(1), + marketId: MarketIdSchema, amount: z.never().optional(), positionId: z.never().optional(), metadata: GaslessTransactionMetadataSchema.optional(), @@ -833,20 +853,16 @@ export async function prepareRedeemMarketPositions( request, PrepareRedeemMarketPositionsRequestSchema, ); - const positions = await listPositions(client, { - user: client.account.wallet, - market: [params.conditionId ?? params.marketId], - sizeThreshold: 0, - }) - .firstPage() - .then((page) => page.items); - const binaryPositions = expectBinaryPositions(positions); - const conditionId = resolveBinaryPositionsConditionId(binaryPositions); - const negativeRisk = expectNegativeRiskFlag(binaryPositions); + const context = await resolveMarketClobContext( + client, + params.conditionId !== undefined + ? { conditionId: params.conditionId } + : { marketId: params.marketId }, + ); const call = ctfRedeemPositionsCall( - resolveLifecycleTargetAddress(client, negativeRisk), + context.adapterAddress, client.environment.collateralToken, - conditionId, + context.conditionId, ); return async function* (): RedeemPositionsWorkflow { @@ -861,7 +877,8 @@ export async function prepareRedeemMarketPositions( return yield* await prepareGaslessTransaction(client, { calls: [call], metadata: - params.metadata ?? `Redeem positions for condition ${conditionId}`, + params.metadata ?? + `Redeem positions for market ${context.marketId} (condition ${context.conditionId})`, }); }.call(null); } @@ -890,13 +907,13 @@ export async function prepareRedeemComboPosition( request, PrepareRedeemComboPositionRequestSchema, ); - const decoded = decodeComboPositionId(params.positionId); + const decoded = decodeComboOutcomePositionId(params.positionId); const balance = decodeErc1155BalanceOfResult( await client.rpc.ethCall( erc1155BalanceOfCall( client.environment.positionManager, client.account.wallet, - decoded.positionId, + params.positionId, ), ), ); @@ -1009,139 +1026,101 @@ function sendRedeemPositionsTransaction( }; } -function resolveLifecycleTargetAddress( - client: BaseSecureClient, - negRisk: boolean, -) { - return negRisk - ? client.environment.negRiskCollateralAdapter - : client.environment.collateralAdapter; -} +type MarketClobContext = { + marketId: MarketId; + conditionId: ConditionId; + negRisk: boolean; + adapterAddress: EvmAddress; + positionErc1155Address: EvmAddress; + tokenIds: [yes: TokenId, no: TokenId]; +}; -async function resolveMarketNegativeRiskFlag( +type ResolveMarketClobContextRequest = + | { conditionId: ConditionId; marketId?: never } + | { marketId: MarketId; conditionId?: never }; + +async function resolveMarketClobContext( client: BaseSecureClient, - conditionId: ConditionId, -): Promise { - const page = await listMarkets(client, { - conditionIds: [conditionId], - pageSize: 2, - }).firstPage(); + request: ResolveMarketClobContextRequest, +): Promise { + const context = + request.conditionId !== undefined + ? `condition ${request.conditionId}` + : `market ${request.marketId}`; + const page = await listMarkets( + client, + request.conditionId !== undefined + ? { conditionIds: [request.conditionId], pageSize: 1 } + : { ids: [parseMarketId(request.marketId)], pageSize: 1 }, + ).firstPage(); const markets = page.items; - invariant( - markets.length === 1, - `Expected exactly one market for condition ${conditionId}`, - ); + invariant(markets.length === 1, `Expected exactly one ${context}`); const market = markets[0]; - invariant( - market !== undefined, - `No market found for condition ${conditionId}`, - ); - invariant( - isPresent(market.state.negRisk), - `Missing negRisk flag for condition ${conditionId}`, - ); + invariant(market !== undefined, `No market found for ${context}`); - return market.state.negRisk; -} + const marketContext = normalizeMarketClobContext(market, context); -function expectNegativeRiskFlag([ - yesPosition, - noPosition, -]: BinaryPositions): boolean { - const first = yesPosition ?? noPosition; - const conditionId = first.conditionId; + return { + ...marketContext, + adapterAddress: marketContext.negRisk + ? client.environment.negRiskCollateralAdapter + : client.environment.collateralAdapter, + positionErc1155Address: marketContext.negRisk + ? client.environment.negRiskAdapter + : client.environment.conditionalTokens, + }; +} - invariant( - isPresent(first.negativeRisk), - `Missing negativeRisk flag for condition ${conditionId}`, - ); +function parseMarketId(id: MarketId): number { + const parsed = Number(id); - if (yesPosition !== undefined && noPosition !== undefined) { - invariant( - isPresent(yesPosition.negativeRisk), - `Missing negativeRisk flag for condition ${conditionId}`, - ); - invariant( - isPresent(noPosition.negativeRisk), - `Missing negativeRisk flag for condition ${conditionId}`, - ); - invariant( - yesPosition.negativeRisk === noPosition.negativeRisk, - `Mixed negativeRisk flags for condition ${conditionId}`, - ); + if (!Number.isInteger(parsed)) { + throw new UserInputError(`Market ID must be an integer, received ${id}`); } - return first.negativeRisk; + return parsed; } -function expectBinaryPositions( - positions: readonly Position[], -): BinaryPositions { - const firstPosition = positions[0]; - - if (firstPosition === undefined) { - throw new UserInputError('You have no positions'); +function normalizeMarketClobContext( + market: Market, + context: string, +): Omit { + if (!isPresent(market.conditionId)) { + throw new UnexpectedResponseError(`Missing condition ID for ${context}`); } - const conditionId = firstPosition.conditionId; - - invariant( - positions.length <= 2, - `Expected at most two positions for condition ${conditionId}`, - ); - - let yesPosition: Position | undefined; - let noPosition: Position | undefined; - - for (const position of positions) { - invariant( - position.outcomeIndex === 0 || position.outcomeIndex === 1, - `Unexpected outcomeIndex ${position.outcomeIndex} for condition ${conditionId}`, + if (!isPresent(market.state.negRisk)) { + throw new UnexpectedResponseError( + `Missing negative-risk flag for ${context}`, ); + } - if (position.outcomeIndex === 0) { - invariant( - yesPosition === undefined, - `Duplicate YES position for condition ${conditionId}`, - ); - yesPosition = position; - continue; - } + const yesTokenId = market.outcomes.yes.tokenId; + const noTokenId = market.outcomes.no.tokenId; - invariant( - noPosition === undefined, - `Duplicate NO position for condition ${conditionId}`, + if (!isPresent(yesTokenId) || !isPresent(noTokenId)) { + throw new UnexpectedResponseError( + `Missing market token IDs for ${context}`, ); - noPosition = position; - } - - if (yesPosition !== undefined) { - return [yesPosition, noPosition]; } - invariant( - noPosition !== undefined, - `Expected positions for condition ${conditionId}`, - ); - - return [undefined, noPosition]; -} - -function resolveBinaryPositionsConditionId( - positions: BinaryPositions, -): ConditionId { - return (positions[0] ?? positions[1]).conditionId; + return { + marketId: market.id, + conditionId: market.conditionId, + negRisk: market.state.negRisk, + tokenIds: [yesTokenId, noTokenId], + }; } function resolveMergeAmount( - positions: BinaryPositions, + conditionId: ConditionId, + balances: readonly bigint[], requestedAmount: bigint | 'max', -): PositiveAmount { - const maxAmount = calculateMaxMergeAmount(positions); - const conditionId = resolveBinaryPositionsConditionId(positions); +): bigint { + const maxAmount = calculateMaxMergeAmount(balances); if (maxAmount === 0n) { throw new UserInputError( @@ -1162,44 +1141,15 @@ function resolveMergeAmount( return requestedAmount; } -function calculateMaxMergeAmount([ - yesPosition, - noPosition, -]: BinaryPositions): bigint { - const yesAmount = toPositionAmount(yesPosition, 0); - const noAmount = toPositionAmount(noPosition, 1); - - return yesAmount < noAmount ? yesAmount : noAmount; -} - -function toPositionAmount( - position: Position | undefined, - expectedOutcomeIndex: 0 | 1, -): bigint { - if (position === undefined) { - return 0n; - } - - invariant( - position.outcomeIndex === expectedOutcomeIndex, - `Expected outcomeIndex ${expectedOutcomeIndex}`, - ); - - if (isNullish(position.size)) { - return 0n; +function calculateMaxMergeAmount(balances: readonly bigint[]): bigint { + if (balances.length !== 2) { + throw new UnexpectedResponseError('Expected two position balances'); } - return toTokenBaseUnits(position.size); -} + const [yesAmount, noAmount] = balances; -function toTokenBaseUnits(size: DecimalString): bigint { - const numericSize = Number(size); + invariant(yesAmount !== undefined, 'Expected YES position balance'); + invariant(noAmount !== undefined, 'Expected NO position balance'); - if (!Number.isFinite(numericSize) || numericSize < 0) { - throw new UserInputError( - 'Position size must be a non-negative finite number', - ); - } - - return BigInt(Math.floor(numericSize * 1e6)); + return yesAmount < noAmount ? yesAmount : noAmount; } diff --git a/packages/client/src/decorators/wallet.ts b/packages/client/src/decorators/wallet.ts index 1730e6e..84c66ca 100644 --- a/packages/client/src/decorators/wallet.ts +++ b/packages/client/src/decorators/wallet.ts @@ -157,7 +157,7 @@ export type SecureWalletActions = { * @example Merge a combo by legs. * ```ts * const handle = await client.mergePositions({ - * amount: 1n, + * amount: 'max', * legs: ['123', '456'], * }); * diff --git a/packages/client/src/protocol.test.ts b/packages/client/src/protocol.test.ts index 165a5bc..5221a8f 100644 --- a/packages/client/src/protocol.test.ts +++ b/packages/client/src/protocol.test.ts @@ -3,8 +3,8 @@ import { describe, expect, it } from 'vitest'; import { type CanonicalComboLegs, canonicalizeComboLegs, - decodeComboPositionId, - deriveComboConditionId, + decodeComboOutcomePositionId, + deriveComboPositionContext, } from './protocol'; const CONDITION_ID = @@ -31,14 +31,20 @@ describe('Protocol helpers', () => { }); }); - describe('deriveComboConditionId', () => { - it('derives a combo condition ID from canonical legs', () => { + describe('deriveComboPositionContext', () => { + it('derives a combo condition ID and position IDs from canonical legs', () => { const legs = [ BigInt(legPosition(1, 0)), BigInt(legPosition(2, 1)), ] as unknown as CanonicalComboLegs; - expect(deriveComboConditionId(legs)).toBe(CONDITION_ID); + expect(deriveComboPositionContext(legs)).toEqual({ + conditionId: CONDITION_ID, + positionIds: [ + comboPosition(CONDITION_ID, 0), + comboPosition(CONDITION_ID, 1), + ], + }); }); }); @@ -46,22 +52,21 @@ describe('Protocol helpers', () => { it('decodes a combo position ID into a condition ID and outcome index', () => { const positionId = comboPosition(CONDITION_ID, 1); - expect(decodeComboPositionId(positionId)).toEqual({ + expect(decodeComboOutcomePositionId(positionId)).toEqual({ conditionId: CONDITION_ID, outcomeIndex: 1, - positionId: BigInt(positionId), }); }); it('rejects non-combo position IDs', () => { - expect(() => decodeComboPositionId(legPosition(1, 0))).toThrow( + expect(() => decodeComboOutcomePositionId(legPosition(1, 0))).toThrow( /combinatorial module/, ); }); it('rejects combo position IDs with non-binary outcomes', () => { expect(() => - decodeComboPositionId(comboPosition(CONDITION_ID, 2)), + decodeComboOutcomePositionId(comboPosition(CONDITION_ID, 2)), ).toThrow(/YES\/NO/); }); }); diff --git a/packages/client/src/protocol.ts b/packages/client/src/protocol.ts index c8f5945..88013d5 100644 --- a/packages/client/src/protocol.ts +++ b/packages/client/src/protocol.ts @@ -1,4 +1,8 @@ -import type { ConditionId, PositionId } from '@polymarket/bindings'; +import { + type ConditionId, + type PositionId, + toPositionId, +} from '@polymarket/bindings'; import type { Tagged } from '@polymarket/types'; import { AbiParameters, Hash } from 'ox'; import { UserInputError } from './errors'; @@ -13,16 +17,17 @@ export type CanonicalComboLegs = Tagged< 'CanonicalComboLegs' >; -export type DecodedComboPositionId = { +export type ComboPositionContext = { conditionId: ConditionId; - outcomeIndex: 0 | 1; - positionId: bigint; + positionIds: [yes: PositionId, no: PositionId]; }; /** - * Derives a combo condition ID from canonical combo legs. + * Derives a combo condition ID and its complementary YES/NO position IDs from canonical combo legs. */ -export function deriveComboConditionId(legs: CanonicalComboLegs): ConditionId { +export function deriveComboPositionContext( + legs: CanonicalComboLegs, +): ComboPositionContext { const encodedLegs = AbiParameters.encode( [{ name: 'legs', type: 'uint256[]' }], [legs], @@ -36,8 +41,16 @@ export function deriveComboConditionId(legs: CanonicalComboLegs): ConditionId { [COMBINATORIAL_MODULE_ID, encodedLegs], ), ); + const conditionId = + `0x03${baseHash.slice(34)}0000000000000000000000000000` as ConditionId; - return `0x03${baseHash.slice(34)}0000000000000000000000000000` as ConditionId; + return { + conditionId, + positionIds: [ + toPositionId(BigInt(`${conditionId}00`).toString()), + toPositionId(BigInt(`${conditionId}01`).toString()), + ], + }; } /** @@ -103,15 +116,20 @@ export function canonicalizeComboLegs( ) as unknown as CanonicalComboLegs; } +export type DecodedComboOutcomePositionId = { + conditionId: ConditionId; + outcomeIndex: 0 | 1; +}; + /** * Decodes a combo YES/NO position ID into its condition ID and outcome index. * * @throws {@link UserInputError} * Thrown when the position ID is not a valid combo YES/NO position ID. */ -export function decodeComboPositionId( +export function decodeComboOutcomePositionId( positionId: PositionId, -): DecodedComboPositionId { +): DecodedComboOutcomePositionId { const value = parsePositionId(positionId); const hex = value.toString(16).padStart(UINT256_BYTE_LENGTH * 2, '0'); const moduleId = BigInt(`0x${hex.slice(0, 2)}`); @@ -130,7 +148,6 @@ export function decodeComboPositionId( return { conditionId: `0x${hex.slice(0, -2)}` as ConditionId, outcomeIndex, - positionId: value, }; } diff --git a/packages/client/tests/integration/positions.test.ts b/packages/client/tests/integration/positions.test.ts index 698ac7d..cddd95d 100644 --- a/packages/client/tests/integration/positions.test.ts +++ b/packages/client/tests/integration/positions.test.ts @@ -2,7 +2,6 @@ import { createSecureClient, preproduction, type SecureClient, - WalletType, } from '@polymarket/client'; import { expectPresent } from '@polymarket/types'; import { vi } from 'vitest'; @@ -35,8 +34,6 @@ describe('Positions', () => { wallet: depositWalletAddress, }); - expect(secureClient.account.walletType).toBe(WalletType.DEPOSIT_WALLET); - await secureClient .splitPosition({ amount: 1_000_000n, @@ -70,8 +67,6 @@ describe('Positions', () => { wallet: depositWalletAddress, }); - expect(secureClient.account.walletType).toBe(WalletType.DEPOSIT_WALLET); - const { items: positions } = await secureClient .listPositions({ user: secureClient.account.wallet, @@ -118,8 +113,6 @@ describe('Positions', () => { environment: preproduction, }); - expect(secureClient.account.walletType).toBe(WalletType.DEPOSIT_WALLET); - const initialShares = await fetchComboShares(secureClient); await secureClient @@ -152,8 +145,6 @@ describe('Positions', () => { environment: preproduction, }); - expect(secureClient.account.walletType).toBe(WalletType.DEPOSIT_WALLET); - const initialShares = await fetchComboShares(secureClient); if (initialShares === 0) { @@ -162,7 +153,7 @@ describe('Positions', () => { await secureClient .mergePositions({ - amount: TEST_COMBO_AMOUNT, + amount: 'max', legs: TEST_COMBO_LEGS, }) .then((handle) => handle.wait()); From 4b256b0843145a3d3ba330284e31edf7b93eb2d2 Mon Sep 17 00:00:00 2001 From: Cesare Naldi <3353250+cesarenaldi@users.noreply.github.com> Date: Fri, 5 Jun 2026 22:27:22 +0200 Subject: [PATCH 4/4] fix(client): flatten split position error union --- packages/client/src/actions/positions.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/client/src/actions/positions.ts b/packages/client/src/actions/positions.ts index 8afbcdf..7c3f607 100644 --- a/packages/client/src/actions/positions.ts +++ b/packages/client/src/actions/positions.ts @@ -66,7 +66,6 @@ import { GaslessTransactionMetadataSchema, type GaslessWorkflowRequest, prepareGaslessTransaction, - type WaitForGaslessTransactionError, } from './gasless'; import { listMarkets } from './markets'; @@ -355,10 +354,15 @@ export async function prepareSplitPosition( } export type SplitPositionError = - | PrepareSplitPositionError | CancelledSigningError + | RateLimitError + | RequestRejectedError | SigningError - | WaitForGaslessTransactionError; + | TimeoutError + | TransactionFailedError + | TransportError + | UnexpectedResponseError + | UserInputError; export const SplitPositionError = makeErrorGuard( CancelledSigningError, RateLimitError,