From b9bd235867b471821e39337d11a783e783fd18ed Mon Sep 17 00:00:00 2001 From: Daniel Constantin Date: Fri, 17 Jul 2026 11:04:46 +0300 Subject: [PATCH 1/8] feat: add new getTwapOrders method --- packages/composable/README.md | 19 + packages/composable/package.json | 3 +- packages/composable/src/index.ts | 1 + .../ProgrammaticOrderApi.ts | 87 +++ .../src/programmaticOrders/client.ts | 200 +++++++ .../src/programmaticOrders/common/parse.ts | 133 +++++ .../src/programmaticOrders/common/types.ts | 37 ++ .../src/programmaticOrders/index.ts | 8 + .../src/programmaticOrders/twap/parse.ts | 86 +++ .../src/programmaticOrders/twap/queries.ts | 153 +++++ .../src/programmaticOrders/twap/types.ts | 113 ++++ .../tests/ProgrammaticOrderApi.spec.ts | 54 ++ .../ProgrammaticOrderApi.spec.ts.snap | 551 ++++++++++++++++++ pnpm-lock.yaml | 15 + 14 files changed, 1459 insertions(+), 1 deletion(-) create mode 100644 packages/composable/src/programmaticOrders/ProgrammaticOrderApi.ts create mode 100644 packages/composable/src/programmaticOrders/client.ts create mode 100644 packages/composable/src/programmaticOrders/common/parse.ts create mode 100644 packages/composable/src/programmaticOrders/common/types.ts create mode 100644 packages/composable/src/programmaticOrders/index.ts create mode 100644 packages/composable/src/programmaticOrders/twap/parse.ts create mode 100644 packages/composable/src/programmaticOrders/twap/queries.ts create mode 100644 packages/composable/src/programmaticOrders/twap/types.ts create mode 100644 packages/composable/tests/ProgrammaticOrderApi.spec.ts create mode 100644 packages/composable/tests/__snapshots__/ProgrammaticOrderApi.spec.ts.snap diff --git a/packages/composable/README.md b/packages/composable/README.md index eb434d08c..c6f0f8c29 100644 --- a/packages/composable/README.md +++ b/packages/composable/README.md @@ -22,6 +22,25 @@ or yarn add @cowprotocol/sdk-composable ``` +## Read TWAP history + +`ProgrammaticOrderApi` lists TWAP orders and their part orders for an EVM owner. It resolves current proxy mappings, so the protocol `owner` may differ from `resolvedOwner`. + +```typescript +import { ProgrammaticOrderApi } from '@cowprotocol/sdk-composable' +import { SupportedChainId } from '@cowprotocol/sdk-config' + +const api = new ProgrammaticOrderApi() +const twapOrders = await api.getTwapOrders({ + owner: '0x...', + chainId: SupportedChainId.GNOSIS_CHAIN, +}) +``` + +Each result preserves both its creation `eventId` and ComposableCoW `hash`. `schedule.numberOfParts` is the number of scheduled parts. `partOrders` contains the corresponding part orders, so its length may be smaller. `executedAmounts` sums those part orders. The schedule's `effectiveStartTime` is the creation block timestamp when the on-chain `t0` was zero. Pass `apiUrl` to the constructor to use another programmatic orders API deployment. + +Source contracts: [conditional-order generator](https://github.com/bleu/cow-programmatic-orders-api/blob/main/src/api/gql-docs/conditional-order-generator.ts), [discrete order](https://github.com/bleu/cow-programmatic-orders-api/blob/main/src/api/gql-docs/discrete-order.ts), [TWAP schedule](https://github.com/bleu/cow-programmatic-orders-api/blob/main/docs/supported-order-types.md#twap-time-weighted-average-price), and the canonical [ComposableCoW TWAP order](https://github.com/cowprotocol/composable-cow/blob/main/src/types/twap/libraries/TWAPOrder.sol#L31-L42). + ## Core Components ### ConditionalOrderFactory diff --git a/packages/composable/package.json b/packages/composable/package.json index 4f5321e2b..5ffd5759f 100644 --- a/packages/composable/package.json +++ b/packages/composable/package.json @@ -37,7 +37,8 @@ "@cowprotocol/sdk-contracts-ts": "workspace:*", "@cowprotocol/sdk-order-book": "workspace:*", "@cowprotocol/sdk-order-signing": "workspace:*", - "@openzeppelin/merkle-tree": "^1.0.8" + "@openzeppelin/merkle-tree": "^1.0.8", + "valibot": "1.4.2" }, "devDependencies": { "@cowprotocol/sdk-ethers-v5-adapter": "workspace:*", diff --git a/packages/composable/src/index.ts b/packages/composable/src/index.ts index ea7e7f484..952195124 100644 --- a/packages/composable/src/index.ts +++ b/packages/composable/src/index.ts @@ -4,3 +4,4 @@ export * from './ConditionalOrder' export * from './Multiplexer' export * from './ConditionalOrderFactory' export * from './orderTypes' +export * from './programmaticOrders' diff --git a/packages/composable/src/programmaticOrders/ProgrammaticOrderApi.ts b/packages/composable/src/programmaticOrders/ProgrammaticOrderApi.ts new file mode 100644 index 000000000..e733586d0 --- /dev/null +++ b/packages/composable/src/programmaticOrders/ProgrammaticOrderApi.ts @@ -0,0 +1,87 @@ +import { isEvmChain, isSupportedChain } from '@cowprotocol/sdk-config' +import type { SupportedChainId } from '@cowprotocol/sdk-config' +import { getEvmAddressKey, isEvmAddress, log } from '@cowprotocol/sdk-common' + +import { ProgrammaticOrdersClient } from './client' +import { ProgrammaticOrderApiError, type ProgrammaticOrderApiOptions } from './common/types' +import { sumExecutedAmounts } from './twap/parse' +import { getTwapParents, getTwapPartOrders } from './twap/queries' +import type { GetTwapOrdersParams, TwapOrder } from './twap/types' + +/** Read-only client for programmatic orders. */ +export class ProgrammaticOrderApi { + private readonly client: ProgrammaticOrdersClient + + /** + * Creates a client backed by the default programmatic orders API. + * + * @param options - Optional API endpoint configuration. + * @throws {@link ProgrammaticOrderApiError} with kind `invalid-request` when `apiUrl` is invalid. + */ + constructor(options: ProgrammaticOrderApiOptions = {}) { + this.client = new ProgrammaticOrdersClient(options.apiUrl) + } + + /** + * Returns all TWAP orders and their part orders for an owner on a supported EVM chain. + * + * @remarks + * Resolves EOA/proxy owner mappings and follows every API page. `partOrders` + * contains part orders; scheduled parts without a part order are not included. + * A failed page rejects the request rather than returning partial results. + * + * @param params - Owner address and chain to query. + * @returns All TWAP orders and their part orders. + * @throws {@link ProgrammaticOrderApiError} when input validation, the request, + * GraphQL, or response validation fails. + */ + async getTwapOrders(params: GetTwapOrdersParams): Promise { + const { owner, chainId } = params + const requestedOwner = validateRequest(owner, chainId) + + log(`ProgrammaticOrderApi: fetching TWAP orders for ${requestedOwner} on chain ${chainId}`) + + const { resolvedOwner, owners } = await this.client.getOwnerScope(requestedOwner, chainId) + const parents = await getTwapParents(this.client, resolvedOwner, chainId, owners) + const partOrdersByParent = await getTwapPartOrders( + this.client, + chainId, + parents.map(({ eventId }) => eventId), + ) + + const orders = parents.map((parent) => { + const partOrders = partOrdersByParent.get(parent.eventId) ?? [] + + return { + eventId: parent.eventId, + hash: parent.hash, + chainId, + owner: parent.owner, + resolvedOwner, + status: parent.status, + createdAt: parent.createdAt, + schedule: parent.schedule, + partOrders, + executedAmounts: sumExecutedAmounts(partOrders), + } + }) + + log( + `ProgrammaticOrderApi: fetched ${orders.length} TWAP orders and ${orders.reduce((sum, order) => sum + order.partOrders.length, 0)} part orders`, + ) + + return orders + } +} + +function validateRequest(owner: string, chainId: SupportedChainId): string { + if (!isEvmAddress(owner)) { + throw new ProgrammaticOrderApiError('invalid-request', `Invalid EVM owner address: ${owner}`) + } + + if (!Number.isInteger(chainId) || !isSupportedChain(chainId) || !isEvmChain(chainId)) { + throw new ProgrammaticOrderApiError('invalid-request', `Unsupported EVM chain: ${chainId}`) + } + + return getEvmAddressKey(owner) +} diff --git a/packages/composable/src/programmaticOrders/client.ts b/packages/composable/src/programmaticOrders/client.ts new file mode 100644 index 000000000..2dd580c2b --- /dev/null +++ b/packages/composable/src/programmaticOrders/client.ts @@ -0,0 +1,200 @@ +import type { SupportedChainId } from '@cowprotocol/sdk-config' +import { areAddressesEqual, getEvmAddressKey, log } from '@cowprotocol/sdk-common' + +import { + invalidResponse, + OWNER_MAPPING_SCHEMA, + parseGraphqlData, + parsePage, + type Page, +} from './common/parse' +import { ProgrammaticOrderApiError } from './common/types' + +const DEFAULT_API_URL = 'https://cow-programmatic-order.bleu.blue' +export const PAGE_SIZE = 1000 + +const OWNER_MAPPINGS_QUERY = ` + query OwnerMappings($owner: String!, $chainId: Int!, $after: String, $limit: Int!) { + ownerMappings(where: { owner: $owner, chainId: $chainId }, after: $after, limit: $limit) { + items { + address + chainId + owner + } + pageInfo { + hasNextPage + endCursor + } + } + } +` + +const OWNER_RESOLUTION_QUERY = ` + query OwnerResolution($address: String!, $chainId: Int!, $after: String, $limit: Int!) { + ownerMappings(where: { address: $address, chainId: $chainId }, after: $after, limit: $limit) { + items { + address + chainId + owner + } + pageInfo { + hasNextPage + endCursor + } + } + } +` + +interface OwnerScope { + resolvedOwner: string + owners: string[] +} + +export class ProgrammaticOrdersClient { + private readonly graphqlUrl: string + + constructor(apiUrl?: string) { + this.graphqlUrl = getGraphqlUrl(apiUrl ?? DEFAULT_API_URL) + } + + async getOwnerScope(owner: string, chainId: SupportedChainId): Promise { + const inputMappings = await this.paginate('owner resolution', async (after) => { + const data = await this.query('owner resolution', OWNER_RESOLUTION_QUERY, { + address: owner, + chainId, + after, + limit: PAGE_SIZE, + }) + + return parsePage(data, 'ownerMappings', OWNER_MAPPING_SCHEMA) + }) + + if (inputMappings.length > 1) invalidResponse('ownerMappings contains conflicting mappings for an address') + + const inputMapping = inputMappings[0] + + if (inputMapping && (inputMapping.chainId !== chainId || !areAddressesEqual(inputMapping.address, owner))) { + invalidResponse('ownerMappings.items contains a row for another address or chain') + } + + const resolvedOwner = inputMapping?.owner ?? owner + const mappings = await this.paginate('owner mappings', async (after) => { + const data = await this.query('owner mappings', OWNER_MAPPINGS_QUERY, { + owner: resolvedOwner, + chainId, + after, + limit: PAGE_SIZE, + }) + + return parsePage(data, 'ownerMappings', OWNER_MAPPING_SCHEMA) + }) + const owners = new Map([ + [getEvmAddressKey(resolvedOwner), resolvedOwner], + [getEvmAddressKey(owner), owner], + ]) + + for (const mapping of mappings) { + if (mapping.chainId !== chainId || !areAddressesEqual(mapping.owner, resolvedOwner)) { + invalidResponse('ownerMappings.items contains a row for another owner or chain') + } + + owners.set(getEvmAddressKey(mapping.address), mapping.address) + } + + log(`ProgrammaticOrderApi: resolved ${owners.size - 1} proxy owner(s)`) + + return { resolvedOwner, owners: [...owners.values()] } + } + + async paginate(label: string, getPage: (after: string | null) => Promise>): Promise { + const items: T[] = [] + const cursors = new Set() + let after: string | null = null + + while (true) { + const page = await getPage(after) + items.push(...page.items) + + if (!page.hasNextPage) return items + if (page.endCursor === null || page.endCursor.length === 0 || cursors.has(page.endCursor)) { + invalidResponse(`${label}.pageInfo contains an invalid pagination cursor`) + } + + cursors.add(page.endCursor) + after = page.endCursor + } + } + + async query(operation: string, query: string, variables: Record): Promise { + log(`ProgrammaticOrderApi: querying ${operation}`) + + let response: Response + + try { + response = await fetch(this.graphqlUrl, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ query, variables }), + }) + } catch (cause) { + throw new ProgrammaticOrderApiError('network', 'Programmatic orders API request failed', undefined, { cause }) + } + + let payload: unknown + + try { + payload = await response.json() + } catch (cause) { + if (!response.ok) { + throw new ProgrammaticOrderApiError( + 'http', + `Programmatic orders API returned HTTP ${response.status}`, + response.status, + { cause }, + ) + } + + throw new ProgrammaticOrderApiError( + 'invalid-response', + 'Programmatic orders API returned invalid JSON', + undefined, + { cause }, + ) + } + + if (!response.ok) { + throw new ProgrammaticOrderApiError( + 'http', + `Programmatic orders API returned HTTP ${response.status}`, + response.status, + ) + } + + return parseGraphqlData(payload) + } +} + +function getGraphqlUrl(apiUrl: string): string { + if (typeof apiUrl !== 'string') { + throw new ProgrammaticOrderApiError('invalid-request', 'Programmatic orders API URL must be a string') + } + + try { + const url = new URL(apiUrl) + + if (url.protocol !== 'http:' && url.protocol !== 'https:') throw new Error('Unsupported protocol') + + const pathname = url.pathname.replace(/\/+$/, '') + + url.pathname = pathname.endsWith('/graphql') ? pathname : `${pathname}/graphql` + + return url.toString() + } catch (cause) { + throw new ProgrammaticOrderApiError('invalid-request', 'Invalid programmatic orders API URL', undefined, { + cause, + }) + } +} diff --git a/packages/composable/src/programmaticOrders/common/parse.ts b/packages/composable/src/programmaticOrders/common/parse.ts new file mode 100644 index 000000000..4c6c2c7b4 --- /dev/null +++ b/packages/composable/src/programmaticOrders/common/parse.ts @@ -0,0 +1,133 @@ +import { getEvmAddressKey, isEvmAddress } from '@cowprotocol/sdk-common' +import * as v from 'valibot' + +import { ProgrammaticOrderApiError } from './types' + +const MAX_DATE_SECONDS = 8_640_000_000_000n +const MAX_UINT32 = 4_294_967_295 +const MAX_UINT256 = (1n << 256n) - 1n + +const BYTES_32_PATTERN = /^0x[0-9a-fA-F]{64}$/ +const ORDER_UID_PATTERN = /^0x[0-9a-fA-F]{112}$/ +const UNSIGNED_INTEGER_PATTERN = /^\d+$/ + +const RECORD_SCHEMA = v.custom>( + (value) => typeof value === 'object' && value !== null && !Array.isArray(value), + 'must be an object', +) +const GRAPHQL_ERRORS_SCHEMA = v.array(v.object({ message: v.string() }), 'must be an array') +const PAGE_INFO_SCHEMA = v.object({ + hasNextPage: v.boolean(), + endCursor: v.nullable(v.string()), +}) + +export const ADDRESS_SCHEMA = v.pipe( + v.string(), + v.check((address) => isEvmAddress(address), 'must be an EVM address'), + v.transform((address) => getEvmAddressKey(address)), +) +export const BYTES_32_SCHEMA = v.pipe(v.string(), v.regex(BYTES_32_PATTERN, 'must be bytes32')) +export const NON_EMPTY_STRING_SCHEMA = v.pipe(v.string(), v.minLength(1, 'must not be empty')) +export const ORDER_UID_SCHEMA = v.pipe(v.string(), v.regex(ORDER_UID_PATTERN, 'must be an order UID')) +export const SAFE_INTEGER_SCHEMA = v.pipe(v.number(), v.safeInteger('must be a safe integer')) +export const UINT32_SCHEMA = v.pipe( + SAFE_INTEGER_SCHEMA, + v.minValue(0, 'must be a uint32'), + v.maxValue(MAX_UINT32, 'must be a uint32'), +) +export const UINT256_SCHEMA = v.pipe( + v.string(), + v.regex(UNSIGNED_INTEGER_PATTERN, 'must be a uint256 decimal string'), + v.maxLength(78, 'must be a uint256 decimal string'), + v.transform((value) => BigInt(value)), + v.maxValue(MAX_UINT256, 'must fit uint256'), +) +export const TIMESTAMP_SCHEMA = v.pipe( + UINT256_SCHEMA, + v.maxValue(MAX_DATE_SECONDS, 'is outside the supported date range'), + v.transform((value) => Number(value)), +) +export const PROGRAMMATIC_ORDER_STATUS_SCHEMA = v.picklist(['Active', 'Cancelled', 'Completed']) +export const OWNER_MAPPING_SCHEMA = v.object({ + address: ADDRESS_SCHEMA, + chainId: SAFE_INTEGER_SCHEMA, + owner: ADDRESS_SCHEMA, +}) + +export interface Page { + items: T[] + hasNextPage: boolean + endCursor: string | null +} + +export function parseGraphqlData(payload: unknown): unknown { + const envelope = parseSchema(RECORD_SCHEMA, payload, 'response') + const errors = envelope.errors + + if (errors !== undefined && errors !== null) { + const parsedErrors = parseSchema(GRAPHQL_ERRORS_SCHEMA, errors, 'response.errors') + + if (parsedErrors.length > 0) { + throw new ProgrammaticOrderApiError( + 'graphql', + `Programmatic orders API error: ${parsedErrors.map(({ message }) => message).join('; ')}`, + ) + } + } + + if (!('data' in envelope)) invalidResponse('response.data is missing') + + return envelope.data +} + +export function parsePage( + data: unknown, + field: string, + itemSchema: TItem, +): Page> { + const root = parseSchema(RECORD_SCHEMA, data, 'response.data') + const page = parseSchema( + v.object({ + items: v.array(itemSchema), + pageInfo: PAGE_INFO_SCHEMA, + }), + root[field], + `response.data.${field}`, + ) + + return { + items: page.items, + hasNextPage: page.pageInfo.hasNextPage, + endCursor: page.pageInfo.endCursor, + } +} + +function parseSchema( + schema: TSchema, + input: unknown, + path: string, +): v.InferOutput { + const result = v.safeParse(schema, input, { abortEarly: true }) + + if (!result.success) { + const issue = result.issues[0] + + invalidResponse(`${formatIssuePath(path, issue)} ${issue.message}`) + } + + return result.output +} + +function formatIssuePath(path: string, issue: v.BaseIssue): string { + let result = path + + for (const item of issue.path ?? []) { + result += typeof item.key === 'number' ? `[${item.key}]` : `.${String(item.key)}` + } + + return result +} + +export function invalidResponse(message: string): never { + throw new ProgrammaticOrderApiError('invalid-response', `Invalid programmatic orders API response: ${message}`) +} diff --git a/packages/composable/src/programmaticOrders/common/types.ts b/packages/composable/src/programmaticOrders/common/types.ts new file mode 100644 index 000000000..d779bdcdc --- /dev/null +++ b/packages/composable/src/programmaticOrders/common/types.ts @@ -0,0 +1,37 @@ +/** Configuration for the programmatic orders API client. */ +export interface ProgrammaticOrderApiOptions { + /** Programmatic orders API base URL or full GraphQL URL. */ + apiUrl?: string +} + +/** + * Lifecycle status of a programmatic order. + * @see https://github.com/bleu/cow-programmatic-orders-api/blob/main/schema/tables.ts#L20-L24 + * @see https://github.com/bleu/cow-programmatic-orders-api/blob/main/src/api/gql-docs/conditional-order-generator.ts + */ +export type ProgrammaticOrderStatus = 'Active' | 'Cancelled' | 'Completed' + +/** Stable category describing why a programmatic orders API call failed. */ +export type ProgrammaticOrderApiErrorKind = 'invalid-request' | 'network' | 'http' | 'graphql' | 'invalid-response' + +/** Error thrown by programmatic orders API operations. */ +export class ProgrammaticOrderApiError extends Error { + readonly name = 'ProgrammaticOrderApiError' + + /** + * Creates a programmatic orders API error. + * + * @param kind - Stable failure category suitable for programmatic handling. + * @param message - Human-readable failure description. + * @param status - HTTP response status for `http` failures. + * @param options - Standard error options, including the original cause. + */ + constructor( + readonly kind: ProgrammaticOrderApiErrorKind, + message: string, + readonly status?: number, + options?: ErrorOptions, + ) { + super(message, options) + } +} diff --git a/packages/composable/src/programmaticOrders/index.ts b/packages/composable/src/programmaticOrders/index.ts new file mode 100644 index 000000000..bb729b435 --- /dev/null +++ b/packages/composable/src/programmaticOrders/index.ts @@ -0,0 +1,8 @@ +export { ProgrammaticOrderApi } from './ProgrammaticOrderApi' +export { ProgrammaticOrderApiError } from './common/types' +export type { + ProgrammaticOrderApiErrorKind, + ProgrammaticOrderApiOptions, + ProgrammaticOrderStatus, +} from './common/types' +export type { GetTwapOrdersParams, TwapOrder, TwapPartOrder, TwapPartOrderStatus, TwapSchedule } from './twap/types' diff --git a/packages/composable/src/programmaticOrders/twap/parse.ts b/packages/composable/src/programmaticOrders/twap/parse.ts new file mode 100644 index 000000000..3258c50f4 --- /dev/null +++ b/packages/composable/src/programmaticOrders/twap/parse.ts @@ -0,0 +1,86 @@ +import { OrderStatus } from '@cowprotocol/sdk-order-book' +import * as v from 'valibot' + +import { + ADDRESS_SCHEMA, + BYTES_32_SCHEMA, + NON_EMPTY_STRING_SCHEMA, + ORDER_UID_SCHEMA, + PROGRAMMATIC_ORDER_STATUS_SCHEMA, + SAFE_INTEGER_SCHEMA, + TIMESTAMP_SCHEMA, + UINT32_SCHEMA, + UINT256_SCHEMA, +} from '../common/parse' +import type { TwapExecutedAmounts, TwapParent, TwapPartOrder } from './types' + +/** @see https://github.com/bleu/cow-programmatic-orders-api/blob/main/docs/supported-order-types.md#twap-time-weighted-average-price */ +const TWAP_SCHEDULE_SCHEMA = v.object({ + sellToken: ADDRESS_SCHEMA, + buyToken: ADDRESS_SCHEMA, + receiver: ADDRESS_SCHEMA, + partSellAmount: UINT256_SCHEMA, + minPartLimit: UINT256_SCHEMA, + t0: UINT256_SCHEMA, + n: UINT256_SCHEMA, + t: UINT256_SCHEMA, + span: UINT256_SCHEMA, + appData: BYTES_32_SCHEMA, +}) + +/** @see https://github.com/bleu/cow-programmatic-orders-api/blob/main/src/api/gql-docs/conditional-order-generator.ts */ +export const TWAP_PARENT_SCHEMA = v.pipe( + v.object({ + eventId: NON_EMPTY_STRING_SCHEMA, + hash: BYTES_32_SCHEMA, + chainId: SAFE_INTEGER_SCHEMA, + owner: ADDRESS_SCHEMA, + resolvedOwner: v.nullable(ADDRESS_SCHEMA), + status: PROGRAMMATIC_ORDER_STATUS_SCHEMA, + transaction: v.object({ blockTimestamp: TIMESTAMP_SCHEMA }), + schedule: TWAP_SCHEDULE_SCHEMA, + }), + v.transform(({ schedule, transaction, ...parent }): TwapParent => { + const { t0, n, t, span, ...scheduleParams } = schedule + const createdAt = transaction.blockTimestamp + + return { + ...parent, + createdAt, + schedule: { + ...scheduleParams, + effectiveStartTime: t0 === 0n ? BigInt(createdAt) : t0, + numberOfParts: n, + timeBetweenParts: t, + durationOfPart: span, + }, + } + }), +) + +/** @see https://github.com/bleu/cow-programmatic-orders-api/blob/main/src/api/gql-docs/discrete-order.ts */ +export const TWAP_PART_ORDER_SCHEMA = v.object({ + orderUid: ORDER_UID_SCHEMA, + chainId: SAFE_INTEGER_SCHEMA, + parentEventId: NON_EMPTY_STRING_SCHEMA, + status: v.picklist([OrderStatus.OPEN, OrderStatus.FULFILLED, OrderStatus.EXPIRED, OrderStatus.CANCELLED, 'unfilled']), + sellAmount: UINT256_SCHEMA, + buyAmount: UINT256_SCHEMA, + feeAmount: UINT256_SCHEMA, + validTo: v.nullable(UINT32_SCHEMA), + createdAt: TIMESTAMP_SCHEMA, + executedSellAmount: v.nullable(UINT256_SCHEMA), + executedBuyAmount: v.nullable(UINT256_SCHEMA), +}) + +export function sumExecutedAmounts(partOrders: TwapPartOrder[]): TwapExecutedAmounts { + let executedSellAmount = 0n + let executedBuyAmount = 0n + + for (const partOrder of partOrders) { + executedSellAmount += partOrder.executedSellAmount ?? 0n + executedBuyAmount += partOrder.executedBuyAmount ?? 0n + } + + return { executedSellAmount, executedBuyAmount } +} diff --git a/packages/composable/src/programmaticOrders/twap/queries.ts b/packages/composable/src/programmaticOrders/twap/queries.ts new file mode 100644 index 000000000..17538aa1f --- /dev/null +++ b/packages/composable/src/programmaticOrders/twap/queries.ts @@ -0,0 +1,153 @@ +import type { SupportedChainId } from '@cowprotocol/sdk-config' +import { areAddressesEqual } from '@cowprotocol/sdk-common' + +import { PAGE_SIZE, type ProgrammaticOrdersClient } from '../client' +import { invalidResponse, parsePage } from '../common/parse' +import { TWAP_PARENT_SCHEMA, TWAP_PART_ORDER_SCHEMA } from './parse' +import type { TwapParent, TwapPartOrder, TwapPartOrderRecord } from './types' + +const PART_PARENT_BATCH_SIZE = 100 + +const TWAP_ORDERS_QUERY = ` + query TwapOrders($owner: String!, $chainId: Int!, $owners: [String!]!, $after: String, $limit: Int!) { + twapOrders: conditionalOrderGenerators( + where: { + chainId: $chainId + orderType: TWAP + OR: [{ resolvedOwner: $owner }, { owner_in: $owners }] + } + after: $after + limit: $limit + ) { + items { + eventId + chainId + hash + owner + resolvedOwner + status + schedule: decodedParams + transaction { + blockTimestamp + } + } + pageInfo { + hasNextPage + endCursor + } + } + } +` + +const TWAP_PART_ORDERS_QUERY = ` + query TwapPartOrders($where: discreteOrderFilter!, $after: String, $limit: Int!) { + partOrders: discreteOrders(where: $where, after: $after, limit: $limit) { + items { + orderUid + chainId + parentEventId: conditionalOrderGeneratorId + status + sellAmount + buyAmount + feeAmount + validTo + createdAt: creationDate + executedSellAmount + executedBuyAmount + } + pageInfo { + hasNextPage + endCursor + } + } + } +` + +export async function getTwapParents( + client: ProgrammaticOrdersClient, + resolvedOwner: string, + chainId: SupportedChainId, + owners: string[], +): Promise { + const parents = await client.paginate('TWAP orders', async (after) => { + const data = await client.query('TWAP orders', TWAP_ORDERS_QUERY, { + owner: resolvedOwner, + chainId, + owners, + after, + limit: PAGE_SIZE, + }) + + return parsePage(data, 'twapOrders', TWAP_PARENT_SCHEMA) + }) + const uniqueParents = new Map() + + for (const parent of parents) { + const matchesOwner = owners.some((owner) => areAddressesEqual(parent.owner, owner)) + const matchesResolvedOwner = parent.resolvedOwner !== null && areAddressesEqual(parent.resolvedOwner, resolvedOwner) + + if (parent.chainId !== chainId || (!matchesOwner && !matchesResolvedOwner)) { + invalidResponse('twapOrders.items contains a row for another owner or chain') + } + + const existing = uniqueParents.get(parent.eventId) + + if (existing && existing.hash !== parent.hash) { + invalidResponse(`twapOrders.items contains conflicting rows for event ${parent.eventId}`) + } + + if (!existing) uniqueParents.set(parent.eventId, parent) + } + + return [...uniqueParents.values()] +} + +export async function getTwapPartOrders( + client: ProgrammaticOrdersClient, + chainId: SupportedChainId, + parentEventIds: string[], +): Promise> { + const partOrdersByUid = new Map() + + for (let index = 0; index < parentEventIds.length; index += PART_PARENT_BATCH_SIZE) { + const batch = parentEventIds.slice(index, index + PART_PARENT_BATCH_SIZE) + const parentIds = new Set(batch) + const partOrders = await client.paginate('TWAP part orders', async (after) => { + const data = await client.query('TWAP part orders', TWAP_PART_ORDERS_QUERY, { + where: { + chainId, + OR: batch.map((parentEventId) => ({ conditionalOrderGeneratorId: parentEventId })), + }, + after, + limit: PAGE_SIZE, + }) + + return parsePage(data, 'partOrders', TWAP_PART_ORDER_SCHEMA) + }) + + for (const partOrder of partOrders) { + if (partOrder.chainId !== chainId || !parentIds.has(partOrder.parentEventId)) { + invalidResponse('partOrders.items contains a row for another TWAP order or chain') + } + + const existing = partOrdersByUid.get(partOrder.orderUid) + + if (existing && existing.parentEventId !== partOrder.parentEventId) { + invalidResponse(`partOrders.items contains conflicting rows for order ${partOrder.orderUid}`) + } + + if (!existing) partOrdersByUid.set(partOrder.orderUid, partOrder) + } + } + + const result = new Map() + + for (const { chainId: _chainId, parentEventId, ...partOrder } of partOrdersByUid.values()) { + const parentPartOrders = result.get(parentEventId) + + if (parentPartOrders) parentPartOrders.push(partOrder) + else result.set(parentEventId, [partOrder]) + } + + return result +} diff --git a/packages/composable/src/programmaticOrders/twap/types.ts b/packages/composable/src/programmaticOrders/twap/types.ts new file mode 100644 index 000000000..da039f59c --- /dev/null +++ b/packages/composable/src/programmaticOrders/twap/types.ts @@ -0,0 +1,113 @@ +import type { SupportedChainId } from '@cowprotocol/sdk-config' +import type { OrderStatus } from '@cowprotocol/sdk-order-book' + +import type { ProgrammaticOrderStatus } from '../common/types' + +/** Input for querying TWAP orders. */ +export interface GetTwapOrdersParams { + /** EOA or mapped programmatic-order proxy address. */ + owner: string + /** Supported EVM chain containing the orders. */ + chainId: SupportedChainId +} + +/** + * Orderbook status of a TWAP part order. + * + * Unlike `OrderStatus` from `@cowprotocol/sdk-order-book`, this API reports + * `unfilled` when an order leaves the orderbook without settling, and does not + * report `presignaturePending`. + * @see https://github.com/bleu/cow-programmatic-orders-api/blob/main/schema/tables.ts#L36-L42 + * @see https://github.com/bleu/cow-programmatic-orders-api/blob/main/src/api/gql-docs/discrete-order.ts + */ +export type TwapPartOrderStatus = + | OrderStatus.OPEN + | OrderStatus.FULFILLED + | OrderStatus.EXPIRED + | OrderStatus.CANCELLED + | 'unfilled' + +/** + * TWAP schedule. Unlike {@link TwapStruct}, + * `effectiveStartTime` is the creation block timestamp when on-chain `t0` was zero. + * @see https://github.com/bleu/cow-programmatic-orders-api/blob/main/docs/supported-order-types.md#twap-time-weighted-average-price + * @see https://github.com/cowprotocol/composable-cow/blob/main/src/types/twap/libraries/TWAPOrder.sol#L31-L42 + */ +export interface TwapSchedule { + sellToken: string + buyToken: string + receiver: string + partSellAmount: bigint + minPartLimit: bigint + /** Effective Unix start time in seconds. */ + effectiveStartTime: bigint + numberOfParts: bigint + /** Seconds between consecutive parts. */ + timeBetweenParts: bigint + /** Part validity in seconds; zero means the full interval. */ + durationOfPart: bigint + appData: string +} + +/** + * Part order generated for one scheduled part. + * @see https://github.com/bleu/cow-programmatic-orders-api/blob/main/src/api/gql-docs/discrete-order.ts + */ +export interface TwapPartOrder { + orderUid: string + status: TwapPartOrderStatus + sellAmount: bigint + buyAmount: bigint + feeAmount: bigint + /** Unix expiry time in seconds. */ + validTo: number | null + /** Unix creation time in seconds. */ + createdAt: number + executedSellAmount: bigint | null + executedBuyAmount: bigint | null +} + +export interface TwapExecutedAmounts { + /** Executed sell and buy amounts summed over all part orders. */ + executedSellAmount: bigint + executedBuyAmount: bigint +} + +/** + * TWAP order with its schedule, part orders, and executed amounts. + * @see https://github.com/bleu/cow-programmatic-orders-api/blob/main/src/api/gql-docs/conditional-order-generator.ts + * @see https://github.com/bleu/cow-programmatic-orders-api/blob/main/src/api/gql-docs/owner-mapping.ts + */ +export interface TwapOrder { + /** Opaque creation-event identity. Unique together with `chainId`. */ + eventId: string + /** ComposableCoW single-order hash. Multiple creation events may share it. */ + hash: string + chainId: SupportedChainId + /** Protocol owner used by the generated CoW orders, which may be a proxy. */ + owner: string + /** Current owner after applying the indexer's proxy mapping. */ + resolvedOwner: string + status: ProgrammaticOrderStatus + /** Unix creation time in seconds. */ + createdAt: number + schedule: TwapSchedule + partOrders: TwapPartOrder[] + executedAmounts: TwapExecutedAmounts +} + +export interface TwapParent { + eventId: string + hash: string + chainId: number + owner: string + resolvedOwner: string | null + status: ProgrammaticOrderStatus + createdAt: number + schedule: TwapSchedule +} + +export interface TwapPartOrderRecord extends TwapPartOrder { + chainId: number + parentEventId: string +} diff --git a/packages/composable/tests/ProgrammaticOrderApi.spec.ts b/packages/composable/tests/ProgrammaticOrderApi.spec.ts new file mode 100644 index 000000000..d8862f742 --- /dev/null +++ b/packages/composable/tests/ProgrammaticOrderApi.spec.ts @@ -0,0 +1,54 @@ +import { SupportedChainId } from '@cowprotocol/sdk-config' + +import { ProgrammaticOrderApi } from '../src' + +const EOA = '0x016f34D4f2578c3e9DFfC3f2b811Ba30c0c9e7f3' + +describe('ProgrammaticOrderApi', () => { + afterEach(() => { + jest.restoreAllMocks() + }) + + it('lists all known EOA TWAPs from the live programmatic orders API', async () => { + const orders = await new ProgrammaticOrderApi().getTwapOrders({ + owner: EOA, + chainId: SupportedChainId.GNOSIS_CHAIN, + }) + const eventIds = orders.map(({ eventId }) => eventId) + const stableOrders = orders + .map(({ eventId: _eventId, partOrders, ...order }) => ({ + ...order, + partOrders: [...partOrders].sort((a, b) => a.orderUid.localeCompare(b.orderUid)), + })) + .sort((a, b) => a.hash.localeCompare(b.hash)) + + expect(orders).toHaveLength(13) + expect(eventIds.every((eventId) => eventId.length > 0)).toBe(true) + expect(new Set(eventIds).size).toBe(13) + expect(stableOrders).toMatchSnapshot() + }, 30_000) + + it('surfaces malformed indexer data as an invalid-response error', async () => { + jest.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response( + JSON.stringify({ + data: { + ownerMappings: { + items: [{ address: 'not-an-address', chainId: 100, owner: EOA }], + pageInfo: { hasNextPage: false, endCursor: null }, + }, + }, + }), + { status: 200 }, + ), + ) + + await expect( + new ProgrammaticOrderApi().getTwapOrders({ owner: EOA, chainId: SupportedChainId.GNOSIS_CHAIN }), + ).rejects.toMatchObject({ + name: 'ProgrammaticOrderApiError', + kind: 'invalid-response', + message: expect.stringContaining('response.data.ownerMappings.items[0].address'), + }) + }) +}) diff --git a/packages/composable/tests/__snapshots__/ProgrammaticOrderApi.spec.ts.snap b/packages/composable/tests/__snapshots__/ProgrammaticOrderApi.spec.ts.snap new file mode 100644 index 000000000..d4aff708f --- /dev/null +++ b/packages/composable/tests/__snapshots__/ProgrammaticOrderApi.spec.ts.snap @@ -0,0 +1,551 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ProgrammaticOrderApi lists all known EOA TWAPs from the live programmatic orders API 1`] = ` +[ + { + "chainId": 100, + "createdAt": 1757526710, + "executedAmounts": { + "executedBuyAmount": 0n, + "executedSellAmount": 0n, + }, + "hash": "0x24022b547eab0362735d633bd4ed6695a2bfad7e8d470b074e610f4b027f2f37", + "owner": "0x62587918b2f00176646679509217a5a4d1ebbfd5", + "partOrders": [ + { + "buyAmount": 1n, + "createdAt": 1757526710, + "executedBuyAmount": null, + "executedSellAmount": null, + "feeAmount": 0n, + "orderUid": "0x3a1cff1951ea04175bdba196c45b2b7b371db5bf7679ee6fa7a7339e62ff740262587918b2f00176646679509217a5a4d1ebbfd568c1bb2d", + "sellAmount": 25000000000000000n, + "status": "expired", + "validTo": 1757526829, + }, + { + "buyAmount": 1n, + "createdAt": 1757526710, + "executedBuyAmount": null, + "executedSellAmount": null, + "feeAmount": 0n, + "orderUid": "0x3fc2139a409b95dfb3ff2bf903a3c05971225579e6a14f63218a5da44b29fb7d62587918b2f00176646679509217a5a4d1ebbfd568c1baf1", + "sellAmount": 25000000000000000n, + "status": "expired", + "validTo": 1757526769, + }, + ], + "resolvedOwner": "0x016f34d4f2578c3e9dffc3f2b811ba30c0c9e7f3", + "schedule": { + "appData": "0xf7c9274467f87d681e0fa21b3fb886b44546162a0ab94fd44d346a2b78af430a", + "buyToken": "0x177127622c4a00f3d409b75571e12cb3c8973d3c", + "durationOfPart": 0n, + "effectiveStartTime": 1757526710n, + "minPartLimit": 1n, + "numberOfParts": 4n, + "partSellAmount": 25000000000000000n, + "receiver": "0x016f34d4f2578c3e9dffc3f2b811ba30c0c9e7f3", + "sellToken": "0xaf204776c7245bf4147c2612bf6e5972ee483701", + "timeBetweenParts": 30n, + }, + "status": "Active", + }, + { + "chainId": 100, + "createdAt": 1782855780, + "executedAmounts": { + "executedBuyAmount": 0n, + "executedSellAmount": 0n, + }, + "hash": "0x28b61a46088d4082b10aca722efa0d2404106686594f2a82936893f2d7d22f06", + "owner": "0x62587918b2f00176646679509217a5a4d1ebbfd5", + "partOrders": [ + { + "buyAmount": 774421599485031327n, + "createdAt": 1782855780, + "executedBuyAmount": null, + "executedSellAmount": null, + "feeAmount": 0n, + "orderUid": "0xb52801bbe2fd0904f235d20ce0ae3b0eea1d102f8001961cfc7e1e74b583dd1b62587918b2f00176646679509217a5a4d1ebbfd56a4438db", + "sellAmount": 100000000000000000n, + "status": "expired", + "validTo": 1782855899, + }, + ], + "resolvedOwner": "0x016f34d4f2578c3e9dffc3f2b811ba30c0c9e7f3", + "schedule": { + "appData": "0xebe836e7e0257003712b4884f2747a51dc706691305723c19e4bf7bec1c111ff", + "buyToken": "0x177127622c4a00f3d409b75571e12cb3c8973d3c", + "durationOfPart": 0n, + "effectiveStartTime": 1782855780n, + "minPartLimit": 774421599485031327n, + "numberOfParts": 2n, + "partSellAmount": 100000000000000000n, + "receiver": "0x016f34d4f2578c3e9dffc3f2b811ba30c0c9e7f3", + "sellToken": "0xaf204776c7245bf4147c2612bf6e5972ee483701", + "timeBetweenParts": 60n, + }, + "status": "Active", + }, + { + "chainId": 100, + "createdAt": 1757524180, + "executedAmounts": { + "executedBuyAmount": 0n, + "executedSellAmount": 0n, + }, + "hash": "0x296f02547402281d040fa79d59ef61631e4a18c317654cefa1bf2226224c9fd9", + "owner": "0x62587918b2f00176646679509217a5a4d1ebbfd5", + "partOrders": [], + "resolvedOwner": "0x016f34d4f2578c3e9dffc3f2b811ba30c0c9e7f3", + "schedule": { + "appData": "0xf7c9274467f87d681e0fa21b3fb886b44546162a0ab94fd44d346a2b78af430a", + "buyToken": "0x177127622c4a00f3d409b75571e12cb3c8973d3c", + "durationOfPart": 0n, + "effectiveStartTime": 1757524180n, + "minPartLimit": 0n, + "numberOfParts": 4n, + "partSellAmount": 25000000000000000n, + "receiver": "0x016f34d4f2578c3e9dffc3f2b811ba30c0c9e7f3", + "sellToken": "0xaf204776c7245bf4147c2612bf6e5972ee483701", + "timeBetweenParts": 1800n, + }, + "status": "Active", + }, + { + "chainId": 100, + "createdAt": 1757526200, + "executedAmounts": { + "executedBuyAmount": 84756756849678668n, + "executedSellAmount": 25000000000000000n, + }, + "hash": "0x2bbf5f8727c87a4ccfd2ef1332092caa6ea7173a3b5b91ff11fe24df71e0e5f8", + "owner": "0x62587918b2f00176646679509217a5a4d1ebbfd5", + "partOrders": [ + { + "buyAmount": 1n, + "createdAt": 1757526200, + "executedBuyAmount": 84756756849678668n, + "executedSellAmount": 25000000000000000n, + "feeAmount": 0n, + "orderUid": "0x29f584d3184bc6754fafe5beed2e0ca462a3d01389a5add5968fc5fa0a1a8b2662587918b2f00176646679509217a5a4d1ebbfd568c1bfbf", + "sellAmount": 25000000000000000n, + "status": "fulfilled", + "validTo": 1757527999, + }, + ], + "resolvedOwner": "0x016f34d4f2578c3e9dffc3f2b811ba30c0c9e7f3", + "schedule": { + "appData": "0xf7c9274467f87d681e0fa21b3fb886b44546162a0ab94fd44d346a2b78af430a", + "buyToken": "0x177127622c4a00f3d409b75571e12cb3c8973d3c", + "durationOfPart": 0n, + "effectiveStartTime": 1757526200n, + "minPartLimit": 1n, + "numberOfParts": 4n, + "partSellAmount": 25000000000000000n, + "receiver": "0x016f34d4f2578c3e9dffc3f2b811ba30c0c9e7f3", + "sellToken": "0xaf204776c7245bf4147c2612bf6e5972ee483701", + "timeBetweenParts": 1800n, + }, + "status": "Active", + }, + { + "chainId": 100, + "createdAt": 1757525910, + "executedAmounts": { + "executedBuyAmount": 84880520382659281n, + "executedSellAmount": 25000000000000000n, + }, + "hash": "0x4ac7633559e7c90792574306ae2c3bbae7588681a0219c3668b61cc6da2be10b", + "owner": "0x62587918b2f00176646679509217a5a4d1ebbfd5", + "partOrders": [ + { + "buyAmount": 1n, + "createdAt": 1757525910, + "executedBuyAmount": 84880520382659281n, + "executedSellAmount": 25000000000000000n, + "feeAmount": 0n, + "orderUid": "0x7d228549d048c1a4759f198766e8014405dae68b97ec3efbe81c0d8df523140062587918b2f00176646679509217a5a4d1ebbfd568c1be9d", + "sellAmount": 25000000000000000n, + "status": "fulfilled", + "validTo": 1757527709, + }, + ], + "resolvedOwner": "0x016f34d4f2578c3e9dffc3f2b811ba30c0c9e7f3", + "schedule": { + "appData": "0xf7c9274467f87d681e0fa21b3fb886b44546162a0ab94fd44d346a2b78af430a", + "buyToken": "0x177127622c4a00f3d409b75571e12cb3c8973d3c", + "durationOfPart": 0n, + "effectiveStartTime": 1757525910n, + "minPartLimit": 1n, + "numberOfParts": 4n, + "partSellAmount": 25000000000000000n, + "receiver": "0x016f34d4f2578c3e9dffc3f2b811ba30c0c9e7f3", + "sellToken": "0xaf204776c7245bf4147c2612bf6e5972ee483701", + "timeBetweenParts": 1800n, + }, + "status": "Active", + }, + { + "chainId": 100, + "createdAt": 1782860530, + "executedAmounts": { + "executedBuyAmount": 862367902269494618n, + "executedSellAmount": 100000000000000000n, + }, + "hash": "0x5ec4e09e073067d53acbcb81a94f926608ce075f0f04842e91a3b22ca2ea333d", + "owner": "0x62587918b2f00176646679509217a5a4d1ebbfd5", + "partOrders": [ + { + "buyAmount": 776021953726338606n, + "createdAt": 1782860530, + "executedBuyAmount": 862367902269494618n, + "executedSellAmount": 100000000000000000n, + "feeAmount": 0n, + "orderUid": "0x24106d3b23805bd9b992bfdba43bd692de4582180acf44ca4edb0d698312562d62587918b2f00176646679509217a5a4d1ebbfd56a444b69", + "sellAmount": 100000000000000000n, + "status": "fulfilled", + "validTo": 1782860649, + }, + { + "buyAmount": 776021953726338606n, + "createdAt": 1782860530, + "executedBuyAmount": 0n, + "executedSellAmount": 0n, + "feeAmount": 0n, + "orderUid": "0xd8d939a5b9bfe1ede5357ec33c48748a1e59567530a6a44a634d615c2a35abfc62587918b2f00176646679509217a5a4d1ebbfd56a444be1", + "sellAmount": 100000000000000000n, + "status": "expired", + "validTo": 1782860769, + }, + ], + "resolvedOwner": "0x016f34d4f2578c3e9dffc3f2b811ba30c0c9e7f3", + "schedule": { + "appData": "0x302493bc6206f0065533621e960c9e5411c58044ea0b5a80b4f61edac00ba2f3", + "buyToken": "0x177127622c4a00f3d409b75571e12cb3c8973d3c", + "durationOfPart": 0n, + "effectiveStartTime": 1782860530n, + "minPartLimit": 776021953726338606n, + "numberOfParts": 2n, + "partSellAmount": 100000000000000000n, + "receiver": "0x016f34d4f2578c3e9dffc3f2b811ba30c0c9e7f3", + "sellToken": "0xaf204776c7245bf4147c2612bf6e5972ee483701", + "timeBetweenParts": 120n, + }, + "status": "Completed", + }, + { + "chainId": 100, + "createdAt": 1757524940, + "executedAmounts": { + "executedBuyAmount": 168880044359503662n, + "executedSellAmount": 50000000000000000n, + }, + "hash": "0x618fc1893e8fc19a353734250d775dc3fa62df205ed545cc66a17a7ab1197a0c", + "owner": "0x62587918b2f00176646679509217a5a4d1ebbfd5", + "partOrders": [ + { + "buyAmount": 1n, + "createdAt": 1757524940, + "executedBuyAmount": 84540640270669195n, + "executedSellAmount": 25000000000000000n, + "feeAmount": 0n, + "orderUid": "0x1407dbf775a8e5869716fdcfe1bcbc8ccffdc43c7bed9db359af46dc15c27bb462587918b2f00176646679509217a5a4d1ebbfd568c1c1db", + "sellAmount": 25000000000000000n, + "status": "fulfilled", + "validTo": 1757528539, + }, + { + "buyAmount": 1n, + "createdAt": 1757524940, + "executedBuyAmount": 84339404088834467n, + "executedSellAmount": 25000000000000000n, + "feeAmount": 0n, + "orderUid": "0x2065f76e5eb0e8da3e20136d4c8bc69f3735d9d54b0d228b3794438282b7fd9f62587918b2f00176646679509217a5a4d1ebbfd568c1bad3", + "sellAmount": 25000000000000000n, + "status": "fulfilled", + "validTo": 1757526739, + }, + { + "buyAmount": 1n, + "createdAt": 1757524940, + "executedBuyAmount": null, + "executedSellAmount": null, + "feeAmount": 0n, + "orderUid": "0xdf32ac54fdd380a8351bbb9af6a3d0b13be827e7d0854ff5e36fe8eab810d7c662587918b2f00176646679509217a5a4d1ebbfd568c1c8e3", + "sellAmount": 25000000000000000n, + "status": "expired", + "validTo": 1757530339, + }, + ], + "resolvedOwner": "0x016f34d4f2578c3e9dffc3f2b811ba30c0c9e7f3", + "schedule": { + "appData": "0xf7c9274467f87d681e0fa21b3fb886b44546162a0ab94fd44d346a2b78af430a", + "buyToken": "0x177127622c4a00f3d409b75571e12cb3c8973d3c", + "durationOfPart": 0n, + "effectiveStartTime": 1757524940n, + "minPartLimit": 1n, + "numberOfParts": 4n, + "partSellAmount": 25000000000000000n, + "receiver": "0x016f34d4f2578c3e9dffc3f2b811ba30c0c9e7f3", + "sellToken": "0xaf204776c7245bf4147c2612bf6e5972ee483701", + "timeBetweenParts": 1800n, + }, + "status": "Active", + }, + { + "chainId": 100, + "createdAt": 1782851510, + "executedAmounts": { + "executedBuyAmount": 1718396656651015389n, + "executedSellAmount": 200000000000000000n, + }, + "hash": "0x87040d5bec3aad44a511b6b35be5f7078796947eab1a880c741c7763e27cd490", + "owner": "0x62587918b2f00176646679509217a5a4d1ebbfd5", + "partOrders": [ + { + "buyAmount": 1n, + "createdAt": 1782851510, + "executedBuyAmount": 859336167658404014n, + "executedSellAmount": 100000000000000000n, + "feeAmount": 0n, + "orderUid": "0x69c81c84836821956db2b788a5d006f4bc0da9ac9de19636701622d8555ea49162587918b2f00176646679509217a5a4d1ebbfd56a442a0d", + "sellAmount": 100000000000000000n, + "status": "fulfilled", + "validTo": 1782852109, + }, + { + "buyAmount": 1n, + "createdAt": 1782851510, + "executedBuyAmount": 859060488992611375n, + "executedSellAmount": 100000000000000000n, + "feeAmount": 0n, + "orderUid": "0x79128220bbb235a9181f25e754e51ecb6aadd014a02c06cc041a09b00529780e62587918b2f00176646679509217a5a4d1ebbfd56a4428e1", + "sellAmount": 100000000000000000n, + "status": "fulfilled", + "validTo": 1782851809, + }, + ], + "resolvedOwner": "0x016f34d4f2578c3e9dffc3f2b811ba30c0c9e7f3", + "schedule": { + "appData": "0xf7c9274467f87d681e0fa21b3fb886b44546162a0ab94fd44d346a2b78af430a", + "buyToken": "0x177127622c4a00f3d409b75571e12cb3c8973d3c", + "durationOfPart": 0n, + "effectiveStartTime": 1782851510n, + "minPartLimit": 1n, + "numberOfParts": 2n, + "partSellAmount": 100000000000000000n, + "receiver": "0x016f34d4f2578c3e9dffc3f2b811ba30c0c9e7f3", + "sellToken": "0xaf204776c7245bf4147c2612bf6e5972ee483701", + "timeBetweenParts": 300n, + }, + "status": "Completed", + }, + { + "chainId": 100, + "createdAt": 1782852985, + "executedAmounts": { + "executedBuyAmount": 0n, + "executedSellAmount": 0n, + }, + "hash": "0x979bcc44b12484836ed02e56b24190071c3881b39e3a6368138860d518d4e16d", + "owner": "0x62587918b2f00176646679509217a5a4d1ebbfd5", + "partOrders": [ + { + "buyAmount": 773026802095130550n, + "createdAt": 1782852985, + "executedBuyAmount": null, + "executedSellAmount": null, + "feeAmount": 0n, + "orderUid": "0x52d95f6639c8db3977d13335b1ea2d10d89b77e7261f25e670a94a78f27bb6af62587918b2f00176646679509217a5a4d1ebbfd56a442df0", + "sellAmount": 100000000000000000n, + "status": "expired", + "validTo": 1782853104, + }, + ], + "resolvedOwner": "0x016f34d4f2578c3e9dffc3f2b811ba30c0c9e7f3", + "schedule": { + "appData": "0xf7c9274467f87d681e0fa21b3fb886b44546162a0ab94fd44d346a2b78af430a", + "buyToken": "0x177127622c4a00f3d409b75571e12cb3c8973d3c", + "durationOfPart": 0n, + "effectiveStartTime": 1782852985n, + "minPartLimit": 773026802095130550n, + "numberOfParts": 2n, + "partSellAmount": 100000000000000000n, + "receiver": "0x016f34d4f2578c3e9dffc3f2b811ba30c0c9e7f3", + "sellToken": "0xaf204776c7245bf4147c2612bf6e5972ee483701", + "timeBetweenParts": 60n, + }, + "status": "Active", + }, + { + "chainId": 100, + "createdAt": 1782842370, + "executedAmounts": { + "executedBuyAmount": 1703025006672763226n, + "executedSellAmount": 200000000000000000n, + }, + "hash": "0xa05b5dabbe1703fe82ee0d2ea89dc0397dcebfeb8ba1ec64591fb1d40de06422", + "owner": "0x62587918b2f00176646679509217a5a4d1ebbfd5", + "partOrders": [ + { + "buyAmount": 1n, + "createdAt": 1782842370, + "executedBuyAmount": 846746200516767824n, + "executedSellAmount": 100000000000000000n, + "feeAmount": 0n, + "orderUid": "0x144eb01438d03b3b68e810b407ed884fb40384826736c7d8096b2b508c55f8b962587918b2f00176646679509217a5a4d1ebbfd56a440659", + "sellAmount": 100000000000000000n, + "status": "fulfilled", + "validTo": 1782842969, + }, + { + "buyAmount": 1n, + "createdAt": 1782842370, + "executedBuyAmount": 856278806155995402n, + "executedSellAmount": 100000000000000000n, + "feeAmount": 0n, + "orderUid": "0x6ae91e15d98477537ec583371381627d16724589d79590fccd314e8dc9ebf9f062587918b2f00176646679509217a5a4d1ebbfd56a44052d", + "sellAmount": 100000000000000000n, + "status": "fulfilled", + "validTo": 1782842669, + }, + ], + "resolvedOwner": "0x016f34d4f2578c3e9dffc3f2b811ba30c0c9e7f3", + "schedule": { + "appData": "0xf7c9274467f87d681e0fa21b3fb886b44546162a0ab94fd44d346a2b78af430a", + "buyToken": "0x177127622c4a00f3d409b75571e12cb3c8973d3c", + "durationOfPart": 0n, + "effectiveStartTime": 1782842370n, + "minPartLimit": 1n, + "numberOfParts": 2n, + "partSellAmount": 100000000000000000n, + "receiver": "0x016f34d4f2578c3e9dffc3f2b811ba30c0c9e7f3", + "sellToken": "0xaf204776c7245bf4147c2612bf6e5972ee483701", + "timeBetweenParts": 300n, + }, + "status": "Completed", + }, + { + "chainId": 100, + "createdAt": 1782860530, + "executedAmounts": { + "executedBuyAmount": 862488114171327656n, + "executedSellAmount": 100000000000000000n, + }, + "hash": "0xc6e1f5bc9f8cd772e89ab65222c6e5511fc121be2bb3cae3889ab666fa506c80", + "owner": "0x62587918b2f00176646679509217a5a4d1ebbfd5", + "partOrders": [ + { + "buyAmount": 776195194471677430n, + "createdAt": 1782860530, + "executedBuyAmount": 0n, + "executedSellAmount": 0n, + "feeAmount": 0n, + "orderUid": "0x4f44aa86f906fc8b796a602e1cbf6edba7a0542c0d842a64e2108237f430098c62587918b2f00176646679509217a5a4d1ebbfd56a444be1", + "sellAmount": 100000000000000000n, + "status": "expired", + "validTo": 1782860769, + }, + { + "buyAmount": 776195194471677430n, + "createdAt": 1782860530, + "executedBuyAmount": 862488114171327656n, + "executedSellAmount": 100000000000000000n, + "feeAmount": 0n, + "orderUid": "0x693a30318b8208f78b5028b237bb3cf3cef8c7c7c5a74773b02d4a0dec6f334762587918b2f00176646679509217a5a4d1ebbfd56a444b69", + "sellAmount": 100000000000000000n, + "status": "fulfilled", + "validTo": 1782860649, + }, + ], + "resolvedOwner": "0x016f34d4f2578c3e9dffc3f2b811ba30c0c9e7f3", + "schedule": { + "appData": "0xf248f0d7eee2efe935d59e82d615db8d2a47f7929698e4712416cb608e8877d9", + "buyToken": "0x177127622c4a00f3d409b75571e12cb3c8973d3c", + "durationOfPart": 0n, + "effectiveStartTime": 1782860530n, + "minPartLimit": 776195194471677430n, + "numberOfParts": 2n, + "partSellAmount": 100000000000000000n, + "receiver": "0x016f34d4f2578c3e9dffc3f2b811ba30c0c9e7f3", + "sellToken": "0xaf204776c7245bf4147c2612bf6e5972ee483701", + "timeBetweenParts": 120n, + }, + "status": "Completed", + }, + { + "chainId": 100, + "createdAt": 1782856350, + "executedAmounts": { + "executedBuyAmount": 0n, + "executedSellAmount": 0n, + }, + "hash": "0xf5e3a9d322d4985cd9fc10b84e16f0358c720592ec968ec3921dc65fd702be0b", + "owner": "0x62587918b2f00176646679509217a5a4d1ebbfd5", + "partOrders": [], + "resolvedOwner": "0x016f34d4f2578c3e9dffc3f2b811ba30c0c9e7f3", + "schedule": { + "appData": "0xdfc54d9c8b6a28a0c03947debf1a0f929184919579c926cf3de23c5881eb524a", + "buyToken": "0x177127622c4a00f3d409b75571e12cb3c8973d3c", + "durationOfPart": 0n, + "effectiveStartTime": 1782856350n, + "minPartLimit": 774221321423698851n, + "numberOfParts": 2n, + "partSellAmount": 100000000000000000n, + "receiver": "0x016f34d4f2578c3e9dffc3f2b811ba30c0c9e7f3", + "sellToken": "0xaf204776c7245bf4147c2612bf6e5972ee483701", + "timeBetweenParts": 60n, + }, + "status": "Active", + }, + { + "chainId": 100, + "createdAt": 1757527335, + "executedAmounts": { + "executedBuyAmount": 678620197252801976n, + "executedSellAmount": 200000000000000000n, + }, + "hash": "0xf8edb9707569dec76a362bb6bac1909fdf43d5afe70beca3e422ec7b1bbaa237", + "owner": "0x62587918b2f00176646679509217a5a4d1ebbfd5", + "partOrders": [ + { + "buyAmount": 1n, + "createdAt": 1757527335, + "executedBuyAmount": 339434970519487884n, + "executedSellAmount": 100000000000000000n, + "feeAmount": 0n, + "orderUid": "0x00b3bd2d842331d914ec6b4136d8056aec35fc3dff3f158c3d2f876d764e2ca962587918b2f00176646679509217a5a4d1ebbfd568c1bf7e", + "sellAmount": 100000000000000000n, + "status": "fulfilled", + "validTo": 1757527934, + }, + { + "buyAmount": 1n, + "createdAt": 1757527335, + "executedBuyAmount": 339185226733314092n, + "executedSellAmount": 100000000000000000n, + "feeAmount": 0n, + "orderUid": "0xf600a33ded1bd6cac5c9d38fbc9c4bc3a5ab492fd2b34aecadd701f7d4bb805f62587918b2f00176646679509217a5a4d1ebbfd568c1be52", + "sellAmount": 100000000000000000n, + "status": "fulfilled", + "validTo": 1757527634, + }, + ], + "resolvedOwner": "0x016f34d4f2578c3e9dffc3f2b811ba30c0c9e7f3", + "schedule": { + "appData": "0xf7c9274467f87d681e0fa21b3fb886b44546162a0ab94fd44d346a2b78af430a", + "buyToken": "0x177127622c4a00f3d409b75571e12cb3c8973d3c", + "durationOfPart": 0n, + "effectiveStartTime": 1757527335n, + "minPartLimit": 1n, + "numberOfParts": 2n, + "partSellAmount": 100000000000000000n, + "receiver": "0x016f34d4f2578c3e9dffc3f2b811ba30c0c9e7f3", + "sellToken": "0xaf204776c7245bf4147c2612bf6e5972ee483701", + "timeBetweenParts": 300n, + }, + "status": "Completed", + }, +] +`; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fb1da5fc7..8757764ad 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -552,6 +552,9 @@ importers: '@openzeppelin/merkle-tree': specifier: ^1.0.8 version: 1.0.8 + valibot: + specifier: 1.4.2 + version: 1.4.2(typescript@5.8.3) devDependencies: '@cow-sdk/typescript-config': specifier: workspace:* @@ -6887,6 +6890,14 @@ packages: resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} hasBin: true + valibot@1.4.2: + resolution: {integrity: sha512-gjdCvJ6d3RyHAneqxMYMW9QMCwYMb3jpOO0IyHZV1bnRHFBHrX3VkIILt5XYR0WhwHiH7Mty8ovuPZ/O3gamrg==} + peerDependencies: + typescript: '>=5' + peerDependenciesMeta: + typescript: + optional: true + v8-compile-cache-lib@3.0.1: resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==} @@ -15158,6 +15169,10 @@ snapshots: uuid@9.0.1: {} + valibot@1.4.2(typescript@5.8.3): + optionalDependencies: + typescript: 5.8.3 + v8-compile-cache-lib@3.0.1: {} v8-to-istanbul@9.3.0: From a1f3dee46bdb77be841586331d6235ac2afa6ea5 Mon Sep 17 00:00:00 2001 From: Daniel Constantin Date: Fri, 17 Jul 2026 13:37:44 +0300 Subject: [PATCH 2/8] chore: fix eslint looping suggestion --- packages/composable/src/programmaticOrders/client.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/composable/src/programmaticOrders/client.ts b/packages/composable/src/programmaticOrders/client.ts index 2dd580c2b..4deebf4dc 100644 --- a/packages/composable/src/programmaticOrders/client.ts +++ b/packages/composable/src/programmaticOrders/client.ts @@ -110,12 +110,14 @@ export class ProgrammaticOrdersClient { const items: T[] = [] const cursors = new Set() let after: string | null = null + let hasNextPage = true - while (true) { + while (hasNextPage) { const page = await getPage(after) items.push(...page.items) + hasNextPage = page.hasNextPage - if (!page.hasNextPage) return items + if (!hasNextPage) break if (page.endCursor === null || page.endCursor.length === 0 || cursors.has(page.endCursor)) { invalidResponse(`${label}.pageInfo contains an invalid pagination cursor`) } @@ -123,6 +125,8 @@ export class ProgrammaticOrdersClient { cursors.add(page.endCursor) after = page.endCursor } + + return items } async query(operation: string, query: string, variables: Record): Promise { From 84bcee325763f30517f00daa44c86a2b4451cf36 Mon Sep 17 00:00:00 2001 From: Daniel Constantin Date: Fri, 17 Jul 2026 18:03:46 +0300 Subject: [PATCH 3/8] refactor: allow fetching twap history of a safe --- packages/composable/README.md | 4 +- .../ProgrammaticOrderApi.ts | 27 +- .../src/programmaticOrders/client.ts | 99 +--- .../src/programmaticOrders/common/parse.ts | 5 - .../src/programmaticOrders/twap/queries.ts | 16 +- .../src/programmaticOrders/twap/types.ts | 9 +- .../tests/ProgrammaticOrderApi.spec.ts | 48 +- .../ProgrammaticOrderApi.spec.ts.snap | 437 +++++++++--------- 8 files changed, 268 insertions(+), 377 deletions(-) diff --git a/packages/composable/README.md b/packages/composable/README.md index c6f0f8c29..045171105 100644 --- a/packages/composable/README.md +++ b/packages/composable/README.md @@ -24,7 +24,7 @@ yarn add @cowprotocol/sdk-composable ## Read TWAP history -`ProgrammaticOrderApi` lists TWAP orders and their part orders for an EVM owner. It resolves current proxy mappings, so the protocol `owner` may differ from `resolvedOwner`. +`ProgrammaticOrderApi` lists TWAP orders and their part orders for a controlling EOA or Safe. ```typescript import { ProgrammaticOrderApi } from '@cowprotocol/sdk-composable' @@ -32,7 +32,7 @@ import { SupportedChainId } from '@cowprotocol/sdk-config' const api = new ProgrammaticOrderApi() const twapOrders = await api.getTwapOrders({ - owner: '0x...', + resolvedOwner: '0x...', chainId: SupportedChainId.GNOSIS_CHAIN, }) ``` diff --git a/packages/composable/src/programmaticOrders/ProgrammaticOrderApi.ts b/packages/composable/src/programmaticOrders/ProgrammaticOrderApi.ts index e733586d0..b17a1acb6 100644 --- a/packages/composable/src/programmaticOrders/ProgrammaticOrderApi.ts +++ b/packages/composable/src/programmaticOrders/ProgrammaticOrderApi.ts @@ -23,47 +23,40 @@ export class ProgrammaticOrderApi { } /** - * Returns all TWAP orders and their part orders for an owner on a supported EVM chain. + * Returns all TWAP orders and their part orders for a canonical owner on a supported EVM chain. * * @remarks - * Resolves EOA/proxy owner mappings and follows every API page. `partOrders` - * contains part orders; scheduled parts without a part order are not included. + * Follows every API page. `partOrders` contains part orders; scheduled parts without a part order are not included. * A failed page rejects the request rather than returning partial results. * - * @param params - Owner address and chain to query. + * @param params - Controlling EOA or Safe address and chain to query. The address must not be a CoWShed proxy. * @returns All TWAP orders and their part orders. * @throws {@link ProgrammaticOrderApiError} when input validation, the request, * GraphQL, or response validation fails. */ async getTwapOrders(params: GetTwapOrdersParams): Promise { - const { owner, chainId } = params - const requestedOwner = validateRequest(owner, chainId) + const { chainId } = params + const resolvedOwner = validateRequest(params.resolvedOwner, chainId) - log(`ProgrammaticOrderApi: fetching TWAP orders for ${requestedOwner} on chain ${chainId}`) + log(`ProgrammaticOrderApi: fetching TWAP orders for ${resolvedOwner} on chain ${chainId}`) - const { resolvedOwner, owners } = await this.client.getOwnerScope(requestedOwner, chainId) - const parents = await getTwapParents(this.client, resolvedOwner, chainId, owners) + const parents = await getTwapParents(this.client, resolvedOwner, chainId) const partOrdersByParent = await getTwapPartOrders( this.client, chainId, parents.map(({ eventId }) => eventId), ) - const orders = parents.map((parent) => { + const orders = parents.map((parent) => { const partOrders = partOrdersByParent.get(parent.eventId) ?? [] return { - eventId: parent.eventId, - hash: parent.hash, + ...parent, chainId, - owner: parent.owner, resolvedOwner, - status: parent.status, - createdAt: parent.createdAt, - schedule: parent.schedule, partOrders, executedAmounts: sumExecutedAmounts(partOrders), - } + } satisfies TwapOrder }) log( diff --git a/packages/composable/src/programmaticOrders/client.ts b/packages/composable/src/programmaticOrders/client.ts index 4deebf4dc..889683147 100644 --- a/packages/composable/src/programmaticOrders/client.ts +++ b/packages/composable/src/programmaticOrders/client.ts @@ -1,55 +1,11 @@ -import type { SupportedChainId } from '@cowprotocol/sdk-config' -import { areAddressesEqual, getEvmAddressKey, log } from '@cowprotocol/sdk-common' - -import { - invalidResponse, - OWNER_MAPPING_SCHEMA, - parseGraphqlData, - parsePage, - type Page, -} from './common/parse' +import { log } from '@cowprotocol/sdk-common' + +import { invalidResponse, parseGraphqlData, type Page } from './common/parse' import { ProgrammaticOrderApiError } from './common/types' const DEFAULT_API_URL = 'https://cow-programmatic-order.bleu.blue' export const PAGE_SIZE = 1000 -const OWNER_MAPPINGS_QUERY = ` - query OwnerMappings($owner: String!, $chainId: Int!, $after: String, $limit: Int!) { - ownerMappings(where: { owner: $owner, chainId: $chainId }, after: $after, limit: $limit) { - items { - address - chainId - owner - } - pageInfo { - hasNextPage - endCursor - } - } - } -` - -const OWNER_RESOLUTION_QUERY = ` - query OwnerResolution($address: String!, $chainId: Int!, $after: String, $limit: Int!) { - ownerMappings(where: { address: $address, chainId: $chainId }, after: $after, limit: $limit) { - items { - address - chainId - owner - } - pageInfo { - hasNextPage - endCursor - } - } - } -` - -interface OwnerScope { - resolvedOwner: string - owners: string[] -} - export class ProgrammaticOrdersClient { private readonly graphqlUrl: string @@ -57,55 +13,6 @@ export class ProgrammaticOrdersClient { this.graphqlUrl = getGraphqlUrl(apiUrl ?? DEFAULT_API_URL) } - async getOwnerScope(owner: string, chainId: SupportedChainId): Promise { - const inputMappings = await this.paginate('owner resolution', async (after) => { - const data = await this.query('owner resolution', OWNER_RESOLUTION_QUERY, { - address: owner, - chainId, - after, - limit: PAGE_SIZE, - }) - - return parsePage(data, 'ownerMappings', OWNER_MAPPING_SCHEMA) - }) - - if (inputMappings.length > 1) invalidResponse('ownerMappings contains conflicting mappings for an address') - - const inputMapping = inputMappings[0] - - if (inputMapping && (inputMapping.chainId !== chainId || !areAddressesEqual(inputMapping.address, owner))) { - invalidResponse('ownerMappings.items contains a row for another address or chain') - } - - const resolvedOwner = inputMapping?.owner ?? owner - const mappings = await this.paginate('owner mappings', async (after) => { - const data = await this.query('owner mappings', OWNER_MAPPINGS_QUERY, { - owner: resolvedOwner, - chainId, - after, - limit: PAGE_SIZE, - }) - - return parsePage(data, 'ownerMappings', OWNER_MAPPING_SCHEMA) - }) - const owners = new Map([ - [getEvmAddressKey(resolvedOwner), resolvedOwner], - [getEvmAddressKey(owner), owner], - ]) - - for (const mapping of mappings) { - if (mapping.chainId !== chainId || !areAddressesEqual(mapping.owner, resolvedOwner)) { - invalidResponse('ownerMappings.items contains a row for another owner or chain') - } - - owners.set(getEvmAddressKey(mapping.address), mapping.address) - } - - log(`ProgrammaticOrderApi: resolved ${owners.size - 1} proxy owner(s)`) - - return { resolvedOwner, owners: [...owners.values()] } - } - async paginate(label: string, getPage: (after: string | null) => Promise>): Promise { const items: T[] = [] const cursors = new Set() diff --git a/packages/composable/src/programmaticOrders/common/parse.ts b/packages/composable/src/programmaticOrders/common/parse.ts index 4c6c2c7b4..b1e2904e3 100644 --- a/packages/composable/src/programmaticOrders/common/parse.ts +++ b/packages/composable/src/programmaticOrders/common/parse.ts @@ -48,11 +48,6 @@ export const TIMESTAMP_SCHEMA = v.pipe( v.transform((value) => Number(value)), ) export const PROGRAMMATIC_ORDER_STATUS_SCHEMA = v.picklist(['Active', 'Cancelled', 'Completed']) -export const OWNER_MAPPING_SCHEMA = v.object({ - address: ADDRESS_SCHEMA, - chainId: SAFE_INTEGER_SCHEMA, - owner: ADDRESS_SCHEMA, -}) export interface Page { items: T[] diff --git a/packages/composable/src/programmaticOrders/twap/queries.ts b/packages/composable/src/programmaticOrders/twap/queries.ts index 17538aa1f..81ef3de2d 100644 --- a/packages/composable/src/programmaticOrders/twap/queries.ts +++ b/packages/composable/src/programmaticOrders/twap/queries.ts @@ -9,12 +9,17 @@ import type { TwapParent, TwapPartOrder, TwapPartOrderRecord } from './types' const PART_PARENT_BATCH_SIZE = 100 const TWAP_ORDERS_QUERY = ` - query TwapOrders($owner: String!, $chainId: Int!, $owners: [String!]!, $after: String, $limit: Int!) { + query TwapOrders( + $resolvedOwner: String! + $chainId: Int! + $after: String + $limit: Int! + ) { twapOrders: conditionalOrderGenerators( where: { chainId: $chainId orderType: TWAP - OR: [{ resolvedOwner: $owner }, { owner_in: $owners }] + resolvedOwner: $resolvedOwner } after: $after limit: $limit @@ -67,13 +72,11 @@ export async function getTwapParents( client: ProgrammaticOrdersClient, resolvedOwner: string, chainId: SupportedChainId, - owners: string[], ): Promise { const parents = await client.paginate('TWAP orders', async (after) => { const data = await client.query('TWAP orders', TWAP_ORDERS_QUERY, { - owner: resolvedOwner, + resolvedOwner, chainId, - owners, after, limit: PAGE_SIZE, }) @@ -83,10 +86,9 @@ export async function getTwapParents( const uniqueParents = new Map() for (const parent of parents) { - const matchesOwner = owners.some((owner) => areAddressesEqual(parent.owner, owner)) const matchesResolvedOwner = parent.resolvedOwner !== null && areAddressesEqual(parent.resolvedOwner, resolvedOwner) - if (parent.chainId !== chainId || (!matchesOwner && !matchesResolvedOwner)) { + if (parent.chainId !== chainId || !matchesResolvedOwner) { invalidResponse('twapOrders.items contains a row for another owner or chain') } diff --git a/packages/composable/src/programmaticOrders/twap/types.ts b/packages/composable/src/programmaticOrders/twap/types.ts index da039f59c..4c84f9a7c 100644 --- a/packages/composable/src/programmaticOrders/twap/types.ts +++ b/packages/composable/src/programmaticOrders/twap/types.ts @@ -5,8 +5,8 @@ import type { ProgrammaticOrderStatus } from '../common/types' /** Input for querying TWAP orders. */ export interface GetTwapOrdersParams { - /** EOA or mapped programmatic-order proxy address. */ - owner: string + /** Controlling EOA or Safe address. Must not be a CoWShed proxy. */ + resolvedOwner: string /** Supported EVM chain containing the orders. */ chainId: SupportedChainId } @@ -84,9 +84,9 @@ export interface TwapOrder { /** ComposableCoW single-order hash. Multiple creation events may share it. */ hash: string chainId: SupportedChainId - /** Protocol owner used by the generated CoW orders, which may be a proxy. */ + /** Protocol owner used by the generated CoW orders: the CoWShed proxy for an EOA TWAP, or the Safe for a Safe TWAP. */ owner: string - /** Current owner after applying the indexer's proxy mapping. */ + /** Canonical owner after applying known proxy mappings: the controlling EOA for a proxy, otherwise `owner`. */ resolvedOwner: string status: ProgrammaticOrderStatus /** Unix creation time in seconds. */ @@ -101,6 +101,7 @@ export interface TwapParent { hash: string chainId: number owner: string + /** Owner stored by the API after applying any known proxy mapping; nullable on the wire. */ resolvedOwner: string | null status: ProgrammaticOrderStatus createdAt: number diff --git a/packages/composable/tests/ProgrammaticOrderApi.spec.ts b/packages/composable/tests/ProgrammaticOrderApi.spec.ts index d8862f742..9bdfaa003 100644 --- a/packages/composable/tests/ProgrammaticOrderApi.spec.ts +++ b/packages/composable/tests/ProgrammaticOrderApi.spec.ts @@ -3,52 +3,24 @@ import { SupportedChainId } from '@cowprotocol/sdk-config' import { ProgrammaticOrderApi } from '../src' const EOA = '0x016f34D4f2578c3e9DFfC3f2b811Ba30c0c9e7f3' +const SAFE = '0xaA248D5328c7D781a96D93d7D013bcF393157bB4' describe('ProgrammaticOrderApi', () => { - afterEach(() => { - jest.restoreAllMocks() - }) - it('lists all known EOA TWAPs from the live programmatic orders API', async () => { const orders = await new ProgrammaticOrderApi().getTwapOrders({ - owner: EOA, + resolvedOwner: EOA, chainId: SupportedChainId.GNOSIS_CHAIN, }) - const eventIds = orders.map(({ eventId }) => eventId) - const stableOrders = orders - .map(({ eventId: _eventId, partOrders, ...order }) => ({ - ...order, - partOrders: [...partOrders].sort((a, b) => a.orderUid.localeCompare(b.orderUid)), - })) - .sort((a, b) => a.hash.localeCompare(b.hash)) - expect(orders).toHaveLength(13) - expect(eventIds.every((eventId) => eventId.length > 0)).toBe(true) - expect(new Set(eventIds).size).toBe(13) - expect(stableOrders).toMatchSnapshot() + expect(orders).toMatchSnapshot() }, 30_000) - it('surfaces malformed indexer data as an invalid-response error', async () => { - jest.spyOn(globalThis, 'fetch').mockResolvedValue( - new Response( - JSON.stringify({ - data: { - ownerMappings: { - items: [{ address: 'not-an-address', chainId: 100, owner: EOA }], - pageInfo: { hasNextPage: false, endCursor: null }, - }, - }, - }), - { status: 200 }, - ), - ) - - await expect( - new ProgrammaticOrderApi().getTwapOrders({ owner: EOA, chainId: SupportedChainId.GNOSIS_CHAIN }), - ).rejects.toMatchObject({ - name: 'ProgrammaticOrderApiError', - kind: 'invalid-response', - message: expect.stringContaining('response.data.ownerMappings.items[0].address'), + it('lists all known Safe TWAPs from the live programmatic orders API', async () => { + const orders = await new ProgrammaticOrderApi().getTwapOrders({ + resolvedOwner: SAFE, + chainId: SupportedChainId.GNOSIS_CHAIN, }) - }) + + expect(orders).toMatchSnapshot() + }, 30_000) }) diff --git a/packages/composable/tests/__snapshots__/ProgrammaticOrderApi.spec.ts.snap b/packages/composable/tests/__snapshots__/ProgrammaticOrderApi.spec.ts.snap index d4aff708f..062ca74ea 100644 --- a/packages/composable/tests/__snapshots__/ProgrammaticOrderApi.spec.ts.snap +++ b/packages/composable/tests/__snapshots__/ProgrammaticOrderApi.spec.ts.snap @@ -4,106 +4,109 @@ exports[`ProgrammaticOrderApi lists all known EOA TWAPs from the live programmat [ { "chainId": 100, - "createdAt": 1757526710, + "createdAt": 1757524180, + "eventId": "175752418000000000000001000000000042055392000000000000000250000000000000021", "executedAmounts": { "executedBuyAmount": 0n, "executedSellAmount": 0n, }, - "hash": "0x24022b547eab0362735d633bd4ed6695a2bfad7e8d470b074e610f4b027f2f37", + "hash": "0x296f02547402281d040fa79d59ef61631e4a18c317654cefa1bf2226224c9fd9", "owner": "0x62587918b2f00176646679509217a5a4d1ebbfd5", - "partOrders": [ - { - "buyAmount": 1n, - "createdAt": 1757526710, - "executedBuyAmount": null, - "executedSellAmount": null, - "feeAmount": 0n, - "orderUid": "0x3a1cff1951ea04175bdba196c45b2b7b371db5bf7679ee6fa7a7339e62ff740262587918b2f00176646679509217a5a4d1ebbfd568c1bb2d", - "sellAmount": 25000000000000000n, - "status": "expired", - "validTo": 1757526829, - }, - { - "buyAmount": 1n, - "createdAt": 1757526710, - "executedBuyAmount": null, - "executedSellAmount": null, - "feeAmount": 0n, - "orderUid": "0x3fc2139a409b95dfb3ff2bf903a3c05971225579e6a14f63218a5da44b29fb7d62587918b2f00176646679509217a5a4d1ebbfd568c1baf1", - "sellAmount": 25000000000000000n, - "status": "expired", - "validTo": 1757526769, - }, - ], + "partOrders": [], "resolvedOwner": "0x016f34d4f2578c3e9dffc3f2b811ba30c0c9e7f3", "schedule": { "appData": "0xf7c9274467f87d681e0fa21b3fb886b44546162a0ab94fd44d346a2b78af430a", "buyToken": "0x177127622c4a00f3d409b75571e12cb3c8973d3c", "durationOfPart": 0n, - "effectiveStartTime": 1757526710n, - "minPartLimit": 1n, + "effectiveStartTime": 1757524180n, + "minPartLimit": 0n, "numberOfParts": 4n, "partSellAmount": 25000000000000000n, "receiver": "0x016f34d4f2578c3e9dffc3f2b811ba30c0c9e7f3", "sellToken": "0xaf204776c7245bf4147c2612bf6e5972ee483701", - "timeBetweenParts": 30n, + "timeBetweenParts": 1800n, }, "status": "Active", }, { "chainId": 100, - "createdAt": 1782855780, + "createdAt": 1757524940, + "eventId": "175752494000000000000001000000000042055539000000000000000050000000000000008", "executedAmounts": { - "executedBuyAmount": 0n, - "executedSellAmount": 0n, + "executedBuyAmount": 168880044359503662n, + "executedSellAmount": 50000000000000000n, }, - "hash": "0x28b61a46088d4082b10aca722efa0d2404106686594f2a82936893f2d7d22f06", + "hash": "0x618fc1893e8fc19a353734250d775dc3fa62df205ed545cc66a17a7ab1197a0c", "owner": "0x62587918b2f00176646679509217a5a4d1ebbfd5", "partOrders": [ { - "buyAmount": 774421599485031327n, - "createdAt": 1782855780, - "executedBuyAmount": null, - "executedSellAmount": null, + "buyAmount": 1n, + "createdAt": 1757524940, + "executedBuyAmount": 84540640270669195n, + "executedSellAmount": 25000000000000000n, "feeAmount": 0n, - "orderUid": "0xb52801bbe2fd0904f235d20ce0ae3b0eea1d102f8001961cfc7e1e74b583dd1b62587918b2f00176646679509217a5a4d1ebbfd56a4438db", - "sellAmount": 100000000000000000n, - "status": "expired", - "validTo": 1782855899, + "orderUid": "0x1407dbf775a8e5869716fdcfe1bcbc8ccffdc43c7bed9db359af46dc15c27bb462587918b2f00176646679509217a5a4d1ebbfd568c1c1db", + "sellAmount": 25000000000000000n, + "status": "fulfilled", + "validTo": 1757528539, + }, + { + "buyAmount": 1n, + "createdAt": 1757524940, + "executedBuyAmount": 84339404088834467n, + "executedSellAmount": 25000000000000000n, + "feeAmount": 0n, + "orderUid": "0x2065f76e5eb0e8da3e20136d4c8bc69f3735d9d54b0d228b3794438282b7fd9f62587918b2f00176646679509217a5a4d1ebbfd568c1bad3", + "sellAmount": 25000000000000000n, + "status": "fulfilled", + "validTo": 1757526739, }, ], "resolvedOwner": "0x016f34d4f2578c3e9dffc3f2b811ba30c0c9e7f3", "schedule": { - "appData": "0xebe836e7e0257003712b4884f2747a51dc706691305723c19e4bf7bec1c111ff", + "appData": "0xf7c9274467f87d681e0fa21b3fb886b44546162a0ab94fd44d346a2b78af430a", "buyToken": "0x177127622c4a00f3d409b75571e12cb3c8973d3c", "durationOfPart": 0n, - "effectiveStartTime": 1782855780n, - "minPartLimit": 774421599485031327n, - "numberOfParts": 2n, - "partSellAmount": 100000000000000000n, + "effectiveStartTime": 1757524940n, + "minPartLimit": 1n, + "numberOfParts": 4n, + "partSellAmount": 25000000000000000n, "receiver": "0x016f34d4f2578c3e9dffc3f2b811ba30c0c9e7f3", "sellToken": "0xaf204776c7245bf4147c2612bf6e5972ee483701", - "timeBetweenParts": 60n, + "timeBetweenParts": 1800n, }, "status": "Active", }, { "chainId": 100, - "createdAt": 1757524180, + "createdAt": 1757525910, + "eventId": "175752591000000000000001000000000042055728000000000000000050000000000000015", "executedAmounts": { - "executedBuyAmount": 0n, - "executedSellAmount": 0n, + "executedBuyAmount": 84880520382659281n, + "executedSellAmount": 25000000000000000n, }, - "hash": "0x296f02547402281d040fa79d59ef61631e4a18c317654cefa1bf2226224c9fd9", + "hash": "0x4ac7633559e7c90792574306ae2c3bbae7588681a0219c3668b61cc6da2be10b", "owner": "0x62587918b2f00176646679509217a5a4d1ebbfd5", - "partOrders": [], + "partOrders": [ + { + "buyAmount": 1n, + "createdAt": 1757525910, + "executedBuyAmount": 84880520382659281n, + "executedSellAmount": 25000000000000000n, + "feeAmount": 0n, + "orderUid": "0x7d228549d048c1a4759f198766e8014405dae68b97ec3efbe81c0d8df523140062587918b2f00176646679509217a5a4d1ebbfd568c1be9d", + "sellAmount": 25000000000000000n, + "status": "fulfilled", + "validTo": 1757527709, + }, + ], "resolvedOwner": "0x016f34d4f2578c3e9dffc3f2b811ba30c0c9e7f3", "schedule": { "appData": "0xf7c9274467f87d681e0fa21b3fb886b44546162a0ab94fd44d346a2b78af430a", "buyToken": "0x177127622c4a00f3d409b75571e12cb3c8973d3c", "durationOfPart": 0n, - "effectiveStartTime": 1757524180n, - "minPartLimit": 0n, + "effectiveStartTime": 1757525910n, + "minPartLimit": 1n, "numberOfParts": 4n, "partSellAmount": 25000000000000000n, "receiver": "0x016f34d4f2578c3e9dffc3f2b811ba30c0c9e7f3", @@ -115,6 +118,7 @@ exports[`ProgrammaticOrderApi lists all known EOA TWAPs from the live programmat { "chainId": 100, "createdAt": 1757526200, + "eventId": "175752620000000000000001000000000042055786000000000000000050000000000000008", "executedAmounts": { "executedBuyAmount": 84756756849678668n, "executedSellAmount": 25000000000000000n, @@ -151,24 +155,36 @@ exports[`ProgrammaticOrderApi lists all known EOA TWAPs from the live programmat }, { "chainId": 100, - "createdAt": 1757525910, + "createdAt": 1757526710, + "eventId": "175752671000000000000001000000000042055888000000000000000150000000000000029", "executedAmounts": { - "executedBuyAmount": 84880520382659281n, - "executedSellAmount": 25000000000000000n, + "executedBuyAmount": 0n, + "executedSellAmount": 0n, }, - "hash": "0x4ac7633559e7c90792574306ae2c3bbae7588681a0219c3668b61cc6da2be10b", + "hash": "0x24022b547eab0362735d633bd4ed6695a2bfad7e8d470b074e610f4b027f2f37", "owner": "0x62587918b2f00176646679509217a5a4d1ebbfd5", "partOrders": [ { "buyAmount": 1n, - "createdAt": 1757525910, - "executedBuyAmount": 84880520382659281n, - "executedSellAmount": 25000000000000000n, + "createdAt": 1757526710, + "executedBuyAmount": null, + "executedSellAmount": null, "feeAmount": 0n, - "orderUid": "0x7d228549d048c1a4759f198766e8014405dae68b97ec3efbe81c0d8df523140062587918b2f00176646679509217a5a4d1ebbfd568c1be9d", + "orderUid": "0x3a1cff1951ea04175bdba196c45b2b7b371db5bf7679ee6fa7a7339e62ff740262587918b2f00176646679509217a5a4d1ebbfd568c1bb2d", "sellAmount": 25000000000000000n, - "status": "fulfilled", - "validTo": 1757527709, + "status": "expired", + "validTo": 1757526829, + }, + { + "buyAmount": 1n, + "createdAt": 1757526710, + "executedBuyAmount": null, + "executedSellAmount": null, + "feeAmount": 0n, + "orderUid": "0x3fc2139a409b95dfb3ff2bf903a3c05971225579e6a14f63218a5da44b29fb7d62587918b2f00176646679509217a5a4d1ebbfd568c1baf1", + "sellAmount": 25000000000000000n, + "status": "expired", + "validTo": 1757526769, }, ], "resolvedOwner": "0x016f34d4f2578c3e9dffc3f2b811ba30c0c9e7f3", @@ -176,106 +192,97 @@ exports[`ProgrammaticOrderApi lists all known EOA TWAPs from the live programmat "appData": "0xf7c9274467f87d681e0fa21b3fb886b44546162a0ab94fd44d346a2b78af430a", "buyToken": "0x177127622c4a00f3d409b75571e12cb3c8973d3c", "durationOfPart": 0n, - "effectiveStartTime": 1757525910n, + "effectiveStartTime": 1757526710n, "minPartLimit": 1n, "numberOfParts": 4n, "partSellAmount": 25000000000000000n, "receiver": "0x016f34d4f2578c3e9dffc3f2b811ba30c0c9e7f3", "sellToken": "0xaf204776c7245bf4147c2612bf6e5972ee483701", - "timeBetweenParts": 1800n, + "timeBetweenParts": 30n, }, "status": "Active", }, { "chainId": 100, - "createdAt": 1782860530, + "createdAt": 1757527335, + "eventId": "175752733500000000000001000000000042056007000000000000000050000000000000013", "executedAmounts": { - "executedBuyAmount": 862367902269494618n, - "executedSellAmount": 100000000000000000n, + "executedBuyAmount": 678620197252801976n, + "executedSellAmount": 200000000000000000n, }, - "hash": "0x5ec4e09e073067d53acbcb81a94f926608ce075f0f04842e91a3b22ca2ea333d", + "hash": "0xf8edb9707569dec76a362bb6bac1909fdf43d5afe70beca3e422ec7b1bbaa237", "owner": "0x62587918b2f00176646679509217a5a4d1ebbfd5", "partOrders": [ { - "buyAmount": 776021953726338606n, - "createdAt": 1782860530, - "executedBuyAmount": 862367902269494618n, + "buyAmount": 1n, + "createdAt": 1757527335, + "executedBuyAmount": 339434970519487884n, "executedSellAmount": 100000000000000000n, "feeAmount": 0n, - "orderUid": "0x24106d3b23805bd9b992bfdba43bd692de4582180acf44ca4edb0d698312562d62587918b2f00176646679509217a5a4d1ebbfd56a444b69", + "orderUid": "0x00b3bd2d842331d914ec6b4136d8056aec35fc3dff3f158c3d2f876d764e2ca962587918b2f00176646679509217a5a4d1ebbfd568c1bf7e", "sellAmount": 100000000000000000n, "status": "fulfilled", - "validTo": 1782860649, + "validTo": 1757527934, }, { - "buyAmount": 776021953726338606n, - "createdAt": 1782860530, - "executedBuyAmount": 0n, - "executedSellAmount": 0n, + "buyAmount": 1n, + "createdAt": 1757527335, + "executedBuyAmount": 339185226733314092n, + "executedSellAmount": 100000000000000000n, "feeAmount": 0n, - "orderUid": "0xd8d939a5b9bfe1ede5357ec33c48748a1e59567530a6a44a634d615c2a35abfc62587918b2f00176646679509217a5a4d1ebbfd56a444be1", + "orderUid": "0xf600a33ded1bd6cac5c9d38fbc9c4bc3a5ab492fd2b34aecadd701f7d4bb805f62587918b2f00176646679509217a5a4d1ebbfd568c1be52", "sellAmount": 100000000000000000n, - "status": "expired", - "validTo": 1782860769, + "status": "fulfilled", + "validTo": 1757527634, }, ], "resolvedOwner": "0x016f34d4f2578c3e9dffc3f2b811ba30c0c9e7f3", "schedule": { - "appData": "0x302493bc6206f0065533621e960c9e5411c58044ea0b5a80b4f61edac00ba2f3", + "appData": "0xf7c9274467f87d681e0fa21b3fb886b44546162a0ab94fd44d346a2b78af430a", "buyToken": "0x177127622c4a00f3d409b75571e12cb3c8973d3c", "durationOfPart": 0n, - "effectiveStartTime": 1782860530n, - "minPartLimit": 776021953726338606n, + "effectiveStartTime": 1757527335n, + "minPartLimit": 1n, "numberOfParts": 2n, "partSellAmount": 100000000000000000n, "receiver": "0x016f34d4f2578c3e9dffc3f2b811ba30c0c9e7f3", "sellToken": "0xaf204776c7245bf4147c2612bf6e5972ee483701", - "timeBetweenParts": 120n, + "timeBetweenParts": 300n, }, "status": "Completed", }, { "chainId": 100, - "createdAt": 1757524940, + "createdAt": 1782842370, + "eventId": "178284237000000000000001000000000046965898000000000000000250000000000000013", "executedAmounts": { - "executedBuyAmount": 168880044359503662n, - "executedSellAmount": 50000000000000000n, + "executedBuyAmount": 1703025006672763226n, + "executedSellAmount": 200000000000000000n, }, - "hash": "0x618fc1893e8fc19a353734250d775dc3fa62df205ed545cc66a17a7ab1197a0c", + "hash": "0xa05b5dabbe1703fe82ee0d2ea89dc0397dcebfeb8ba1ec64591fb1d40de06422", "owner": "0x62587918b2f00176646679509217a5a4d1ebbfd5", "partOrders": [ { "buyAmount": 1n, - "createdAt": 1757524940, - "executedBuyAmount": 84540640270669195n, - "executedSellAmount": 25000000000000000n, + "createdAt": 1782842370, + "executedBuyAmount": 846746200516767824n, + "executedSellAmount": 100000000000000000n, "feeAmount": 0n, - "orderUid": "0x1407dbf775a8e5869716fdcfe1bcbc8ccffdc43c7bed9db359af46dc15c27bb462587918b2f00176646679509217a5a4d1ebbfd568c1c1db", - "sellAmount": 25000000000000000n, + "orderUid": "0x144eb01438d03b3b68e810b407ed884fb40384826736c7d8096b2b508c55f8b962587918b2f00176646679509217a5a4d1ebbfd56a440659", + "sellAmount": 100000000000000000n, "status": "fulfilled", - "validTo": 1757528539, + "validTo": 1782842969, }, { "buyAmount": 1n, - "createdAt": 1757524940, - "executedBuyAmount": 84339404088834467n, - "executedSellAmount": 25000000000000000n, + "createdAt": 1782842370, + "executedBuyAmount": 856278806155995402n, + "executedSellAmount": 100000000000000000n, "feeAmount": 0n, - "orderUid": "0x2065f76e5eb0e8da3e20136d4c8bc69f3735d9d54b0d228b3794438282b7fd9f62587918b2f00176646679509217a5a4d1ebbfd568c1bad3", - "sellAmount": 25000000000000000n, + "orderUid": "0x6ae91e15d98477537ec583371381627d16724589d79590fccd314e8dc9ebf9f062587918b2f00176646679509217a5a4d1ebbfd56a44052d", + "sellAmount": 100000000000000000n, "status": "fulfilled", - "validTo": 1757526739, - }, - { - "buyAmount": 1n, - "createdAt": 1757524940, - "executedBuyAmount": null, - "executedSellAmount": null, - "feeAmount": 0n, - "orderUid": "0xdf32ac54fdd380a8351bbb9af6a3d0b13be827e7d0854ff5e36fe8eab810d7c662587918b2f00176646679509217a5a4d1ebbfd568c1c8e3", - "sellAmount": 25000000000000000n, - "status": "expired", - "validTo": 1757530339, + "validTo": 1782842669, }, ], "resolvedOwner": "0x016f34d4f2578c3e9dffc3f2b811ba30c0c9e7f3", @@ -283,19 +290,20 @@ exports[`ProgrammaticOrderApi lists all known EOA TWAPs from the live programmat "appData": "0xf7c9274467f87d681e0fa21b3fb886b44546162a0ab94fd44d346a2b78af430a", "buyToken": "0x177127622c4a00f3d409b75571e12cb3c8973d3c", "durationOfPart": 0n, - "effectiveStartTime": 1757524940n, + "effectiveStartTime": 1782842370n, "minPartLimit": 1n, - "numberOfParts": 4n, - "partSellAmount": 25000000000000000n, + "numberOfParts": 2n, + "partSellAmount": 100000000000000000n, "receiver": "0x016f34d4f2578c3e9dffc3f2b811ba30c0c9e7f3", "sellToken": "0xaf204776c7245bf4147c2612bf6e5972ee483701", - "timeBetweenParts": 1800n, + "timeBetweenParts": 300n, }, - "status": "Active", + "status": "Completed", }, { "chainId": 100, "createdAt": 1782851510, + "eventId": "178285151000000000000001000000000046967681000000000000000450000000000000034", "executedAmounts": { "executedBuyAmount": 1718396656651015389n, "executedSellAmount": 200000000000000000n, @@ -344,25 +352,14 @@ exports[`ProgrammaticOrderApi lists all known EOA TWAPs from the live programmat { "chainId": 100, "createdAt": 1782852985, + "eventId": "178285298500000000000001000000000046967969000000000000000450000000000000025", "executedAmounts": { "executedBuyAmount": 0n, "executedSellAmount": 0n, }, "hash": "0x979bcc44b12484836ed02e56b24190071c3881b39e3a6368138860d518d4e16d", "owner": "0x62587918b2f00176646679509217a5a4d1ebbfd5", - "partOrders": [ - { - "buyAmount": 773026802095130550n, - "createdAt": 1782852985, - "executedBuyAmount": null, - "executedSellAmount": null, - "feeAmount": 0n, - "orderUid": "0x52d95f6639c8db3977d13335b1ea2d10d89b77e7261f25e670a94a78f27bb6af62587918b2f00176646679509217a5a4d1ebbfd56a442df0", - "sellAmount": 100000000000000000n, - "status": "expired", - "validTo": 1782853104, - }, - ], + "partOrders": [], "resolvedOwner": "0x016f34d4f2578c3e9dffc3f2b811ba30c0c9e7f3", "schedule": { "appData": "0xf7c9274467f87d681e0fa21b3fb886b44546162a0ab94fd44d346a2b78af430a", @@ -380,55 +377,109 @@ exports[`ProgrammaticOrderApi lists all known EOA TWAPs from the live programmat }, { "chainId": 100, - "createdAt": 1782842370, + "createdAt": 1782855780, + "eventId": "178285578000000000000001000000000046968514000000000000000450000000000000024", "executedAmounts": { - "executedBuyAmount": 1703025006672763226n, - "executedSellAmount": 200000000000000000n, + "executedBuyAmount": 0n, + "executedSellAmount": 0n, }, - "hash": "0xa05b5dabbe1703fe82ee0d2ea89dc0397dcebfeb8ba1ec64591fb1d40de06422", + "hash": "0x28b61a46088d4082b10aca722efa0d2404106686594f2a82936893f2d7d22f06", + "owner": "0x62587918b2f00176646679509217a5a4d1ebbfd5", + "partOrders": [], + "resolvedOwner": "0x016f34d4f2578c3e9dffc3f2b811ba30c0c9e7f3", + "schedule": { + "appData": "0xebe836e7e0257003712b4884f2747a51dc706691305723c19e4bf7bec1c111ff", + "buyToken": "0x177127622c4a00f3d409b75571e12cb3c8973d3c", + "durationOfPart": 0n, + "effectiveStartTime": 1782855780n, + "minPartLimit": 774421599485031327n, + "numberOfParts": 2n, + "partSellAmount": 100000000000000000n, + "receiver": "0x016f34d4f2578c3e9dffc3f2b811ba30c0c9e7f3", + "sellToken": "0xaf204776c7245bf4147c2612bf6e5972ee483701", + "timeBetweenParts": 60n, + }, + "status": "Active", + }, + { + "chainId": 100, + "createdAt": 1782856350, + "eventId": "178285635000000000000001000000000046968626000000000000001750000000000000087", + "executedAmounts": { + "executedBuyAmount": 0n, + "executedSellAmount": 0n, + }, + "hash": "0xf5e3a9d322d4985cd9fc10b84e16f0358c720592ec968ec3921dc65fd702be0b", + "owner": "0x62587918b2f00176646679509217a5a4d1ebbfd5", + "partOrders": [], + "resolvedOwner": "0x016f34d4f2578c3e9dffc3f2b811ba30c0c9e7f3", + "schedule": { + "appData": "0xdfc54d9c8b6a28a0c03947debf1a0f929184919579c926cf3de23c5881eb524a", + "buyToken": "0x177127622c4a00f3d409b75571e12cb3c8973d3c", + "durationOfPart": 0n, + "effectiveStartTime": 1782856350n, + "minPartLimit": 774221321423698851n, + "numberOfParts": 2n, + "partSellAmount": 100000000000000000n, + "receiver": "0x016f34d4f2578c3e9dffc3f2b811ba30c0c9e7f3", + "sellToken": "0xaf204776c7245bf4147c2612bf6e5972ee483701", + "timeBetweenParts": 60n, + }, + "status": "Active", + }, + { + "chainId": 100, + "createdAt": 1782860530, + "eventId": "178286053000000000000001000000000046969430000000000000000850000000000000038", + "executedAmounts": { + "executedBuyAmount": 862367902269494618n, + "executedSellAmount": 100000000000000000n, + }, + "hash": "0x5ec4e09e073067d53acbcb81a94f926608ce075f0f04842e91a3b22ca2ea333d", "owner": "0x62587918b2f00176646679509217a5a4d1ebbfd5", "partOrders": [ { - "buyAmount": 1n, - "createdAt": 1782842370, - "executedBuyAmount": 846746200516767824n, + "buyAmount": 776021953726338606n, + "createdAt": 1782860530, + "executedBuyAmount": 862367902269494618n, "executedSellAmount": 100000000000000000n, "feeAmount": 0n, - "orderUid": "0x144eb01438d03b3b68e810b407ed884fb40384826736c7d8096b2b508c55f8b962587918b2f00176646679509217a5a4d1ebbfd56a440659", + "orderUid": "0x24106d3b23805bd9b992bfdba43bd692de4582180acf44ca4edb0d698312562d62587918b2f00176646679509217a5a4d1ebbfd56a444b69", "sellAmount": 100000000000000000n, "status": "fulfilled", - "validTo": 1782842969, + "validTo": 1782860649, }, { - "buyAmount": 1n, - "createdAt": 1782842370, - "executedBuyAmount": 856278806155995402n, - "executedSellAmount": 100000000000000000n, + "buyAmount": 776021953726338606n, + "createdAt": 1782860530, + "executedBuyAmount": 0n, + "executedSellAmount": 0n, "feeAmount": 0n, - "orderUid": "0x6ae91e15d98477537ec583371381627d16724589d79590fccd314e8dc9ebf9f062587918b2f00176646679509217a5a4d1ebbfd56a44052d", + "orderUid": "0xd8d939a5b9bfe1ede5357ec33c48748a1e59567530a6a44a634d615c2a35abfc62587918b2f00176646679509217a5a4d1ebbfd56a444be1", "sellAmount": 100000000000000000n, - "status": "fulfilled", - "validTo": 1782842669, + "status": "expired", + "validTo": 1782860769, }, ], "resolvedOwner": "0x016f34d4f2578c3e9dffc3f2b811ba30c0c9e7f3", "schedule": { - "appData": "0xf7c9274467f87d681e0fa21b3fb886b44546162a0ab94fd44d346a2b78af430a", + "appData": "0x302493bc6206f0065533621e960c9e5411c58044ea0b5a80b4f61edac00ba2f3", "buyToken": "0x177127622c4a00f3d409b75571e12cb3c8973d3c", "durationOfPart": 0n, - "effectiveStartTime": 1782842370n, - "minPartLimit": 1n, + "effectiveStartTime": 1782860530n, + "minPartLimit": 776021953726338606n, "numberOfParts": 2n, "partSellAmount": 100000000000000000n, "receiver": "0x016f34d4f2578c3e9dffc3f2b811ba30c0c9e7f3", "sellToken": "0xaf204776c7245bf4147c2612bf6e5972ee483701", - "timeBetweenParts": 300n, + "timeBetweenParts": 120n, }, "status": "Completed", }, { "chainId": 100, "createdAt": 1782860530, + "eventId": "178286053000000000000001000000000046969430000000000000000850000000000000041", "executedAmounts": { "executedBuyAmount": 862488114171327656n, "executedSellAmount": 100000000000000000n, @@ -474,78 +525,48 @@ exports[`ProgrammaticOrderApi lists all known EOA TWAPs from the live programmat }, "status": "Completed", }, +] +`; + +exports[`ProgrammaticOrderApi lists all known Safe TWAPs from the live programmatic orders API 1`] = ` +[ { "chainId": 100, - "createdAt": 1782856350, - "executedAmounts": { - "executedBuyAmount": 0n, - "executedSellAmount": 0n, - }, - "hash": "0xf5e3a9d322d4985cd9fc10b84e16f0358c720592ec968ec3921dc65fd702be0b", - "owner": "0x62587918b2f00176646679509217a5a4d1ebbfd5", - "partOrders": [], - "resolvedOwner": "0x016f34d4f2578c3e9dffc3f2b811ba30c0c9e7f3", - "schedule": { - "appData": "0xdfc54d9c8b6a28a0c03947debf1a0f929184919579c926cf3de23c5881eb524a", - "buyToken": "0x177127622c4a00f3d409b75571e12cb3c8973d3c", - "durationOfPart": 0n, - "effectiveStartTime": 1782856350n, - "minPartLimit": 774221321423698851n, - "numberOfParts": 2n, - "partSellAmount": 100000000000000000n, - "receiver": "0x016f34d4f2578c3e9dffc3f2b811ba30c0c9e7f3", - "sellToken": "0xaf204776c7245bf4147c2612bf6e5972ee483701", - "timeBetweenParts": 60n, - }, - "status": "Active", - }, - { - "chainId": 100, - "createdAt": 1757527335, + "createdAt": 1784289275, + "eventId": "178428927500000000000001000000000047248688000000000000002250000000000000129", "executedAmounts": { - "executedBuyAmount": 678620197252801976n, - "executedSellAmount": 200000000000000000n, + "executedBuyAmount": 7938540649899270304n, + "executedSellAmount": 1150000000000000000n, }, - "hash": "0xf8edb9707569dec76a362bb6bac1909fdf43d5afe70beca3e422ec7b1bbaa237", - "owner": "0x62587918b2f00176646679509217a5a4d1ebbfd5", + "hash": "0x9aa36d973466810ad97a07041496c6f7776e7a2ab3cc63c47584006673f14050", + "owner": "0xaa248d5328c7d781a96d93d7d013bcf393157bb4", "partOrders": [ { - "buyAmount": 1n, - "createdAt": 1757527335, - "executedBuyAmount": 339434970519487884n, - "executedSellAmount": 100000000000000000n, + "buyAmount": 7143588130746906513n, + "createdAt": 1784289275, + "executedBuyAmount": 7938540649899270304n, + "executedSellAmount": 1150000000000000000n, "feeAmount": 0n, - "orderUid": "0x00b3bd2d842331d914ec6b4136d8056aec35fc3dff3f158c3d2f876d764e2ca962587918b2f00176646679509217a5a4d1ebbfd568c1bf7e", - "sellAmount": 100000000000000000n, - "status": "fulfilled", - "validTo": 1757527934, - }, - { - "buyAmount": 1n, - "createdAt": 1757527335, - "executedBuyAmount": 339185226733314092n, - "executedSellAmount": 100000000000000000n, - "feeAmount": 0n, - "orderUid": "0xf600a33ded1bd6cac5c9d38fbc9c4bc3a5ab492fd2b34aecadd701f7d4bb805f62587918b2f00176646679509217a5a4d1ebbfd568c1be52", - "sellAmount": 100000000000000000n, + "orderUid": "0x158849e34f900227b0fb25e349da2617168af20fba9fca677ed02a3b01a5a101aa248d5328c7d781a96d93d7d013bcf393157bb46a5a1f02", + "sellAmount": 1150000000000000000n, "status": "fulfilled", - "validTo": 1757527634, + "validTo": 1784291074, }, ], - "resolvedOwner": "0x016f34d4f2578c3e9dffc3f2b811ba30c0c9e7f3", + "resolvedOwner": "0xaa248d5328c7d781a96d93d7d013bcf393157bb4", "schedule": { - "appData": "0xf7c9274467f87d681e0fa21b3fb886b44546162a0ab94fd44d346a2b78af430a", + "appData": "0x832ef833029247a34f08ea828c3d19301241443de679ecfe2d53ead11c983a59", "buyToken": "0x177127622c4a00f3d409b75571e12cb3c8973d3c", "durationOfPart": 0n, - "effectiveStartTime": 1757527335n, - "minPartLimit": 1n, + "effectiveStartTime": 1784289275n, + "minPartLimit": 7143588130746906513n, "numberOfParts": 2n, - "partSellAmount": 100000000000000000n, - "receiver": "0x016f34d4f2578c3e9dffc3f2b811ba30c0c9e7f3", - "sellToken": "0xaf204776c7245bf4147c2612bf6e5972ee483701", - "timeBetweenParts": 300n, + "partSellAmount": 1150000000000000000n, + "receiver": "0xaa248d5328c7d781a96d93d7d013bcf393157bb4", + "sellToken": "0xe91d153e0b41518a2ce8dd3d7944fa863463a97d", + "timeBetweenParts": 1800n, }, - "status": "Completed", + "status": "Active", }, ] `; From 1c1b3eb5978e9c80a4c22b537733cf5a61e66587 Mon Sep 17 00:00:00 2001 From: Daniel Constantin Date: Tue, 21 Jul 2026 15:43:39 +0300 Subject: [PATCH 4/8] refactor: onProgress + separate graphql client --- packages/composable/README.md | 2 + .../ProgrammaticOrderApi.ts | 86 +++++------ .../src/programmaticOrders/client.ts | 111 -------------- .../src/programmaticOrders/common/parse.ts | 90 ------------ .../src/programmaticOrders/common/types.ts | 14 +- .../src/programmaticOrders/graphql.ts | 136 ++++++++++++++++++ .../src/programmaticOrders/index.ts | 6 +- .../src/programmaticOrders/twap/queries.ts | 113 ++++++--------- .../src/programmaticOrders/twap/types.ts | 10 +- .../composable/tests/GraphqlClient.spec.ts | 88 ++++++++++++ .../tests/ProgrammaticOrderApi.spec.ts | 5 +- 11 files changed, 330 insertions(+), 331 deletions(-) delete mode 100644 packages/composable/src/programmaticOrders/client.ts create mode 100644 packages/composable/src/programmaticOrders/graphql.ts create mode 100644 packages/composable/tests/GraphqlClient.spec.ts diff --git a/packages/composable/README.md b/packages/composable/README.md index 045171105..3dab7dc86 100644 --- a/packages/composable/README.md +++ b/packages/composable/README.md @@ -39,6 +39,8 @@ const twapOrders = await api.getTwapOrders({ Each result preserves both its creation `eventId` and ComposableCoW `hash`. `schedule.numberOfParts` is the number of scheduled parts. `partOrders` contains the corresponding part orders, so its length may be smaller. `executedAmounts` sums those part orders. The schedule's `effectiveStartTime` is the creation block timestamp when the on-chain `t0` was zero. Pass `apiUrl` to the constructor to use another programmatic orders API deployment. +Pass `onProgress` to receive each fully assembled TWAP order after all its available part orders are fetched. The promise returns the complete result. If a later page fails, the promise rejects and previously emitted orders remain provisional. + Source contracts: [conditional-order generator](https://github.com/bleu/cow-programmatic-orders-api/blob/main/src/api/gql-docs/conditional-order-generator.ts), [discrete order](https://github.com/bleu/cow-programmatic-orders-api/blob/main/src/api/gql-docs/discrete-order.ts), [TWAP schedule](https://github.com/bleu/cow-programmatic-orders-api/blob/main/docs/supported-order-types.md#twap-time-weighted-average-price), and the canonical [ComposableCoW TWAP order](https://github.com/cowprotocol/composable-cow/blob/main/src/types/twap/libraries/TWAPOrder.sol#L31-L42). ## Core Components diff --git a/packages/composable/src/programmaticOrders/ProgrammaticOrderApi.ts b/packages/composable/src/programmaticOrders/ProgrammaticOrderApi.ts index b17a1acb6..de9fbe840 100644 --- a/packages/composable/src/programmaticOrders/ProgrammaticOrderApi.ts +++ b/packages/composable/src/programmaticOrders/ProgrammaticOrderApi.ts @@ -1,25 +1,29 @@ import { isEvmChain, isSupportedChain } from '@cowprotocol/sdk-config' -import type { SupportedChainId } from '@cowprotocol/sdk-config' import { getEvmAddressKey, isEvmAddress, log } from '@cowprotocol/sdk-common' -import { ProgrammaticOrdersClient } from './client' import { ProgrammaticOrderApiError, type ProgrammaticOrderApiOptions } from './common/types' +import { GraphqlClient } from './graphql' import { sumExecutedAmounts } from './twap/parse' import { getTwapParents, getTwapPartOrders } from './twap/queries' import type { GetTwapOrdersParams, TwapOrder } from './twap/types' -/** Read-only client for programmatic orders. */ +const DEFAULT_API_URL = 'https://cow-programmatic-order.bleu.blue' + export class ProgrammaticOrderApi { - private readonly client: ProgrammaticOrdersClient + private readonly graphql: GraphqlClient /** * Creates a client backed by the default programmatic orders API. * * @param options - Optional API endpoint configuration. - * @throws {@link ProgrammaticOrderApiError} with kind `invalid-request` when `apiUrl` is invalid. + * @throws {@link ProgrammaticOrderApiError} when `apiUrl` is invalid. */ constructor(options: ProgrammaticOrderApiOptions = {}) { - this.client = new ProgrammaticOrdersClient(options.apiUrl) + try { + this.graphql = new GraphqlClient(options.apiUrl ?? DEFAULT_API_URL) + } catch (cause) { + throw new ProgrammaticOrderApiError('Invalid programmatic orders API URL', { cause }) + } } /** @@ -27,7 +31,8 @@ export class ProgrammaticOrderApi { * * @remarks * Follows every API page. `partOrders` contains part orders; scheduled parts without a part order are not included. - * A failed page rejects the request rather than returning partial results. + * Progress callbacks contain fully assembled TWAP orders. A failed page rejects the request, but TWAP orders emitted + * by earlier callbacks remain provisional. * * @param params - Controlling EOA or Safe address and chain to query. The address must not be a CoWShed proxy. * @returns All TWAP orders and their part orders. @@ -35,46 +40,47 @@ export class ProgrammaticOrderApi { * GraphQL, or response validation fails. */ async getTwapOrders(params: GetTwapOrdersParams): Promise { - const { chainId } = params - const resolvedOwner = validateRequest(params.resolvedOwner, chainId) + const { chainId, onProgress } = params - log(`ProgrammaticOrderApi: fetching TWAP orders for ${resolvedOwner} on chain ${chainId}`) + if (!isEvmAddress(params.resolvedOwner)) { + throw new ProgrammaticOrderApiError(`Invalid EVM owner address: ${params.resolvedOwner}`) + } - const parents = await getTwapParents(this.client, resolvedOwner, chainId) - const partOrdersByParent = await getTwapPartOrders( - this.client, - chainId, - parents.map(({ eventId }) => eventId), - ) + if (!Number.isInteger(chainId) || !isSupportedChain(chainId) || !isEvmChain(chainId)) { + throw new ProgrammaticOrderApiError(`Unsupported EVM chain: ${chainId}`) + } - const orders = parents.map((parent) => { - const partOrders = partOrdersByParent.get(parent.eventId) ?? [] + const resolvedOwner = getEvmAddressKey(params.resolvedOwner) - return { - ...parent, - chainId, - resolvedOwner, - partOrders, - executedAmounts: sumExecutedAmounts(partOrders), - } satisfies TwapOrder - }) + log(`ProgrammaticOrderApi: fetching TWAP orders for ${resolvedOwner} on chain ${chainId}`) - log( - `ProgrammaticOrderApi: fetched ${orders.length} TWAP orders and ${orders.reduce((sum, order) => sum + order.partOrders.length, 0)} part orders`, - ) + try { + const orders: TwapOrder[] = [] + const parents = await getTwapParents(this.graphql, resolvedOwner, chainId) - return orders - } -} + for (const parent of parents) { + const partOrders = await getTwapPartOrders(this.graphql, chainId, parent.eventId) + const order = { + ...parent, + chainId, + resolvedOwner, + partOrders, + executedAmounts: sumExecutedAmounts(partOrders), + } satisfies TwapOrder -function validateRequest(owner: string, chainId: SupportedChainId): string { - if (!isEvmAddress(owner)) { - throw new ProgrammaticOrderApiError('invalid-request', `Invalid EVM owner address: ${owner}`) - } + orders.push(order) + onProgress?.(order) + } - if (!Number.isInteger(chainId) || !isSupportedChain(chainId) || !isEvmChain(chainId)) { - throw new ProgrammaticOrderApiError('invalid-request', `Unsupported EVM chain: ${chainId}`) - } + log( + `ProgrammaticOrderApi: fetched ${orders.length} TWAP orders and ${orders.reduce((sum, order) => sum + order.partOrders.length, 0)} part orders`, + ) - return getEvmAddressKey(owner) + return orders + } catch (cause) { + if (cause instanceof ProgrammaticOrderApiError) throw cause + + throw new ProgrammaticOrderApiError('Failed to fetch TWAP orders', { cause }) + } + } } diff --git a/packages/composable/src/programmaticOrders/client.ts b/packages/composable/src/programmaticOrders/client.ts deleted file mode 100644 index 889683147..000000000 --- a/packages/composable/src/programmaticOrders/client.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { log } from '@cowprotocol/sdk-common' - -import { invalidResponse, parseGraphqlData, type Page } from './common/parse' -import { ProgrammaticOrderApiError } from './common/types' - -const DEFAULT_API_URL = 'https://cow-programmatic-order.bleu.blue' -export const PAGE_SIZE = 1000 - -export class ProgrammaticOrdersClient { - private readonly graphqlUrl: string - - constructor(apiUrl?: string) { - this.graphqlUrl = getGraphqlUrl(apiUrl ?? DEFAULT_API_URL) - } - - async paginate(label: string, getPage: (after: string | null) => Promise>): Promise { - const items: T[] = [] - const cursors = new Set() - let after: string | null = null - let hasNextPage = true - - while (hasNextPage) { - const page = await getPage(after) - items.push(...page.items) - hasNextPage = page.hasNextPage - - if (!hasNextPage) break - if (page.endCursor === null || page.endCursor.length === 0 || cursors.has(page.endCursor)) { - invalidResponse(`${label}.pageInfo contains an invalid pagination cursor`) - } - - cursors.add(page.endCursor) - after = page.endCursor - } - - return items - } - - async query(operation: string, query: string, variables: Record): Promise { - log(`ProgrammaticOrderApi: querying ${operation}`) - - let response: Response - - try { - response = await fetch(this.graphqlUrl, { - method: 'POST', - headers: { - Accept: 'application/json', - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ query, variables }), - }) - } catch (cause) { - throw new ProgrammaticOrderApiError('network', 'Programmatic orders API request failed', undefined, { cause }) - } - - let payload: unknown - - try { - payload = await response.json() - } catch (cause) { - if (!response.ok) { - throw new ProgrammaticOrderApiError( - 'http', - `Programmatic orders API returned HTTP ${response.status}`, - response.status, - { cause }, - ) - } - - throw new ProgrammaticOrderApiError( - 'invalid-response', - 'Programmatic orders API returned invalid JSON', - undefined, - { cause }, - ) - } - - if (!response.ok) { - throw new ProgrammaticOrderApiError( - 'http', - `Programmatic orders API returned HTTP ${response.status}`, - response.status, - ) - } - - return parseGraphqlData(payload) - } -} - -function getGraphqlUrl(apiUrl: string): string { - if (typeof apiUrl !== 'string') { - throw new ProgrammaticOrderApiError('invalid-request', 'Programmatic orders API URL must be a string') - } - - try { - const url = new URL(apiUrl) - - if (url.protocol !== 'http:' && url.protocol !== 'https:') throw new Error('Unsupported protocol') - - const pathname = url.pathname.replace(/\/+$/, '') - - url.pathname = pathname.endsWith('/graphql') ? pathname : `${pathname}/graphql` - - return url.toString() - } catch (cause) { - throw new ProgrammaticOrderApiError('invalid-request', 'Invalid programmatic orders API URL', undefined, { - cause, - }) - } -} diff --git a/packages/composable/src/programmaticOrders/common/parse.ts b/packages/composable/src/programmaticOrders/common/parse.ts index b1e2904e3..6378bd02e 100644 --- a/packages/composable/src/programmaticOrders/common/parse.ts +++ b/packages/composable/src/programmaticOrders/common/parse.ts @@ -1,8 +1,6 @@ import { getEvmAddressKey, isEvmAddress } from '@cowprotocol/sdk-common' import * as v from 'valibot' -import { ProgrammaticOrderApiError } from './types' - const MAX_DATE_SECONDS = 8_640_000_000_000n const MAX_UINT32 = 4_294_967_295 const MAX_UINT256 = (1n << 256n) - 1n @@ -11,16 +9,6 @@ const BYTES_32_PATTERN = /^0x[0-9a-fA-F]{64}$/ const ORDER_UID_PATTERN = /^0x[0-9a-fA-F]{112}$/ const UNSIGNED_INTEGER_PATTERN = /^\d+$/ -const RECORD_SCHEMA = v.custom>( - (value) => typeof value === 'object' && value !== null && !Array.isArray(value), - 'must be an object', -) -const GRAPHQL_ERRORS_SCHEMA = v.array(v.object({ message: v.string() }), 'must be an array') -const PAGE_INFO_SCHEMA = v.object({ - hasNextPage: v.boolean(), - endCursor: v.nullable(v.string()), -}) - export const ADDRESS_SCHEMA = v.pipe( v.string(), v.check((address) => isEvmAddress(address), 'must be an EVM address'), @@ -48,81 +36,3 @@ export const TIMESTAMP_SCHEMA = v.pipe( v.transform((value) => Number(value)), ) export const PROGRAMMATIC_ORDER_STATUS_SCHEMA = v.picklist(['Active', 'Cancelled', 'Completed']) - -export interface Page { - items: T[] - hasNextPage: boolean - endCursor: string | null -} - -export function parseGraphqlData(payload: unknown): unknown { - const envelope = parseSchema(RECORD_SCHEMA, payload, 'response') - const errors = envelope.errors - - if (errors !== undefined && errors !== null) { - const parsedErrors = parseSchema(GRAPHQL_ERRORS_SCHEMA, errors, 'response.errors') - - if (parsedErrors.length > 0) { - throw new ProgrammaticOrderApiError( - 'graphql', - `Programmatic orders API error: ${parsedErrors.map(({ message }) => message).join('; ')}`, - ) - } - } - - if (!('data' in envelope)) invalidResponse('response.data is missing') - - return envelope.data -} - -export function parsePage( - data: unknown, - field: string, - itemSchema: TItem, -): Page> { - const root = parseSchema(RECORD_SCHEMA, data, 'response.data') - const page = parseSchema( - v.object({ - items: v.array(itemSchema), - pageInfo: PAGE_INFO_SCHEMA, - }), - root[field], - `response.data.${field}`, - ) - - return { - items: page.items, - hasNextPage: page.pageInfo.hasNextPage, - endCursor: page.pageInfo.endCursor, - } -} - -function parseSchema( - schema: TSchema, - input: unknown, - path: string, -): v.InferOutput { - const result = v.safeParse(schema, input, { abortEarly: true }) - - if (!result.success) { - const issue = result.issues[0] - - invalidResponse(`${formatIssuePath(path, issue)} ${issue.message}`) - } - - return result.output -} - -function formatIssuePath(path: string, issue: v.BaseIssue): string { - let result = path - - for (const item of issue.path ?? []) { - result += typeof item.key === 'number' ? `[${item.key}]` : `.${String(item.key)}` - } - - return result -} - -export function invalidResponse(message: string): never { - throw new ProgrammaticOrderApiError('invalid-response', `Invalid programmatic orders API response: ${message}`) -} diff --git a/packages/composable/src/programmaticOrders/common/types.ts b/packages/composable/src/programmaticOrders/common/types.ts index d779bdcdc..64afc1e9a 100644 --- a/packages/composable/src/programmaticOrders/common/types.ts +++ b/packages/composable/src/programmaticOrders/common/types.ts @@ -1,4 +1,3 @@ -/** Configuration for the programmatic orders API client. */ export interface ProgrammaticOrderApiOptions { /** Programmatic orders API base URL or full GraphQL URL. */ apiUrl?: string @@ -11,27 +10,16 @@ export interface ProgrammaticOrderApiOptions { */ export type ProgrammaticOrderStatus = 'Active' | 'Cancelled' | 'Completed' -/** Stable category describing why a programmatic orders API call failed. */ -export type ProgrammaticOrderApiErrorKind = 'invalid-request' | 'network' | 'http' | 'graphql' | 'invalid-response' - -/** Error thrown by programmatic orders API operations. */ export class ProgrammaticOrderApiError extends Error { readonly name = 'ProgrammaticOrderApiError' /** * Creates a programmatic orders API error. * - * @param kind - Stable failure category suitable for programmatic handling. * @param message - Human-readable failure description. - * @param status - HTTP response status for `http` failures. * @param options - Standard error options, including the original cause. */ - constructor( - readonly kind: ProgrammaticOrderApiErrorKind, - message: string, - readonly status?: number, - options?: ErrorOptions, - ) { + constructor(message: string, options?: ErrorOptions) { super(message, options) } } diff --git a/packages/composable/src/programmaticOrders/graphql.ts b/packages/composable/src/programmaticOrders/graphql.ts new file mode 100644 index 000000000..c84981f1c --- /dev/null +++ b/packages/composable/src/programmaticOrders/graphql.ts @@ -0,0 +1,136 @@ +import * as v from 'valibot' + +const GRAPHQL_ENVELOPE_SCHEMA = v.object({ + data: v.optional(v.unknown()), + errors: v.optional(v.nullable(v.array(v.object({ message: v.string() })))), +}) + +interface GraphqlPaginatedOperation { + query: string + page: string + variables: (after: string | null) => Record + itemSchema: TItemSchema +} + +export class GraphqlClientError extends Error { + readonly name = 'GraphqlClientError' +} + +export class GraphqlClient { + private readonly url: string + + constructor(url: string) { + try { + const parsedUrl = new URL(url) + + if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') throw new Error('Unsupported protocol') + + const pathname = parsedUrl.pathname.replace(/\/+$/, '') + + parsedUrl.pathname = pathname.endsWith('/graphql') ? pathname : `${pathname}/graphql` + this.url = parsedUrl.toString() + } catch (cause) { + throw new GraphqlClientError('Invalid GraphQL endpoint', { cause }) + } + } + + async query(query: string, variables: Record): Promise { + let response: Response + + try { + response = await fetch(this.url, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ query, variables }), + }) + } catch (cause) { + throw new GraphqlClientError('GraphQL request failed', { cause }) + } + + let payload: unknown + + try { + payload = await response.json() + } catch (cause) { + if (!response.ok) { + throw new GraphqlClientError(`GraphQL request returned HTTP ${response.status}`, { + cause, + }) + } + + throw new GraphqlClientError('GraphQL response is not valid JSON', { cause }) + } + + if (!response.ok) { + throw new GraphqlClientError(`GraphQL request returned HTTP ${response.status}`) + } + + const result = v.safeParse(GRAPHQL_ENVELOPE_SCHEMA, payload, { abortEarly: true }) + + if (!result.success) { + throw new GraphqlClientError('Invalid GraphQL response') + } + + const { data, errors } = result.output + + if (errors?.length) { + throw new GraphqlClientError(errors.map(({ message }) => message).join('; ')) + } + + if (data === undefined) throw new GraphqlClientError('GraphQL response.data is missing') + + return data + } + + async paginate( + operation: GraphqlPaginatedOperation, + ): Promise[]> { + const items: v.InferOutput[] = [] + const cursors = new Set() + let after: string | null = null + let hasNextPage = true + + while (hasNextPage) { + const data = await this.query(operation.query, operation.variables(after)) + const result = v.safeParse( + v.object({ + [operation.page]: v.object({ + items: v.array(operation.itemSchema), + pageInfo: v.object({ + hasNextPage: v.boolean(), + endCursor: v.nullable(v.string()), + }), + }), + }), + data, + { abortEarly: true }, + ) + + if (!result.success) { + throw new GraphqlClientError(`Invalid GraphQL page: ${operation.page}`) + } + + const page = result.output[operation.page] + + if (page === undefined) throw new GraphqlClientError(`response.data.${operation.page} is missing`) + + const { endCursor } = page.pageInfo + + hasNextPage = page.pageInfo.hasNextPage + items.push(...page.items) + + if (!hasNextPage) break + if (endCursor === null || endCursor.length === 0 || cursors.has(endCursor)) { + throw new GraphqlClientError('GraphQL page contains an invalid pagination cursor') + } + + cursors.add(endCursor) + after = endCursor + } + + return items + } +} diff --git a/packages/composable/src/programmaticOrders/index.ts b/packages/composable/src/programmaticOrders/index.ts index bb729b435..fbcc080fb 100644 --- a/packages/composable/src/programmaticOrders/index.ts +++ b/packages/composable/src/programmaticOrders/index.ts @@ -1,8 +1,4 @@ export { ProgrammaticOrderApi } from './ProgrammaticOrderApi' export { ProgrammaticOrderApiError } from './common/types' -export type { - ProgrammaticOrderApiErrorKind, - ProgrammaticOrderApiOptions, - ProgrammaticOrderStatus, -} from './common/types' +export type { ProgrammaticOrderApiOptions, ProgrammaticOrderStatus } from './common/types' export type { GetTwapOrdersParams, TwapOrder, TwapPartOrder, TwapPartOrderStatus, TwapSchedule } from './twap/types' diff --git a/packages/composable/src/programmaticOrders/twap/queries.ts b/packages/composable/src/programmaticOrders/twap/queries.ts index 81ef3de2d..51328164d 100644 --- a/packages/composable/src/programmaticOrders/twap/queries.ts +++ b/packages/composable/src/programmaticOrders/twap/queries.ts @@ -1,12 +1,11 @@ import type { SupportedChainId } from '@cowprotocol/sdk-config' import { areAddressesEqual } from '@cowprotocol/sdk-common' -import { PAGE_SIZE, type ProgrammaticOrdersClient } from '../client' -import { invalidResponse, parsePage } from '../common/parse' +import type { GraphqlClient } from '../graphql' import { TWAP_PARENT_SCHEMA, TWAP_PART_ORDER_SCHEMA } from './parse' -import type { TwapParent, TwapPartOrder, TwapPartOrderRecord } from './types' +import type { TwapParent, TwapPartOrder } from './types' -const PART_PARENT_BATCH_SIZE = 100 +const PAGE_SIZE = 1000 const TWAP_ORDERS_QUERY = ` query TwapOrders( @@ -45,8 +44,15 @@ const TWAP_ORDERS_QUERY = ` ` const TWAP_PART_ORDERS_QUERY = ` - query TwapPartOrders($where: discreteOrderFilter!, $after: String, $limit: Int!) { - partOrders: discreteOrders(where: $where, after: $after, limit: $limit) { + query TwapPartOrders($chainId: Int!, $parentEventId: String!, $after: String, $limit: Int!) { + partOrders: discreteOrders( + where: { + chainId: $chainId + conditionalOrderGeneratorId: $parentEventId + } + after: $after + limit: $limit + ) { items { orderUid chainId @@ -69,87 +75,62 @@ const TWAP_PART_ORDERS_QUERY = ` ` export async function getTwapParents( - client: ProgrammaticOrdersClient, + client: GraphqlClient, resolvedOwner: string, chainId: SupportedChainId, ): Promise { - const parents = await client.paginate('TWAP orders', async (after) => { - const data = await client.query('TWAP orders', TWAP_ORDERS_QUERY, { + const parents = await client.paginate({ + query: TWAP_ORDERS_QUERY, + page: 'twapOrders', + variables: (after) => ({ resolvedOwner, chainId, after, limit: PAGE_SIZE, - }) - - return parsePage(data, 'twapOrders', TWAP_PARENT_SCHEMA) + }), + itemSchema: TWAP_PARENT_SCHEMA, }) - const uniqueParents = new Map() for (const parent of parents) { - const matchesResolvedOwner = parent.resolvedOwner !== null && areAddressesEqual(parent.resolvedOwner, resolvedOwner) - - if (parent.chainId !== chainId || !matchesResolvedOwner) { - invalidResponse('twapOrders.items contains a row for another owner or chain') - } - - const existing = uniqueParents.get(parent.eventId) - - if (existing && existing.hash !== parent.hash) { - invalidResponse(`twapOrders.items contains conflicting rows for event ${parent.eventId}`) + if ( + parent.chainId !== chainId || + parent.resolvedOwner === null || + !areAddressesEqual(parent.resolvedOwner, resolvedOwner) + ) { + throw new Error('Programmatic orders API returned a TWAP order with an unexpected owner or chain') } - - if (!existing) uniqueParents.set(parent.eventId, parent) } - return [...uniqueParents.values()] + return parents } export async function getTwapPartOrders( - client: ProgrammaticOrdersClient, + client: GraphqlClient, chainId: SupportedChainId, - parentEventIds: string[], -): Promise> { - const partOrdersByUid = new Map() - - for (let index = 0; index < parentEventIds.length; index += PART_PARENT_BATCH_SIZE) { - const batch = parentEventIds.slice(index, index + PART_PARENT_BATCH_SIZE) - const parentIds = new Set(batch) - const partOrders = await client.paginate('TWAP part orders', async (after) => { - const data = await client.query('TWAP part orders', TWAP_PART_ORDERS_QUERY, { - where: { - chainId, - OR: batch.map((parentEventId) => ({ conditionalOrderGeneratorId: parentEventId })), - }, - after, - limit: PAGE_SIZE, - }) - - return parsePage(data, 'partOrders', TWAP_PART_ORDER_SCHEMA) - }) - - for (const partOrder of partOrders) { - if (partOrder.chainId !== chainId || !parentIds.has(partOrder.parentEventId)) { - invalidResponse('partOrders.items contains a row for another TWAP order or chain') - } - - const existing = partOrdersByUid.get(partOrder.orderUid) + parentEventId: string, +): Promise { + const records = await client.paginate({ + query: TWAP_PART_ORDERS_QUERY, + page: 'partOrders', + variables: (after) => ({ + chainId, + parentEventId, + after, + limit: PAGE_SIZE, + }), + itemSchema: TWAP_PART_ORDER_SCHEMA, + }) + const partOrders: TwapPartOrder[] = [] - if (existing && existing.parentEventId !== partOrder.parentEventId) { - invalidResponse(`partOrders.items contains conflicting rows for order ${partOrder.orderUid}`) - } + for (const record of records) { + const { chainId: recordChainId, parentEventId: recordParentEventId, ...partOrder } = record - if (!existing) partOrdersByUid.set(partOrder.orderUid, partOrder) + if (recordChainId !== chainId || recordParentEventId !== parentEventId) { + throw new Error('Programmatic orders API returned a part order for an unexpected TWAP order or chain') } - } - - const result = new Map() - - for (const { chainId: _chainId, parentEventId, ...partOrder } of partOrdersByUid.values()) { - const parentPartOrders = result.get(parentEventId) - if (parentPartOrders) parentPartOrders.push(partOrder) - else result.set(parentEventId, [partOrder]) + partOrders.push(partOrder) } - return result + return partOrders } diff --git a/packages/composable/src/programmaticOrders/twap/types.ts b/packages/composable/src/programmaticOrders/twap/types.ts index 4c84f9a7c..23afb856c 100644 --- a/packages/composable/src/programmaticOrders/twap/types.ts +++ b/packages/composable/src/programmaticOrders/twap/types.ts @@ -9,6 +9,11 @@ export interface GetTwapOrdersParams { resolvedOwner: string /** Supported EVM chain containing the orders. */ chainId: SupportedChainId + /** + * Receives each fully assembled TWAP order after all its available part orders are fetched. + * Emitted orders are provisional when a later page fails; the promise returns the complete result. + */ + onProgress?: (order: TwapOrder) => void } /** @@ -107,8 +112,3 @@ export interface TwapParent { createdAt: number schedule: TwapSchedule } - -export interface TwapPartOrderRecord extends TwapPartOrder { - chainId: number - parentEventId: string -} diff --git a/packages/composable/tests/GraphqlClient.spec.ts b/packages/composable/tests/GraphqlClient.spec.ts new file mode 100644 index 000000000..d7c724fb8 --- /dev/null +++ b/packages/composable/tests/GraphqlClient.spec.ts @@ -0,0 +1,88 @@ +import * as v from 'valibot' + +import { GraphqlClient, GraphqlClientError } from '../src/programmaticOrders/graphql' + +describe('GraphqlClient', () => { + afterEach(() => jest.restoreAllMocks()) + + it('iterates parsed pages', async () => { + const fetchMock = jest + .spyOn(globalThis, 'fetch') + .mockResolvedValueOnce(pageResponse(['first'], true, 'next')) + .mockResolvedValueOnce(pageResponse(['second'], false, null)) + const client = new GraphqlClient('https://example.com/api') + + const items = await client.paginate({ + query: 'query Nodes($after: String) { nodes(after: $after) { items pageInfo } }', + page: 'nodes', + variables: (after) => ({ after }), + itemSchema: v.string(), + }) + + expect(items).toEqual(['first', 'second']) + expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([ + 'https://example.com/api/graphql', + 'https://example.com/api/graphql', + ]) + expect(fetchMock.mock.calls.map(([, init]) => JSON.parse(String(init?.body)).variables.after)).toEqual([ + null, + 'next', + ]) + }) + + it('rejects invalid envelopes, pages, and cursors', async () => { + const fetchMock = jest.spyOn(globalThis, 'fetch') + const client = new GraphqlClient('https://example.com/graphql') + + fetchMock.mockResolvedValueOnce(jsonResponse({ value: 1 })) + + await expect(client.query('query Value { value }', {})).rejects.toThrow('GraphQL response.data is missing') + + fetchMock.mockResolvedValueOnce(pageResponse([1], false, null)) + + await expect( + client.paginate({ + query: 'query Nodes { nodes }', + page: 'nodes', + variables: () => ({}), + itemSchema: v.string(), + }), + ).rejects.toThrow('Invalid GraphQL page: nodes') + + fetchMock + .mockResolvedValueOnce(pageResponse(['first'], true, 'same')) + .mockResolvedValueOnce(pageResponse(['second'], true, 'same')) + + await expect( + client.paginate({ + query: 'query Nodes { nodes }', + page: 'nodes', + variables: () => ({}), + itemSchema: v.string(), + }), + ).rejects.toThrow('GraphQL page contains an invalid pagination cursor') + }) + + it('rejects invalid endpoints', () => { + expect(() => new GraphqlClient('not a URL')).toThrow(GraphqlClientError) + }) +}) + +function pageResponse(items: unknown[], hasNextPage: boolean, endCursor: string | null): Promise { + return Promise.resolve( + jsonResponse({ + data: { + nodes: { + items, + pageInfo: { hasNextPage, endCursor }, + }, + }, + }), + ) +} + +function jsonResponse(payload: unknown): Response { + return new Response(JSON.stringify(payload), { + headers: { 'Content-Type': 'application/json' }, + }) +} diff --git a/packages/composable/tests/ProgrammaticOrderApi.spec.ts b/packages/composable/tests/ProgrammaticOrderApi.spec.ts index 9bdfaa003..5a5e86420 100644 --- a/packages/composable/tests/ProgrammaticOrderApi.spec.ts +++ b/packages/composable/tests/ProgrammaticOrderApi.spec.ts @@ -1,17 +1,20 @@ import { SupportedChainId } from '@cowprotocol/sdk-config' -import { ProgrammaticOrderApi } from '../src' +import { ProgrammaticOrderApi, type TwapOrder } from '../src' const EOA = '0x016f34D4f2578c3e9DFfC3f2b811Ba30c0c9e7f3' const SAFE = '0xaA248D5328c7D781a96D93d7D013bcF393157bB4' describe('ProgrammaticOrderApi', () => { it('lists all known EOA TWAPs from the live programmatic orders API', async () => { + const progress: TwapOrder[] = [] const orders = await new ProgrammaticOrderApi().getTwapOrders({ resolvedOwner: EOA, chainId: SupportedChainId.GNOSIS_CHAIN, + onProgress: (order) => progress.push(order), }) + expect(progress).toEqual(orders) expect(orders).toMatchSnapshot() }, 30_000) From 1a2ed62df570d1c9eb60d7a2733ef03344267bf3 Mon Sep 17 00:00:00 2001 From: Daniel Constantin Date: Tue, 21 Jul 2026 15:44:45 +0300 Subject: [PATCH 5/8] refactor: folder structure --- .../programmaticOrders/ProgrammaticOrderApi.ts | 17 ++++++++++++----- .../composable/src/programmaticOrders/index.ts | 6 +++--- .../{common/parse.ts => schemas.ts} | 0 .../{twap/queries.ts => twap-queries.ts} | 6 +++--- .../{twap/parse.ts => twap-schemas.ts} | 16 ++-------------- .../{twap/types.ts => twap-types.ts} | 2 +- .../programmaticOrders/{common => }/types.ts | 0 7 files changed, 21 insertions(+), 26 deletions(-) rename packages/composable/src/programmaticOrders/{common/parse.ts => schemas.ts} (100%) rename packages/composable/src/programmaticOrders/{twap/queries.ts => twap-queries.ts} (94%) rename packages/composable/src/programmaticOrders/{twap/parse.ts => twap-schemas.ts} (83%) rename packages/composable/src/programmaticOrders/{twap/types.ts => twap-types.ts} (98%) rename packages/composable/src/programmaticOrders/{common => }/types.ts (100%) diff --git a/packages/composable/src/programmaticOrders/ProgrammaticOrderApi.ts b/packages/composable/src/programmaticOrders/ProgrammaticOrderApi.ts index de9fbe840..b7a3673a7 100644 --- a/packages/composable/src/programmaticOrders/ProgrammaticOrderApi.ts +++ b/packages/composable/src/programmaticOrders/ProgrammaticOrderApi.ts @@ -1,11 +1,10 @@ import { isEvmChain, isSupportedChain } from '@cowprotocol/sdk-config' import { getEvmAddressKey, isEvmAddress, log } from '@cowprotocol/sdk-common' -import { ProgrammaticOrderApiError, type ProgrammaticOrderApiOptions } from './common/types' import { GraphqlClient } from './graphql' -import { sumExecutedAmounts } from './twap/parse' -import { getTwapParents, getTwapPartOrders } from './twap/queries' -import type { GetTwapOrdersParams, TwapOrder } from './twap/types' +import { getTwapParents, getTwapPartOrders } from './twap-queries' +import type { GetTwapOrdersParams, TwapOrder } from './twap-types' +import { ProgrammaticOrderApiError, type ProgrammaticOrderApiOptions } from './types' const DEFAULT_API_URL = 'https://cow-programmatic-order.bleu.blue' @@ -60,12 +59,20 @@ export class ProgrammaticOrderApi { for (const parent of parents) { const partOrders = await getTwapPartOrders(this.graphql, chainId, parent.eventId) + let executedSellAmount = 0n + let executedBuyAmount = 0n + + for (const partOrder of partOrders) { + executedSellAmount += partOrder.executedSellAmount ?? 0n + executedBuyAmount += partOrder.executedBuyAmount ?? 0n + } + const order = { ...parent, chainId, resolvedOwner, partOrders, - executedAmounts: sumExecutedAmounts(partOrders), + executedAmounts: { executedSellAmount, executedBuyAmount }, } satisfies TwapOrder orders.push(order) diff --git a/packages/composable/src/programmaticOrders/index.ts b/packages/composable/src/programmaticOrders/index.ts index fbcc080fb..d05ebe59b 100644 --- a/packages/composable/src/programmaticOrders/index.ts +++ b/packages/composable/src/programmaticOrders/index.ts @@ -1,4 +1,4 @@ export { ProgrammaticOrderApi } from './ProgrammaticOrderApi' -export { ProgrammaticOrderApiError } from './common/types' -export type { ProgrammaticOrderApiOptions, ProgrammaticOrderStatus } from './common/types' -export type { GetTwapOrdersParams, TwapOrder, TwapPartOrder, TwapPartOrderStatus, TwapSchedule } from './twap/types' +export { ProgrammaticOrderApiError } from './types' +export type { ProgrammaticOrderApiOptions, ProgrammaticOrderStatus } from './types' +export type { GetTwapOrdersParams, TwapOrder, TwapPartOrder, TwapPartOrderStatus, TwapSchedule } from './twap-types' diff --git a/packages/composable/src/programmaticOrders/common/parse.ts b/packages/composable/src/programmaticOrders/schemas.ts similarity index 100% rename from packages/composable/src/programmaticOrders/common/parse.ts rename to packages/composable/src/programmaticOrders/schemas.ts diff --git a/packages/composable/src/programmaticOrders/twap/queries.ts b/packages/composable/src/programmaticOrders/twap-queries.ts similarity index 94% rename from packages/composable/src/programmaticOrders/twap/queries.ts rename to packages/composable/src/programmaticOrders/twap-queries.ts index 51328164d..dead4d223 100644 --- a/packages/composable/src/programmaticOrders/twap/queries.ts +++ b/packages/composable/src/programmaticOrders/twap-queries.ts @@ -1,9 +1,9 @@ import type { SupportedChainId } from '@cowprotocol/sdk-config' import { areAddressesEqual } from '@cowprotocol/sdk-common' -import type { GraphqlClient } from '../graphql' -import { TWAP_PARENT_SCHEMA, TWAP_PART_ORDER_SCHEMA } from './parse' -import type { TwapParent, TwapPartOrder } from './types' +import type { GraphqlClient } from './graphql' +import { TWAP_PARENT_SCHEMA, TWAP_PART_ORDER_SCHEMA } from './twap-schemas' +import type { TwapParent, TwapPartOrder } from './twap-types' const PAGE_SIZE = 1000 diff --git a/packages/composable/src/programmaticOrders/twap/parse.ts b/packages/composable/src/programmaticOrders/twap-schemas.ts similarity index 83% rename from packages/composable/src/programmaticOrders/twap/parse.ts rename to packages/composable/src/programmaticOrders/twap-schemas.ts index 3258c50f4..4f593a2a7 100644 --- a/packages/composable/src/programmaticOrders/twap/parse.ts +++ b/packages/composable/src/programmaticOrders/twap-schemas.ts @@ -11,8 +11,8 @@ import { TIMESTAMP_SCHEMA, UINT32_SCHEMA, UINT256_SCHEMA, -} from '../common/parse' -import type { TwapExecutedAmounts, TwapParent, TwapPartOrder } from './types' +} from './schemas' +import type { TwapParent } from './twap-types' /** @see https://github.com/bleu/cow-programmatic-orders-api/blob/main/docs/supported-order-types.md#twap-time-weighted-average-price */ const TWAP_SCHEDULE_SCHEMA = v.object({ @@ -72,15 +72,3 @@ export const TWAP_PART_ORDER_SCHEMA = v.object({ executedSellAmount: v.nullable(UINT256_SCHEMA), executedBuyAmount: v.nullable(UINT256_SCHEMA), }) - -export function sumExecutedAmounts(partOrders: TwapPartOrder[]): TwapExecutedAmounts { - let executedSellAmount = 0n - let executedBuyAmount = 0n - - for (const partOrder of partOrders) { - executedSellAmount += partOrder.executedSellAmount ?? 0n - executedBuyAmount += partOrder.executedBuyAmount ?? 0n - } - - return { executedSellAmount, executedBuyAmount } -} diff --git a/packages/composable/src/programmaticOrders/twap/types.ts b/packages/composable/src/programmaticOrders/twap-types.ts similarity index 98% rename from packages/composable/src/programmaticOrders/twap/types.ts rename to packages/composable/src/programmaticOrders/twap-types.ts index 23afb856c..57f71849d 100644 --- a/packages/composable/src/programmaticOrders/twap/types.ts +++ b/packages/composable/src/programmaticOrders/twap-types.ts @@ -1,7 +1,7 @@ import type { SupportedChainId } from '@cowprotocol/sdk-config' import type { OrderStatus } from '@cowprotocol/sdk-order-book' -import type { ProgrammaticOrderStatus } from '../common/types' +import type { ProgrammaticOrderStatus } from './types' /** Input for querying TWAP orders. */ export interface GetTwapOrdersParams { diff --git a/packages/composable/src/programmaticOrders/common/types.ts b/packages/composable/src/programmaticOrders/types.ts similarity index 100% rename from packages/composable/src/programmaticOrders/common/types.ts rename to packages/composable/src/programmaticOrders/types.ts From ae8936f61c9e20f2d5eee3e78612945ae8eb54e6 Mon Sep 17 00:00:00 2001 From: Daniel Constantin Date: Mon, 27 Jul 2026 10:38:52 +0300 Subject: [PATCH 6/8] refactor: decouple parts from twap order --- packages/composable/README.md | 31 +- .../ProgrammaticOrderApi.ts | 145 +++-- .../src/programmaticOrders/graphql.ts | 70 +-- .../src/programmaticOrders/index.ts | 17 +- .../src/programmaticOrders/schemas.ts | 21 + .../src/programmaticOrders/twap-queries.ts | 109 +--- .../src/programmaticOrders/twap-schemas.ts | 73 ++- .../src/programmaticOrders/twap-types.ts | 53 +- .../src/programmaticOrders/types.ts | 28 +- .../src/programmaticOrders/validation.ts | 13 + .../composable/tests/GraphqlClient.spec.ts | 88 --- .../tests/ProgrammaticOrderApi.spec.ts | 234 +++++++- .../ProgrammaticOrderApi.spec.ts.snap | 531 +++++++----------- packages/composable/tests/graphql.spec.ts | 77 +++ 14 files changed, 791 insertions(+), 699 deletions(-) create mode 100644 packages/composable/src/programmaticOrders/validation.ts delete mode 100644 packages/composable/tests/GraphqlClient.spec.ts create mode 100644 packages/composable/tests/graphql.spec.ts diff --git a/packages/composable/README.md b/packages/composable/README.md index 3dab7dc86..8d0afa8f5 100644 --- a/packages/composable/README.md +++ b/packages/composable/README.md @@ -24,24 +24,29 @@ yarn add @cowprotocol/sdk-composable ## Read TWAP history -`ProgrammaticOrderApi` lists TWAP orders and their part orders for a controlling EOA or Safe. - ```typescript import { ProgrammaticOrderApi } from '@cowprotocol/sdk-composable' import { SupportedChainId } from '@cowprotocol/sdk-config' const api = new ProgrammaticOrderApi() -const twapOrders = await api.getTwapOrders({ +const { items: twaps } = await api.getTwapOrders({ resolvedOwner: '0x...', chainId: SupportedChainId.GNOSIS_CHAIN, }) +const { items: parts } = await api.getTwapPartOrders( + { + eventId: twaps[0].eventId, + chainId: SupportedChainId.GNOSIS_CHAIN, + }, + { + offset: 0, + limit: 10, + direction: 'desc', + }, +) ``` -Each result preserves both its creation `eventId` and ComposableCoW `hash`. `schedule.numberOfParts` is the number of scheduled parts. `partOrders` contains the corresponding part orders, so its length may be smaller. `executedAmounts` sums those part orders. The schedule's `effectiveStartTime` is the creation block timestamp when the on-chain `t0` was zero. Pass `apiUrl` to the constructor to use another programmatic orders API deployment. - -Pass `onProgress` to receive each fully assembled TWAP order after all its available part orders are fetched. The promise returns the complete result. If a later page fails, the promise rejects and previously emitted orders remain provisional. - -Source contracts: [conditional-order generator](https://github.com/bleu/cow-programmatic-orders-api/blob/main/src/api/gql-docs/conditional-order-generator.ts), [discrete order](https://github.com/bleu/cow-programmatic-orders-api/blob/main/src/api/gql-docs/discrete-order.ts), [TWAP schedule](https://github.com/bleu/cow-programmatic-orders-api/blob/main/docs/supported-order-types.md#twap-time-weighted-average-price), and the canonical [ComposableCoW TWAP order](https://github.com/cowprotocol/composable-cow/blob/main/src/types/twap/libraries/TWAPOrder.sol#L31-L42). +Both methods return `QueryPage` and accept optional `QueryOptions` (`offset`, `limit`, `direction`). Parent results include execution totals and indexed part counts; pass the returned `eventId` to fetch parts. ## Core Components @@ -94,7 +99,7 @@ const adapter = new EthersV6Adapter({ provider, signer: wallet }) // Create a conditional order factory const registry = { twap: TWAPOrderFactory, - dca: DCAOrderFactory, + dca: DCAOrderFactory, // WIP: not implemented // ... other order types } @@ -138,7 +143,9 @@ const twapOrder = new TWAPOrder({ }) ``` -### DCA (Dollar Cost Averaging) +### DCA (Dollar Cost Averaging) — WIP + +> `DCAOrder` is not implemented or exported yet. Regularly buy or sell assets at predetermined intervals: @@ -153,7 +160,9 @@ const dcaOrder = new DCAOrder({ }) ``` -### Stop-Loss Orders +### Stop-Loss Orders — WIP + +> `StopLossOrder` is not implemented or exported yet. Automatically sell when price drops below threshold: diff --git a/packages/composable/src/programmaticOrders/ProgrammaticOrderApi.ts b/packages/composable/src/programmaticOrders/ProgrammaticOrderApi.ts index b7a3673a7..829608aec 100644 --- a/packages/composable/src/programmaticOrders/ProgrammaticOrderApi.ts +++ b/packages/composable/src/programmaticOrders/ProgrammaticOrderApi.ts @@ -1,20 +1,34 @@ -import { isEvmChain, isSupportedChain } from '@cowprotocol/sdk-config' -import { getEvmAddressKey, isEvmAddress, log } from '@cowprotocol/sdk-common' - import { GraphqlClient } from './graphql' -import { getTwapParents, getTwapPartOrders } from './twap-queries' -import type { GetTwapOrdersParams, TwapOrder } from './twap-types' -import { ProgrammaticOrderApiError, type ProgrammaticOrderApiOptions } from './types' +import { QUERY_OPTIONS_SCHEMA } from './schemas' +import { TWAP_ORDERS_QUERY, TWAP_PART_ORDERS_QUERY } from './twap-queries' +import { + GET_TWAP_ORDERS_PARAMS_SCHEMA, + GET_TWAP_PART_ORDERS_PARAMS_SCHEMA, + TWAP_PARENT_SCHEMA, + TWAP_PART_ORDER_SCHEMA, +} from './twap-schemas' +import type { GetTwapOrdersParams, GetTwapPartOrdersParams, TwapOrder, TwapPartOrder } from './twap-types' +import { + ProgrammaticOrderApiError, + type ProgrammaticOrderApiOptions, + type QueryDirection, + type QueryOptions, + type QueryPage, +} from './types' +import { parseInput } from './validation' const DEFAULT_API_URL = 'https://cow-programmatic-order.bleu.blue' +const DEFAULT_PAGE_LIMIT = 100 +const DEFAULT_PAGE_OFFSET = 0 +const DEFAULT_QUERY_DIRECTION: QueryDirection = 'desc' export class ProgrammaticOrderApi { private readonly graphql: GraphqlClient /** - * Creates a client backed by the default programmatic orders API. + * Creates a client that uses the default programmatic orders API. * - * @param options - Optional API endpoint configuration. + * @param options - API endpoint settings. * @throws {@link ProgrammaticOrderApiError} when `apiUrl` is invalid. */ constructor(options: ProgrammaticOrderApiOptions = {}) { @@ -26,68 +40,79 @@ export class ProgrammaticOrderApi { } /** - * Returns all TWAP orders and their part orders for a canonical owner on a supported EVM chain. + * Returns one page of TWAP orders created by an EOA or Safe. * - * @remarks - * Follows every API page. `partOrders` contains part orders; scheduled parts without a part order are not included. - * Progress callbacks contain fully assembled TWAP orders. A failed page rejects the request, but TWAP orders emitted - * by earlier callbacks remain provisional. + * Results are sorted by creation time, newest first by default. Use {@link getTwapPartOrders} to fetch part orders. * - * @param params - Controlling EOA or Safe address and chain to query. The address must not be a CoWShed proxy. - * @returns All TWAP orders and their part orders. - * @throws {@link ProgrammaticOrderApiError} when input validation, the request, - * GraphQL, or response validation fails. + * @param params - EOA or Safe address and chain. Do not pass a CoWShed proxy address. + * @param options - Pagination and sort direction. + * @returns The requested TWAP orders and the total number found. + * @throws {@link ProgrammaticOrderApiError} when the input is invalid or the request fails. */ - async getTwapOrders(params: GetTwapOrdersParams): Promise { - const { chainId, onProgress } = params - - if (!isEvmAddress(params.resolvedOwner)) { - throw new ProgrammaticOrderApiError(`Invalid EVM owner address: ${params.resolvedOwner}`) - } + async getTwapOrders(params: GetTwapOrdersParams, options: QueryOptions = {}): Promise> { + const { chainId, resolvedOwner } = parseInput(GET_TWAP_ORDERS_PARAMS_SCHEMA, params) + const { + direction = DEFAULT_QUERY_DIRECTION, + limit = DEFAULT_PAGE_LIMIT, + offset = DEFAULT_PAGE_OFFSET, + } = parseInput(QUERY_OPTIONS_SCHEMA, options) - if (!Number.isInteger(chainId) || !isSupportedChain(chainId) || !isEvmChain(chainId)) { - throw new ProgrammaticOrderApiError(`Unsupported EVM chain: ${chainId}`) + try { + const page = await this.graphql.queryPage({ + query: TWAP_ORDERS_QUERY, + page: 'twapOrders', + variables: { + resolvedOwner, + chainId, + offset, + limit, + direction, + }, + itemSchema: TWAP_PARENT_SCHEMA, + }) + + return page + } catch (cause) { + throw new ProgrammaticOrderApiError('Failed to fetch TWAP orders', { cause }) } + } - const resolvedOwner = getEvmAddressKey(params.resolvedOwner) - - log(`ProgrammaticOrderApi: fetching TWAP orders for ${resolvedOwner} on chain ${chainId}`) + /** + * Returns one page of part orders for a TWAP order, newest first by default. + * + * @param params - Parent event ID and chain. + * @param options - Pagination and sort direction. + * @returns The requested part orders and the total number found. + * @throws {@link ProgrammaticOrderApiError} when the input is invalid or the request fails. + */ + async getTwapPartOrders( + params: GetTwapPartOrdersParams, + options: QueryOptions = {}, + ): Promise> { + const { chainId, eventId } = parseInput(GET_TWAP_PART_ORDERS_PARAMS_SCHEMA, params) + const { + direction = DEFAULT_QUERY_DIRECTION, + limit = DEFAULT_PAGE_LIMIT, + offset = DEFAULT_PAGE_OFFSET, + } = parseInput(QUERY_OPTIONS_SCHEMA, options) try { - const orders: TwapOrder[] = [] - const parents = await getTwapParents(this.graphql, resolvedOwner, chainId) - - for (const parent of parents) { - const partOrders = await getTwapPartOrders(this.graphql, chainId, parent.eventId) - let executedSellAmount = 0n - let executedBuyAmount = 0n - - for (const partOrder of partOrders) { - executedSellAmount += partOrder.executedSellAmount ?? 0n - executedBuyAmount += partOrder.executedBuyAmount ?? 0n - } - - const order = { - ...parent, + const page = await this.graphql.queryPage({ + query: TWAP_PART_ORDERS_QUERY, + page: 'partOrders', + variables: { chainId, - resolvedOwner, - partOrders, - executedAmounts: { executedSellAmount, executedBuyAmount }, - } satisfies TwapOrder - - orders.push(order) - onProgress?.(order) - } - - log( - `ProgrammaticOrderApi: fetched ${orders.length} TWAP orders and ${orders.reduce((sum, order) => sum + order.partOrders.length, 0)} part orders`, - ) - - return orders + parentEventId: eventId, + offset, + limit, + direction, + }, + itemSchema: TWAP_PART_ORDER_SCHEMA, + }) + + return page } catch (cause) { - if (cause instanceof ProgrammaticOrderApiError) throw cause - - throw new ProgrammaticOrderApiError('Failed to fetch TWAP orders', { cause }) + throw new ProgrammaticOrderApiError('Failed to fetch TWAP part orders', { cause }) } } } diff --git a/packages/composable/src/programmaticOrders/graphql.ts b/packages/composable/src/programmaticOrders/graphql.ts index c84981f1c..fdf1a5f57 100644 --- a/packages/composable/src/programmaticOrders/graphql.ts +++ b/packages/composable/src/programmaticOrders/graphql.ts @@ -5,13 +5,18 @@ const GRAPHQL_ENVELOPE_SCHEMA = v.object({ errors: v.optional(v.nullable(v.array(v.object({ message: v.string() })))), }) -interface GraphqlPaginatedOperation { +interface GraphqlPageOperation { query: string page: string - variables: (after: string | null) => Record + variables: Record itemSchema: TItemSchema } +export interface GraphqlPage { + items: TItem[] + totalCount: number +} + export class GraphqlClientError extends Error { readonly name = 'GraphqlClientError' } @@ -85,52 +90,29 @@ export class GraphqlClient { return data } - async paginate( - operation: GraphqlPaginatedOperation, - ): Promise[]> { - const items: v.InferOutput[] = [] - const cursors = new Set() - let after: string | null = null - let hasNextPage = true - - while (hasNextPage) { - const data = await this.query(operation.query, operation.variables(after)) - const result = v.safeParse( - v.object({ - [operation.page]: v.object({ - items: v.array(operation.itemSchema), - pageInfo: v.object({ - hasNextPage: v.boolean(), - endCursor: v.nullable(v.string()), - }), - }), + async queryPage( + operation: GraphqlPageOperation, + ): Promise>> { + const data = await this.query(operation.query, operation.variables) + const result = v.safeParse( + v.object({ + [operation.page]: v.object({ + items: v.array(operation.itemSchema), + totalCount: v.pipe(v.number(), v.safeInteger(), v.minValue(0)), }), - data, - { abortEarly: true }, - ) - - if (!result.success) { - throw new GraphqlClientError(`Invalid GraphQL page: ${operation.page}`) - } - - const page = result.output[operation.page] - - if (page === undefined) throw new GraphqlClientError(`response.data.${operation.page} is missing`) + }), + data, + { abortEarly: true }, + ) - const { endCursor } = page.pageInfo + if (!result.success) { + throw new GraphqlClientError(`Invalid GraphQL page: ${operation.page}`) + } - hasNextPage = page.pageInfo.hasNextPage - items.push(...page.items) + const page = result.output[operation.page] - if (!hasNextPage) break - if (endCursor === null || endCursor.length === 0 || cursors.has(endCursor)) { - throw new GraphqlClientError('GraphQL page contains an invalid pagination cursor') - } - - cursors.add(endCursor) - after = endCursor - } + if (page === undefined) throw new GraphqlClientError(`response.data.${operation.page} is missing`) - return items + return page } } diff --git a/packages/composable/src/programmaticOrders/index.ts b/packages/composable/src/programmaticOrders/index.ts index d05ebe59b..62e82305d 100644 --- a/packages/composable/src/programmaticOrders/index.ts +++ b/packages/composable/src/programmaticOrders/index.ts @@ -1,4 +1,17 @@ export { ProgrammaticOrderApi } from './ProgrammaticOrderApi' export { ProgrammaticOrderApiError } from './types' -export type { ProgrammaticOrderApiOptions, ProgrammaticOrderStatus } from './types' -export type { GetTwapOrdersParams, TwapOrder, TwapPartOrder, TwapPartOrderStatus, TwapSchedule } from './twap-types' +export type { + ProgrammaticOrderApiOptions, + ProgrammaticOrderStatus, + QueryDirection, + QueryOptions, + QueryPage, +} from './types' +export type { + GetTwapOrdersParams, + GetTwapPartOrdersParams, + TwapOrder, + TwapPartOrder, + TwapPartOrderStatus, + TwapSchedule, +} from './twap-types' diff --git a/packages/composable/src/programmaticOrders/schemas.ts b/packages/composable/src/programmaticOrders/schemas.ts index 6378bd02e..8329f688d 100644 --- a/packages/composable/src/programmaticOrders/schemas.ts +++ b/packages/composable/src/programmaticOrders/schemas.ts @@ -1,6 +1,12 @@ +import { isEvmChain, isSupportedChain, type SupportedChainId } from '@cowprotocol/sdk-config' import { getEvmAddressKey, isEvmAddress } from '@cowprotocol/sdk-common' import * as v from 'valibot' +import type { QueryDirection } from './types' + +const MAX_PAGE_LIMIT = 1000 +const MAX_OFFSET = 2 ** 31 - 1 // GraphQL Int maximum + const MAX_DATE_SECONDS = 8_640_000_000_000n const MAX_UINT32 = 4_294_967_295 const MAX_UINT256 = (1n << 256n) - 1n @@ -9,6 +15,14 @@ const BYTES_32_PATTERN = /^0x[0-9a-fA-F]{64}$/ const ORDER_UID_PATTERN = /^0x[0-9a-fA-F]{112}$/ const UNSIGNED_INTEGER_PATTERN = /^\d+$/ +const QUERY_DIRECTIONS = ['asc', 'desc'] as const satisfies readonly QueryDirection[] + +export const QUERY_OPTIONS_SCHEMA = v.object({ + limit: v.optional(v.pipe(v.number(), v.safeInteger(), v.minValue(1), v.maxValue(MAX_PAGE_LIMIT))), + offset: v.optional(v.pipe(v.number(), v.safeInteger(), v.minValue(0), v.maxValue(MAX_OFFSET))), + direction: v.optional(v.picklist(QUERY_DIRECTIONS)), +}) + export const ADDRESS_SCHEMA = v.pipe( v.string(), v.check((address) => isEvmAddress(address), 'must be an EVM address'), @@ -18,6 +32,13 @@ export const BYTES_32_SCHEMA = v.pipe(v.string(), v.regex(BYTES_32_PATTERN, 'mus export const NON_EMPTY_STRING_SCHEMA = v.pipe(v.string(), v.minLength(1, 'must not be empty')) export const ORDER_UID_SCHEMA = v.pipe(v.string(), v.regex(ORDER_UID_PATTERN, 'must be an order UID')) export const SAFE_INTEGER_SCHEMA = v.pipe(v.number(), v.safeInteger('must be a safe integer')) +export const SUPPORTED_EVM_CHAIN_ID_SCHEMA = v.pipe( + SAFE_INTEGER_SCHEMA, + v.check( + (chainId): chainId is SupportedChainId => isSupportedChain(chainId) && isEvmChain(chainId), + 'must be a supported EVM chain', + ), +) export const UINT32_SCHEMA = v.pipe( SAFE_INTEGER_SCHEMA, v.minValue(0, 'must be a uint32'), diff --git a/packages/composable/src/programmaticOrders/twap-queries.ts b/packages/composable/src/programmaticOrders/twap-queries.ts index dead4d223..907ebb585 100644 --- a/packages/composable/src/programmaticOrders/twap-queries.ts +++ b/packages/composable/src/programmaticOrders/twap-queries.ts @@ -1,27 +1,15 @@ -import type { SupportedChainId } from '@cowprotocol/sdk-config' -import { areAddressesEqual } from '@cowprotocol/sdk-common' - -import type { GraphqlClient } from './graphql' -import { TWAP_PARENT_SCHEMA, TWAP_PART_ORDER_SCHEMA } from './twap-schemas' -import type { TwapParent, TwapPartOrder } from './twap-types' - -const PAGE_SIZE = 1000 - -const TWAP_ORDERS_QUERY = ` - query TwapOrders( - $resolvedOwner: String! - $chainId: Int! - $after: String - $limit: Int! - ) { +export const TWAP_ORDERS_QUERY = ` + query TwapOrders($resolvedOwner: String!, $chainId: Int!, $offset: Int!, $limit: Int!, $direction: String!) { twapOrders: conditionalOrderGenerators( where: { chainId: $chainId orderType: TWAP resolvedOwner: $resolvedOwner } - after: $after + offset: $offset limit: $limit + orderBy: "eventId" + orderDirection: $direction ) { items { eventId @@ -30,33 +18,35 @@ const TWAP_ORDERS_QUERY = ` owner resolvedOwner status + updatedAtBlock + additionalData + partOrders: discreteOrders(limit: 1) { + totalCount + } schedule: decodedParams transaction { blockTimestamp } } - pageInfo { - hasNextPage - endCursor - } + totalCount } } ` -const TWAP_PART_ORDERS_QUERY = ` - query TwapPartOrders($chainId: Int!, $parentEventId: String!, $after: String, $limit: Int!) { +export const TWAP_PART_ORDERS_QUERY = ` + query TwapPartOrders($chainId: Int!, $parentEventId: String!, $offset: Int!, $limit: Int!, $direction: String!) { partOrders: discreteOrders( where: { chainId: $chainId conditionalOrderGeneratorId: $parentEventId } - after: $after + offset: $offset limit: $limit + orderBy: "creationDate" + orderDirection: $direction ) { items { orderUid - chainId - parentEventId: conditionalOrderGeneratorId status sellAmount buyAmount @@ -65,72 +55,9 @@ const TWAP_PART_ORDERS_QUERY = ` createdAt: creationDate executedSellAmount executedBuyAmount + executedFeeAmount: executedFee } - pageInfo { - hasNextPage - endCursor - } + totalCount } } ` - -export async function getTwapParents( - client: GraphqlClient, - resolvedOwner: string, - chainId: SupportedChainId, -): Promise { - const parents = await client.paginate({ - query: TWAP_ORDERS_QUERY, - page: 'twapOrders', - variables: (after) => ({ - resolvedOwner, - chainId, - after, - limit: PAGE_SIZE, - }), - itemSchema: TWAP_PARENT_SCHEMA, - }) - - for (const parent of parents) { - if ( - parent.chainId !== chainId || - parent.resolvedOwner === null || - !areAddressesEqual(parent.resolvedOwner, resolvedOwner) - ) { - throw new Error('Programmatic orders API returned a TWAP order with an unexpected owner or chain') - } - } - - return parents -} - -export async function getTwapPartOrders( - client: GraphqlClient, - chainId: SupportedChainId, - parentEventId: string, -): Promise { - const records = await client.paginate({ - query: TWAP_PART_ORDERS_QUERY, - page: 'partOrders', - variables: (after) => ({ - chainId, - parentEventId, - after, - limit: PAGE_SIZE, - }), - itemSchema: TWAP_PART_ORDER_SCHEMA, - }) - const partOrders: TwapPartOrder[] = [] - - for (const record of records) { - const { chainId: recordChainId, parentEventId: recordParentEventId, ...partOrder } = record - - if (recordChainId !== chainId || recordParentEventId !== parentEventId) { - throw new Error('Programmatic orders API returned a part order for an unexpected TWAP order or chain') - } - - partOrders.push(partOrder) - } - - return partOrders -} diff --git a/packages/composable/src/programmaticOrders/twap-schemas.ts b/packages/composable/src/programmaticOrders/twap-schemas.ts index 4f593a2a7..997e0e131 100644 --- a/packages/composable/src/programmaticOrders/twap-schemas.ts +++ b/packages/composable/src/programmaticOrders/twap-schemas.ts @@ -8,11 +8,24 @@ import { ORDER_UID_SCHEMA, PROGRAMMATIC_ORDER_STATUS_SCHEMA, SAFE_INTEGER_SCHEMA, + SUPPORTED_EVM_CHAIN_ID_SCHEMA, TIMESTAMP_SCHEMA, UINT32_SCHEMA, UINT256_SCHEMA, } from './schemas' -import type { TwapParent } from './twap-types' + +export const GET_TWAP_ORDERS_PARAMS_SCHEMA = v.object({ + resolvedOwner: ADDRESS_SCHEMA, + chainId: SUPPORTED_EVM_CHAIN_ID_SCHEMA, +}) + +export const GET_TWAP_PART_ORDERS_PARAMS_SCHEMA = v.object({ + eventId: v.pipe( + v.string(), + v.check((eventId) => eventId.trim().length > 0, 'TWAP eventId must not be empty'), + ), + chainId: SUPPORTED_EVM_CHAIN_ID_SCHEMA, +}) /** @see https://github.com/bleu/cow-programmatic-orders-api/blob/main/docs/supported-order-types.md#twap-time-weighted-average-price */ const TWAP_SCHEDULE_SCHEMA = v.object({ @@ -33,36 +46,55 @@ export const TWAP_PARENT_SCHEMA = v.pipe( v.object({ eventId: NON_EMPTY_STRING_SCHEMA, hash: BYTES_32_SCHEMA, - chainId: SAFE_INTEGER_SCHEMA, + chainId: SUPPORTED_EVM_CHAIN_ID_SCHEMA, owner: ADDRESS_SCHEMA, - resolvedOwner: v.nullable(ADDRESS_SCHEMA), + resolvedOwner: ADDRESS_SCHEMA, status: PROGRAMMATIC_ORDER_STATUS_SCHEMA, + updatedAtBlock: UINT256_SCHEMA, + additionalData: v.object({ + executedSellAmount: UINT256_SCHEMA, + executedBuyAmount: UINT256_SCHEMA, + executedFee: UINT256_SCHEMA, + }), + partOrders: v.object({ totalCount: SAFE_INTEGER_SCHEMA }), transaction: v.object({ blockTimestamp: TIMESTAMP_SCHEMA }), schedule: TWAP_SCHEDULE_SCHEMA, }), - v.transform(({ schedule, transaction, ...parent }): TwapParent => { - const { t0, n, t, span, ...scheduleParams } = schedule - const createdAt = transaction.blockTimestamp + v.transform( + ({ + additionalData: { executedSellAmount, executedBuyAmount, executedFee }, + partOrders, + schedule, + transaction, + ...parent + }) => { + const { t0, n, t, span, ...scheduleParams } = schedule + const createdAt = transaction.blockTimestamp - return { - ...parent, - createdAt, - schedule: { - ...scheduleParams, - effectiveStartTime: t0 === 0n ? BigInt(createdAt) : t0, - numberOfParts: n, - timeBetweenParts: t, - durationOfPart: span, - }, - } - }), + return { + ...parent, + createdAt, + partOrdersCount: partOrders.totalCount, + executedAmounts: { + executedSellAmount, + executedBuyAmount, + executedFeeAmount: executedFee, // TODO rename in indexer + }, + schedule: { + ...scheduleParams, + effectiveStartTime: t0 === 0n ? BigInt(createdAt) : t0, + numberOfParts: n, + timeBetweenParts: t, + durationOfPart: span, + }, + } + }, + ), ) /** @see https://github.com/bleu/cow-programmatic-orders-api/blob/main/src/api/gql-docs/discrete-order.ts */ export const TWAP_PART_ORDER_SCHEMA = v.object({ orderUid: ORDER_UID_SCHEMA, - chainId: SAFE_INTEGER_SCHEMA, - parentEventId: NON_EMPTY_STRING_SCHEMA, status: v.picklist([OrderStatus.OPEN, OrderStatus.FULFILLED, OrderStatus.EXPIRED, OrderStatus.CANCELLED, 'unfilled']), sellAmount: UINT256_SCHEMA, buyAmount: UINT256_SCHEMA, @@ -71,4 +103,5 @@ export const TWAP_PART_ORDER_SCHEMA = v.object({ createdAt: TIMESTAMP_SCHEMA, executedSellAmount: v.nullable(UINT256_SCHEMA), executedBuyAmount: v.nullable(UINT256_SCHEMA), + executedFeeAmount: v.nullable(UINT256_SCHEMA), }) diff --git a/packages/composable/src/programmaticOrders/twap-types.ts b/packages/composable/src/programmaticOrders/twap-types.ts index 57f71849d..506c362c5 100644 --- a/packages/composable/src/programmaticOrders/twap-types.ts +++ b/packages/composable/src/programmaticOrders/twap-types.ts @@ -5,15 +5,18 @@ import type { ProgrammaticOrderStatus } from './types' /** Input for querying TWAP orders. */ export interface GetTwapOrdersParams { - /** Controlling EOA or Safe address. Must not be a CoWShed proxy. */ + /** EOA or Safe that created the TWAP order. Do not pass a CoWShed proxy address. */ resolvedOwner: string - /** Supported EVM chain containing the orders. */ + /** Chain containing the TWAP orders. */ + chainId: SupportedChainId +} + +/** Input for querying one page of TWAP part orders. */ +export interface GetTwapPartOrdersParams { + /** Parent event ID returned by `getTwapOrders`. */ + eventId: string + /** Chain containing the part orders. */ chainId: SupportedChainId - /** - * Receives each fully assembled TWAP order after all its available part orders are fetched. - * Emitted orders are provisional when a later page fails; the promise returns the complete result. - */ - onProgress?: (order: TwapOrder) => void } /** @@ -33,8 +36,8 @@ export type TwapPartOrderStatus = | 'unfilled' /** - * TWAP schedule. Unlike {@link TwapStruct}, - * `effectiveStartTime` is the creation block timestamp when on-chain `t0` was zero. + * Schedule for a TWAP order. Unlike {@link TwapStruct}, `effectiveStartTime` + * uses the creation block timestamp when the on-chain `t0` value is zero. * @see https://github.com/bleu/cow-programmatic-orders-api/blob/main/docs/supported-order-types.md#twap-time-weighted-average-price * @see https://github.com/cowprotocol/composable-cow/blob/main/src/types/twap/libraries/TWAPOrder.sol#L31-L42 */ @@ -70,45 +73,39 @@ export interface TwapPartOrder { createdAt: number executedSellAmount: bigint | null executedBuyAmount: bigint | null + /** Actual fee charged at settlement, in the sell token. */ + executedFeeAmount: bigint | null } export interface TwapExecutedAmounts { - /** Executed sell and buy amounts summed over all part orders. */ + /** Execution totals for all part orders. */ executedSellAmount: bigint executedBuyAmount: bigint + executedFeeAmount: bigint } /** - * TWAP order with its schedule, part orders, and executed amounts. + * TWAP order with its schedule and execution totals. * @see https://github.com/bleu/cow-programmatic-orders-api/blob/main/src/api/gql-docs/conditional-order-generator.ts * @see https://github.com/bleu/cow-programmatic-orders-api/blob/main/src/api/gql-docs/owner-mapping.ts */ export interface TwapOrder { - /** Opaque creation-event identity. Unique together with `chainId`. */ + /** Creation event ID. Unique within a chain. */ eventId: string - /** ComposableCoW single-order hash. Multiple creation events may share it. */ + /** ComposableCoW order hash. More than one creation event can have the same hash. */ hash: string chainId: SupportedChainId - /** Protocol owner used by the generated CoW orders: the CoWShed proxy for an EOA TWAP, or the Safe for a Safe TWAP. */ + /** Address that owns the generated CoW orders: a CoWShed proxy or Safe. */ owner: string - /** Canonical owner after applying known proxy mappings: the controlling EOA for a proxy, otherwise `owner`. */ + /** EOA behind a known CoWShed proxy, or `owner` for a Safe. */ resolvedOwner: string status: ProgrammaticOrderStatus /** Unix creation time in seconds. */ createdAt: number + /** Block in which the indexer last updated this TWAP or one of its part orders. */ + updatedAtBlock: bigint + /** Number of part orders currently reported for this parent. */ + partOrdersCount: number schedule: TwapSchedule - partOrders: TwapPartOrder[] executedAmounts: TwapExecutedAmounts } - -export interface TwapParent { - eventId: string - hash: string - chainId: number - owner: string - /** Owner stored by the API after applying any known proxy mapping; nullable on the wire. */ - resolvedOwner: string | null - status: ProgrammaticOrderStatus - createdAt: number - schedule: TwapSchedule -} diff --git a/packages/composable/src/programmaticOrders/types.ts b/packages/composable/src/programmaticOrders/types.ts index 64afc1e9a..477e37bb5 100644 --- a/packages/composable/src/programmaticOrders/types.ts +++ b/packages/composable/src/programmaticOrders/types.ts @@ -3,8 +3,24 @@ export interface ProgrammaticOrderApiOptions { apiUrl?: string } +export type QueryDirection = 'asc' | 'desc' + +export interface QueryOptions { + /** Maximum number of records to return. Defaults to 100; maximum 1000. */ + limit?: number + /** Number of records to skip. Defaults to 0. */ + offset?: number + /** Sort direction. Defaults to descending. */ + direction?: QueryDirection +} + +export interface QueryPage { + items: T[] + totalCount: number +} + /** - * Lifecycle status of a programmatic order. + * Status reported by the programmatic orders API. * @see https://github.com/bleu/cow-programmatic-orders-api/blob/main/schema/tables.ts#L20-L24 * @see https://github.com/bleu/cow-programmatic-orders-api/blob/main/src/api/gql-docs/conditional-order-generator.ts */ @@ -12,14 +28,4 @@ export type ProgrammaticOrderStatus = 'Active' | 'Cancelled' | 'Completed' export class ProgrammaticOrderApiError extends Error { readonly name = 'ProgrammaticOrderApiError' - - /** - * Creates a programmatic orders API error. - * - * @param message - Human-readable failure description. - * @param options - Standard error options, including the original cause. - */ - constructor(message: string, options?: ErrorOptions) { - super(message, options) - } } diff --git a/packages/composable/src/programmaticOrders/validation.ts b/packages/composable/src/programmaticOrders/validation.ts new file mode 100644 index 000000000..f974418b8 --- /dev/null +++ b/packages/composable/src/programmaticOrders/validation.ts @@ -0,0 +1,13 @@ +import * as v from 'valibot' + +import { ProgrammaticOrderApiError } from './types' + +export function parseInput(schema: TSchema, input: unknown): v.InferOutput { + const result = v.safeParse(schema, input, { abortEarly: true }) + + if (!result.success) { + throw new ProgrammaticOrderApiError(result.issues[0]?.message ?? 'Invalid input') + } + + return result.output +} diff --git a/packages/composable/tests/GraphqlClient.spec.ts b/packages/composable/tests/GraphqlClient.spec.ts deleted file mode 100644 index d7c724fb8..000000000 --- a/packages/composable/tests/GraphqlClient.spec.ts +++ /dev/null @@ -1,88 +0,0 @@ -import * as v from 'valibot' - -import { GraphqlClient, GraphqlClientError } from '../src/programmaticOrders/graphql' - -describe('GraphqlClient', () => { - afterEach(() => jest.restoreAllMocks()) - - it('iterates parsed pages', async () => { - const fetchMock = jest - .spyOn(globalThis, 'fetch') - .mockResolvedValueOnce(pageResponse(['first'], true, 'next')) - .mockResolvedValueOnce(pageResponse(['second'], false, null)) - const client = new GraphqlClient('https://example.com/api') - - const items = await client.paginate({ - query: 'query Nodes($after: String) { nodes(after: $after) { items pageInfo } }', - page: 'nodes', - variables: (after) => ({ after }), - itemSchema: v.string(), - }) - - expect(items).toEqual(['first', 'second']) - expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([ - 'https://example.com/api/graphql', - 'https://example.com/api/graphql', - ]) - expect(fetchMock.mock.calls.map(([, init]) => JSON.parse(String(init?.body)).variables.after)).toEqual([ - null, - 'next', - ]) - }) - - it('rejects invalid envelopes, pages, and cursors', async () => { - const fetchMock = jest.spyOn(globalThis, 'fetch') - const client = new GraphqlClient('https://example.com/graphql') - - fetchMock.mockResolvedValueOnce(jsonResponse({ value: 1 })) - - await expect(client.query('query Value { value }', {})).rejects.toThrow('GraphQL response.data is missing') - - fetchMock.mockResolvedValueOnce(pageResponse([1], false, null)) - - await expect( - client.paginate({ - query: 'query Nodes { nodes }', - page: 'nodes', - variables: () => ({}), - itemSchema: v.string(), - }), - ).rejects.toThrow('Invalid GraphQL page: nodes') - - fetchMock - .mockResolvedValueOnce(pageResponse(['first'], true, 'same')) - .mockResolvedValueOnce(pageResponse(['second'], true, 'same')) - - await expect( - client.paginate({ - query: 'query Nodes { nodes }', - page: 'nodes', - variables: () => ({}), - itemSchema: v.string(), - }), - ).rejects.toThrow('GraphQL page contains an invalid pagination cursor') - }) - - it('rejects invalid endpoints', () => { - expect(() => new GraphqlClient('not a URL')).toThrow(GraphqlClientError) - }) -}) - -function pageResponse(items: unknown[], hasNextPage: boolean, endCursor: string | null): Promise { - return Promise.resolve( - jsonResponse({ - data: { - nodes: { - items, - pageInfo: { hasNextPage, endCursor }, - }, - }, - }), - ) -} - -function jsonResponse(payload: unknown): Response { - return new Response(JSON.stringify(payload), { - headers: { 'Content-Type': 'application/json' }, - }) -} diff --git a/packages/composable/tests/ProgrammaticOrderApi.spec.ts b/packages/composable/tests/ProgrammaticOrderApi.spec.ts index 5a5e86420..bdc661f0d 100644 --- a/packages/composable/tests/ProgrammaticOrderApi.spec.ts +++ b/packages/composable/tests/ProgrammaticOrderApi.spec.ts @@ -1,29 +1,243 @@ import { SupportedChainId } from '@cowprotocol/sdk-config' -import { ProgrammaticOrderApi, type TwapOrder } from '../src' +import { + ProgrammaticOrderApi, + type GetTwapOrdersParams, + type GetTwapPartOrdersParams, + type QueryDirection, +} from '../src' const EOA = '0x016f34D4f2578c3e9DFfC3f2b811Ba30c0c9e7f3' const SAFE = '0xaA248D5328c7D781a96D93d7D013bcF393157bB4' describe('ProgrammaticOrderApi', () => { - it('lists all known EOA TWAPs from the live programmatic orders API', async () => { - const progress: TwapOrder[] = [] - const orders = await new ProgrammaticOrderApi().getTwapOrders({ + afterEach(() => { + jest.restoreAllMocks() + }) + + it('validates parent and part page bounds before requesting', async () => { + const api = new ProgrammaticOrderApi() + + await expect( + api.getTwapOrders({ resolvedOwner: EOA, chainId: SupportedChainId.GNOSIS_CHAIN }, { limit: 1001 }), + ).rejects.toThrow('Invalid value: Expected <=1000 but received 1001') + await expect( + api.getTwapOrders({ resolvedOwner: EOA, chainId: SupportedChainId.GNOSIS_CHAIN }, { limit: 0 }), + ).rejects.toThrow('Invalid value: Expected >=1 but received 0') + await expect( + api.getTwapOrders({ resolvedOwner: EOA, chainId: SupportedChainId.GNOSIS_CHAIN }, { limit: 1.5 }), + ).rejects.toThrow('Invalid safe integer: Received 1.5') + await expect( + api.getTwapOrders({ resolvedOwner: EOA, chainId: SupportedChainId.GNOSIS_CHAIN }, { offset: -1 }), + ).rejects.toThrow('Invalid value: Expected >=0 but received -1') + await expect( + api.getTwapOrders( + { resolvedOwner: EOA, chainId: SupportedChainId.GNOSIS_CHAIN }, + { direction: 'sideways' as QueryDirection }, + ), + ).rejects.toThrow('Invalid type: Expected ("asc" | "desc") but received "sideways"') + await expect( + api.getTwapPartOrders( + { + eventId: '', + chainId: SupportedChainId.GNOSIS_CHAIN, + }, + { offset: 0, limit: 10 }, + ), + ).rejects.toThrow('TWAP eventId must not be empty') + await expect( + api.getTwapPartOrders( + { + eventId: 'event', + chainId: SupportedChainId.GNOSIS_CHAIN, + }, + { offset: -1, limit: 10 }, + ), + ).rejects.toThrow('Invalid value: Expected >=0 but received -1') + }) + + it('validates parent and part params before requesting', async () => { + const api = new ProgrammaticOrderApi() + + await expect(api.getTwapOrders(undefined as unknown as GetTwapOrdersParams)).rejects.toThrow( + 'Invalid type: Expected Object but received undefined', + ) + await expect( + api.getTwapOrders({ + resolvedOwner: 'invalid', + chainId: SupportedChainId.GNOSIS_CHAIN, + }), + ).rejects.toThrow('must be an EVM address') + await expect( + api.getTwapOrders({ + resolvedOwner: EOA, + chainId: 999 as SupportedChainId, + }), + ).rejects.toThrow('must be a supported EVM chain') + await expect( + api.getTwapPartOrders({ + eventId: ' ', + chainId: SupportedChainId.GNOSIS_CHAIN, + }), + ).rejects.toThrow('TWAP eventId must not be empty') + await expect(api.getTwapPartOrders(null as unknown as GetTwapPartOrdersParams)).rejects.toThrow( + 'Invalid type: Expected Object but received null', + ) + }) + + it('requests parent-only TWAPs by creation and preserves the returned order', async () => { + const fetchMock = jest.spyOn(globalThis, 'fetch').mockResolvedValueOnce( + new Response( + JSON.stringify({ + data: { + twapOrders: { + items: [twapParent('newer-event', 200), twapParent('older-event', 100)], + totalCount: 2, + }, + }, + }), + { headers: { 'Content-Type': 'application/json' } }, + ), + ) + + const page = await new ProgrammaticOrderApi({ apiUrl: 'https://example.com' }).getTwapOrders( + { + resolvedOwner: EOA, + chainId: SupportedChainId.GNOSIS_CHAIN, + }, + { direction: 'asc', limit: 2, offset: 1 }, + ) + const request = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body)) as { + query: string + variables: Record + } + + expect(request.query).toContain('orderBy: "eventId"') + expect(request.query).toContain('orderDirection: $direction') + expect(request.query).not.toContain('discreteOrders {\n') + expect(request.variables).toEqual({ + resolvedOwner: EOA.toLowerCase(), + chainId: SupportedChainId.GNOSIS_CHAIN, + offset: 1, + limit: 2, + direction: 'asc', + }) + expect(page.totalCount).toBe(2) + expect(page.items.map(({ eventId, createdAt }) => ({ eventId, createdAt }))).toEqual([ + { eventId: 'newer-event', createdAt: 200 }, + { eventId: 'older-event', createdAt: 100 }, + ]) + expect(page.items[0]).not.toHaveProperty('partOrders') + }) + + it('applies query options to a part-order page', async () => { + const fetchMock = jest.spyOn(globalThis, 'fetch').mockResolvedValueOnce( + new Response( + JSON.stringify({ + data: { + partOrders: { + items: [], + totalCount: 12, + }, + }, + }), + { headers: { 'Content-Type': 'application/json' } }, + ), + ) + + const page = await new ProgrammaticOrderApi({ apiUrl: 'https://example.com' }).getTwapPartOrders( + { + eventId: 'event', + chainId: SupportedChainId.GNOSIS_CHAIN, + }, + { limit: 10, offset: 10 }, + ) + const request = JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body)) as { + query: string + variables: Record + } + + expect(request.query).toContain('orderBy: "creationDate"') + expect(request.query).toContain('orderDirection: $direction') + expect(request.variables).toEqual({ + chainId: SupportedChainId.GNOSIS_CHAIN, + parentEventId: 'event', + offset: 10, + limit: 10, + direction: 'desc', + }) + expect(page).toEqual({ items: [], totalCount: 12 }) + }) + + it('lists the latest EOA TWAP parents from the live programmatic orders API', async () => { + const page = await new ProgrammaticOrderApi().getTwapOrders({ resolvedOwner: EOA, chainId: SupportedChainId.GNOSIS_CHAIN, - onProgress: (order) => progress.push(order), }) - expect(progress).toEqual(orders) - expect(orders).toMatchSnapshot() + expect(page.items).toMatchSnapshot() }, 30_000) - it('lists all known Safe TWAPs from the live programmatic orders API', async () => { - const orders = await new ProgrammaticOrderApi().getTwapOrders({ + it('lists the latest Safe TWAP parents from the live programmatic orders API', async () => { + const page = await new ProgrammaticOrderApi().getTwapOrders({ resolvedOwner: SAFE, chainId: SupportedChainId.GNOSIS_CHAIN, }) - expect(orders).toMatchSnapshot() + expect(page.items).toMatchSnapshot() + }, 30_000) + + it('lists one page of EOA TWAP part orders from the live programmatic orders API', async () => { + const api = new ProgrammaticOrderApi() + const parentsPage = await api.getTwapOrders({ + resolvedOwner: EOA, + chainId: SupportedChainId.GNOSIS_CHAIN, + }) + const parent = parentsPage.items.find(({ executedAmounts }) => executedAmounts.executedFeeAmount > 0n) + + expect(parent).toBeDefined() + + const page = await api.getTwapPartOrders( + { + eventId: String(parent?.eventId), + chainId: SupportedChainId.GNOSIS_CHAIN, + }, + { offset: 0, limit: 10 }, + ) + + expect(page.totalCount).toBe(parent?.partOrdersCount) + expect(page.items.some(({ executedFeeAmount }) => executedFeeAmount !== null)).toBe(true) + expect(page).toMatchSnapshot() }, 30_000) }) + +function twapParent(eventId: string, blockTimestamp: number): Record { + return { + eventId, + chainId: SupportedChainId.GNOSIS_CHAIN, + hash: `0x${'1'.repeat(64)}`, + owner: EOA, + resolvedOwner: EOA, + status: 'Active', + updatedAtBlock: '1', + additionalData: { + executedSellAmount: '0', + executedBuyAmount: '0', + executedFee: '0', + }, + partOrders: { totalCount: 0 }, + schedule: { + sellToken: EOA, + buyToken: EOA, + receiver: EOA, + partSellAmount: '1', + minPartLimit: '1', + t0: '0', + n: '1', + t: '1', + span: '0', + appData: `0x${'2'.repeat(64)}`, + }, + transaction: { blockTimestamp: String(blockTimestamp) }, + } +} diff --git a/packages/composable/tests/__snapshots__/ProgrammaticOrderApi.spec.ts.snap b/packages/composable/tests/__snapshots__/ProgrammaticOrderApi.spec.ts.snap index 062ca74ea..aabd3d996 100644 --- a/packages/composable/tests/__snapshots__/ProgrammaticOrderApi.spec.ts.snap +++ b/packages/composable/tests/__snapshots__/ProgrammaticOrderApi.spec.ts.snap @@ -1,247 +1,197 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`ProgrammaticOrderApi lists all known EOA TWAPs from the live programmatic orders API 1`] = ` +exports[`ProgrammaticOrderApi lists one page of EOA TWAP part orders from the live programmatic orders API 1`] = ` +{ + "partOrders": [ + { + "buyAmount": 776195194471677430n, + "createdAt": 1782860530, + "executedBuyAmount": 0n, + "executedFeeAmount": null, + "executedSellAmount": 0n, + "feeAmount": 0n, + "orderUid": "0x4f44aa86f906fc8b796a602e1cbf6edba7a0542c0d842a64e2108237f430098c62587918b2f00176646679509217a5a4d1ebbfd56a444be1", + "sellAmount": 100000000000000000n, + "status": "expired", + "validTo": 1782860769, + }, + { + "buyAmount": 776195194471677430n, + "createdAt": 1782860530, + "executedBuyAmount": 862488114171327656n, + "executedFeeAmount": 20028480706687n, + "executedSellAmount": 100000000000000000n, + "feeAmount": 0n, + "orderUid": "0x693a30318b8208f78b5028b237bb3cf3cef8c7c7c5a74773b02d4a0dec6f334762587918b2f00176646679509217a5a4d1ebbfd56a444b69", + "sellAmount": 100000000000000000n, + "status": "fulfilled", + "validTo": 1782860649, + }, + ], + "totalCount": 2, +} +`; + +exports[`ProgrammaticOrderApi lists the latest EOA TWAP parents from the live programmatic orders API 1`] = ` [ { "chainId": 100, - "createdAt": 1757524180, - "eventId": "175752418000000000000001000000000042055392000000000000000250000000000000021", + "createdAt": 1782860530, + "eventId": "178286053000000000000001000000000046969430000000000000000850000000000000041", "executedAmounts": { - "executedBuyAmount": 0n, - "executedSellAmount": 0n, + "executedBuyAmount": 862488114171327656n, + "executedFeeAmount": 20028480706687n, + "executedSellAmount": 100000000000000000n, }, - "hash": "0x296f02547402281d040fa79d59ef61631e4a18c317654cefa1bf2226224c9fd9", + "hash": "0xc6e1f5bc9f8cd772e89ab65222c6e5511fc121be2bb3cae3889ab666fa506c80", "owner": "0x62587918b2f00176646679509217a5a4d1ebbfd5", - "partOrders": [], + "partOrdersCount": 2, "resolvedOwner": "0x016f34d4f2578c3e9dffc3f2b811ba30c0c9e7f3", "schedule": { - "appData": "0xf7c9274467f87d681e0fa21b3fb886b44546162a0ab94fd44d346a2b78af430a", + "appData": "0xf248f0d7eee2efe935d59e82d615db8d2a47f7929698e4712416cb608e8877d9", "buyToken": "0x177127622c4a00f3d409b75571e12cb3c8973d3c", "durationOfPart": 0n, - "effectiveStartTime": 1757524180n, - "minPartLimit": 0n, - "numberOfParts": 4n, - "partSellAmount": 25000000000000000n, + "effectiveStartTime": 1782860530n, + "minPartLimit": 776195194471677430n, + "numberOfParts": 2n, + "partSellAmount": 100000000000000000n, "receiver": "0x016f34d4f2578c3e9dffc3f2b811ba30c0c9e7f3", "sellToken": "0xaf204776c7245bf4147c2612bf6e5972ee483701", - "timeBetweenParts": 1800n, + "timeBetweenParts": 120n, }, - "status": "Active", + "status": "Completed", + "updatedAtBlock": 46969430n, }, { "chainId": 100, - "createdAt": 1757524940, - "eventId": "175752494000000000000001000000000042055539000000000000000050000000000000008", + "createdAt": 1782860530, + "eventId": "178286053000000000000001000000000046969430000000000000000850000000000000038", "executedAmounts": { - "executedBuyAmount": 168880044359503662n, - "executedSellAmount": 50000000000000000n, + "executedBuyAmount": 862367902269494618n, + "executedFeeAmount": 20054871788565n, + "executedSellAmount": 100000000000000000n, }, - "hash": "0x618fc1893e8fc19a353734250d775dc3fa62df205ed545cc66a17a7ab1197a0c", + "hash": "0x5ec4e09e073067d53acbcb81a94f926608ce075f0f04842e91a3b22ca2ea333d", "owner": "0x62587918b2f00176646679509217a5a4d1ebbfd5", - "partOrders": [ - { - "buyAmount": 1n, - "createdAt": 1757524940, - "executedBuyAmount": 84540640270669195n, - "executedSellAmount": 25000000000000000n, - "feeAmount": 0n, - "orderUid": "0x1407dbf775a8e5869716fdcfe1bcbc8ccffdc43c7bed9db359af46dc15c27bb462587918b2f00176646679509217a5a4d1ebbfd568c1c1db", - "sellAmount": 25000000000000000n, - "status": "fulfilled", - "validTo": 1757528539, - }, - { - "buyAmount": 1n, - "createdAt": 1757524940, - "executedBuyAmount": 84339404088834467n, - "executedSellAmount": 25000000000000000n, - "feeAmount": 0n, - "orderUid": "0x2065f76e5eb0e8da3e20136d4c8bc69f3735d9d54b0d228b3794438282b7fd9f62587918b2f00176646679509217a5a4d1ebbfd568c1bad3", - "sellAmount": 25000000000000000n, - "status": "fulfilled", - "validTo": 1757526739, - }, - ], + "partOrdersCount": 2, "resolvedOwner": "0x016f34d4f2578c3e9dffc3f2b811ba30c0c9e7f3", "schedule": { - "appData": "0xf7c9274467f87d681e0fa21b3fb886b44546162a0ab94fd44d346a2b78af430a", + "appData": "0x302493bc6206f0065533621e960c9e5411c58044ea0b5a80b4f61edac00ba2f3", "buyToken": "0x177127622c4a00f3d409b75571e12cb3c8973d3c", "durationOfPart": 0n, - "effectiveStartTime": 1757524940n, - "minPartLimit": 1n, - "numberOfParts": 4n, - "partSellAmount": 25000000000000000n, + "effectiveStartTime": 1782860530n, + "minPartLimit": 776021953726338606n, + "numberOfParts": 2n, + "partSellAmount": 100000000000000000n, "receiver": "0x016f34d4f2578c3e9dffc3f2b811ba30c0c9e7f3", "sellToken": "0xaf204776c7245bf4147c2612bf6e5972ee483701", - "timeBetweenParts": 1800n, + "timeBetweenParts": 120n, }, - "status": "Active", + "status": "Completed", + "updatedAtBlock": 46969430n, }, { "chainId": 100, - "createdAt": 1757525910, - "eventId": "175752591000000000000001000000000042055728000000000000000050000000000000015", + "createdAt": 1782856350, + "eventId": "178285635000000000000001000000000046968626000000000000001750000000000000087", "executedAmounts": { - "executedBuyAmount": 84880520382659281n, - "executedSellAmount": 25000000000000000n, + "executedBuyAmount": 0n, + "executedFeeAmount": 0n, + "executedSellAmount": 0n, }, - "hash": "0x4ac7633559e7c90792574306ae2c3bbae7588681a0219c3668b61cc6da2be10b", + "hash": "0xf5e3a9d322d4985cd9fc10b84e16f0358c720592ec968ec3921dc65fd702be0b", "owner": "0x62587918b2f00176646679509217a5a4d1ebbfd5", - "partOrders": [ - { - "buyAmount": 1n, - "createdAt": 1757525910, - "executedBuyAmount": 84880520382659281n, - "executedSellAmount": 25000000000000000n, - "feeAmount": 0n, - "orderUid": "0x7d228549d048c1a4759f198766e8014405dae68b97ec3efbe81c0d8df523140062587918b2f00176646679509217a5a4d1ebbfd568c1be9d", - "sellAmount": 25000000000000000n, - "status": "fulfilled", - "validTo": 1757527709, - }, - ], + "partOrdersCount": 2, "resolvedOwner": "0x016f34d4f2578c3e9dffc3f2b811ba30c0c9e7f3", "schedule": { - "appData": "0xf7c9274467f87d681e0fa21b3fb886b44546162a0ab94fd44d346a2b78af430a", + "appData": "0xdfc54d9c8b6a28a0c03947debf1a0f929184919579c926cf3de23c5881eb524a", "buyToken": "0x177127622c4a00f3d409b75571e12cb3c8973d3c", "durationOfPart": 0n, - "effectiveStartTime": 1757525910n, - "minPartLimit": 1n, - "numberOfParts": 4n, - "partSellAmount": 25000000000000000n, + "effectiveStartTime": 1782856350n, + "minPartLimit": 774221321423698851n, + "numberOfParts": 2n, + "partSellAmount": 100000000000000000n, "receiver": "0x016f34d4f2578c3e9dffc3f2b811ba30c0c9e7f3", "sellToken": "0xaf204776c7245bf4147c2612bf6e5972ee483701", - "timeBetweenParts": 1800n, + "timeBetweenParts": 60n, }, "status": "Active", + "updatedAtBlock": 47352216n, }, { "chainId": 100, - "createdAt": 1757526200, - "eventId": "175752620000000000000001000000000042055786000000000000000050000000000000008", + "createdAt": 1782855780, + "eventId": "178285578000000000000001000000000046968514000000000000000450000000000000024", "executedAmounts": { - "executedBuyAmount": 84756756849678668n, - "executedSellAmount": 25000000000000000n, + "executedBuyAmount": 0n, + "executedFeeAmount": 0n, + "executedSellAmount": 0n, }, - "hash": "0x2bbf5f8727c87a4ccfd2ef1332092caa6ea7173a3b5b91ff11fe24df71e0e5f8", + "hash": "0x28b61a46088d4082b10aca722efa0d2404106686594f2a82936893f2d7d22f06", "owner": "0x62587918b2f00176646679509217a5a4d1ebbfd5", - "partOrders": [ - { - "buyAmount": 1n, - "createdAt": 1757526200, - "executedBuyAmount": 84756756849678668n, - "executedSellAmount": 25000000000000000n, - "feeAmount": 0n, - "orderUid": "0x29f584d3184bc6754fafe5beed2e0ca462a3d01389a5add5968fc5fa0a1a8b2662587918b2f00176646679509217a5a4d1ebbfd568c1bfbf", - "sellAmount": 25000000000000000n, - "status": "fulfilled", - "validTo": 1757527999, - }, - ], + "partOrdersCount": 2, "resolvedOwner": "0x016f34d4f2578c3e9dffc3f2b811ba30c0c9e7f3", "schedule": { - "appData": "0xf7c9274467f87d681e0fa21b3fb886b44546162a0ab94fd44d346a2b78af430a", + "appData": "0xebe836e7e0257003712b4884f2747a51dc706691305723c19e4bf7bec1c111ff", "buyToken": "0x177127622c4a00f3d409b75571e12cb3c8973d3c", "durationOfPart": 0n, - "effectiveStartTime": 1757526200n, - "minPartLimit": 1n, - "numberOfParts": 4n, - "partSellAmount": 25000000000000000n, + "effectiveStartTime": 1782855780n, + "minPartLimit": 774421599485031327n, + "numberOfParts": 2n, + "partSellAmount": 100000000000000000n, "receiver": "0x016f34d4f2578c3e9dffc3f2b811ba30c0c9e7f3", "sellToken": "0xaf204776c7245bf4147c2612bf6e5972ee483701", - "timeBetweenParts": 1800n, + "timeBetweenParts": 60n, }, "status": "Active", + "updatedAtBlock": 47352216n, }, { "chainId": 100, - "createdAt": 1757526710, - "eventId": "175752671000000000000001000000000042055888000000000000000150000000000000029", + "createdAt": 1782852985, + "eventId": "178285298500000000000001000000000046967969000000000000000450000000000000025", "executedAmounts": { "executedBuyAmount": 0n, + "executedFeeAmount": 0n, "executedSellAmount": 0n, }, - "hash": "0x24022b547eab0362735d633bd4ed6695a2bfad7e8d470b074e610f4b027f2f37", + "hash": "0x979bcc44b12484836ed02e56b24190071c3881b39e3a6368138860d518d4e16d", "owner": "0x62587918b2f00176646679509217a5a4d1ebbfd5", - "partOrders": [ - { - "buyAmount": 1n, - "createdAt": 1757526710, - "executedBuyAmount": null, - "executedSellAmount": null, - "feeAmount": 0n, - "orderUid": "0x3a1cff1951ea04175bdba196c45b2b7b371db5bf7679ee6fa7a7339e62ff740262587918b2f00176646679509217a5a4d1ebbfd568c1bb2d", - "sellAmount": 25000000000000000n, - "status": "expired", - "validTo": 1757526829, - }, - { - "buyAmount": 1n, - "createdAt": 1757526710, - "executedBuyAmount": null, - "executedSellAmount": null, - "feeAmount": 0n, - "orderUid": "0x3fc2139a409b95dfb3ff2bf903a3c05971225579e6a14f63218a5da44b29fb7d62587918b2f00176646679509217a5a4d1ebbfd568c1baf1", - "sellAmount": 25000000000000000n, - "status": "expired", - "validTo": 1757526769, - }, - ], + "partOrdersCount": 2, "resolvedOwner": "0x016f34d4f2578c3e9dffc3f2b811ba30c0c9e7f3", "schedule": { "appData": "0xf7c9274467f87d681e0fa21b3fb886b44546162a0ab94fd44d346a2b78af430a", "buyToken": "0x177127622c4a00f3d409b75571e12cb3c8973d3c", "durationOfPart": 0n, - "effectiveStartTime": 1757526710n, - "minPartLimit": 1n, - "numberOfParts": 4n, - "partSellAmount": 25000000000000000n, + "effectiveStartTime": 1782852985n, + "minPartLimit": 773026802095130550n, + "numberOfParts": 2n, + "partSellAmount": 100000000000000000n, "receiver": "0x016f34d4f2578c3e9dffc3f2b811ba30c0c9e7f3", "sellToken": "0xaf204776c7245bf4147c2612bf6e5972ee483701", - "timeBetweenParts": 30n, + "timeBetweenParts": 60n, }, "status": "Active", + "updatedAtBlock": 47352216n, }, { "chainId": 100, - "createdAt": 1757527335, - "eventId": "175752733500000000000001000000000042056007000000000000000050000000000000013", + "createdAt": 1782851510, + "eventId": "178285151000000000000001000000000046967681000000000000000450000000000000034", "executedAmounts": { - "executedBuyAmount": 678620197252801976n, + "executedBuyAmount": 1718396656651015389n, + "executedFeeAmount": 65038959954542n, "executedSellAmount": 200000000000000000n, }, - "hash": "0xf8edb9707569dec76a362bb6bac1909fdf43d5afe70beca3e422ec7b1bbaa237", + "hash": "0x87040d5bec3aad44a511b6b35be5f7078796947eab1a880c741c7763e27cd490", "owner": "0x62587918b2f00176646679509217a5a4d1ebbfd5", - "partOrders": [ - { - "buyAmount": 1n, - "createdAt": 1757527335, - "executedBuyAmount": 339434970519487884n, - "executedSellAmount": 100000000000000000n, - "feeAmount": 0n, - "orderUid": "0x00b3bd2d842331d914ec6b4136d8056aec35fc3dff3f158c3d2f876d764e2ca962587918b2f00176646679509217a5a4d1ebbfd568c1bf7e", - "sellAmount": 100000000000000000n, - "status": "fulfilled", - "validTo": 1757527934, - }, - { - "buyAmount": 1n, - "createdAt": 1757527335, - "executedBuyAmount": 339185226733314092n, - "executedSellAmount": 100000000000000000n, - "feeAmount": 0n, - "orderUid": "0xf600a33ded1bd6cac5c9d38fbc9c4bc3a5ab492fd2b34aecadd701f7d4bb805f62587918b2f00176646679509217a5a4d1ebbfd568c1be52", - "sellAmount": 100000000000000000n, - "status": "fulfilled", - "validTo": 1757527634, - }, - ], + "partOrdersCount": 2, "resolvedOwner": "0x016f34d4f2578c3e9dffc3f2b811ba30c0c9e7f3", "schedule": { "appData": "0xf7c9274467f87d681e0fa21b3fb886b44546162a0ab94fd44d346a2b78af430a", "buyToken": "0x177127622c4a00f3d409b75571e12cb3c8973d3c", "durationOfPart": 0n, - "effectiveStartTime": 1757527335n, + "effectiveStartTime": 1782851510n, "minPartLimit": 1n, "numberOfParts": 2n, "partSellAmount": 100000000000000000n, @@ -250,6 +200,7 @@ exports[`ProgrammaticOrderApi lists all known EOA TWAPs from the live programmat "timeBetweenParts": 300n, }, "status": "Completed", + "updatedAtBlock": 46967681n, }, { "chainId": 100, @@ -257,34 +208,12 @@ exports[`ProgrammaticOrderApi lists all known EOA TWAPs from the live programmat "eventId": "178284237000000000000001000000000046965898000000000000000250000000000000013", "executedAmounts": { "executedBuyAmount": 1703025006672763226n, + "executedFeeAmount": 64530465104521n, "executedSellAmount": 200000000000000000n, }, "hash": "0xa05b5dabbe1703fe82ee0d2ea89dc0397dcebfeb8ba1ec64591fb1d40de06422", "owner": "0x62587918b2f00176646679509217a5a4d1ebbfd5", - "partOrders": [ - { - "buyAmount": 1n, - "createdAt": 1782842370, - "executedBuyAmount": 846746200516767824n, - "executedSellAmount": 100000000000000000n, - "feeAmount": 0n, - "orderUid": "0x144eb01438d03b3b68e810b407ed884fb40384826736c7d8096b2b508c55f8b962587918b2f00176646679509217a5a4d1ebbfd56a440659", - "sellAmount": 100000000000000000n, - "status": "fulfilled", - "validTo": 1782842969, - }, - { - "buyAmount": 1n, - "createdAt": 1782842370, - "executedBuyAmount": 856278806155995402n, - "executedSellAmount": 100000000000000000n, - "feeAmount": 0n, - "orderUid": "0x6ae91e15d98477537ec583371381627d16724589d79590fccd314e8dc9ebf9f062587918b2f00176646679509217a5a4d1ebbfd56a44052d", - "sellAmount": 100000000000000000n, - "status": "fulfilled", - "validTo": 1782842669, - }, - ], + "partOrdersCount": 2, "resolvedOwner": "0x016f34d4f2578c3e9dffc3f2b811ba30c0c9e7f3", "schedule": { "appData": "0xf7c9274467f87d681e0fa21b3fb886b44546162a0ab94fd44d346a2b78af430a", @@ -299,47 +228,26 @@ exports[`ProgrammaticOrderApi lists all known EOA TWAPs from the live programmat "timeBetweenParts": 300n, }, "status": "Completed", + "updatedAtBlock": 46965898n, }, { "chainId": 100, - "createdAt": 1782851510, - "eventId": "178285151000000000000001000000000046967681000000000000000450000000000000034", + "createdAt": 1757527335, + "eventId": "175752733500000000000001000000000042056007000000000000000050000000000000013", "executedAmounts": { - "executedBuyAmount": 1718396656651015389n, + "executedBuyAmount": 678620197252801976n, + "executedFeeAmount": 722702729572645n, "executedSellAmount": 200000000000000000n, }, - "hash": "0x87040d5bec3aad44a511b6b35be5f7078796947eab1a880c741c7763e27cd490", + "hash": "0xf8edb9707569dec76a362bb6bac1909fdf43d5afe70beca3e422ec7b1bbaa237", "owner": "0x62587918b2f00176646679509217a5a4d1ebbfd5", - "partOrders": [ - { - "buyAmount": 1n, - "createdAt": 1782851510, - "executedBuyAmount": 859336167658404014n, - "executedSellAmount": 100000000000000000n, - "feeAmount": 0n, - "orderUid": "0x69c81c84836821956db2b788a5d006f4bc0da9ac9de19636701622d8555ea49162587918b2f00176646679509217a5a4d1ebbfd56a442a0d", - "sellAmount": 100000000000000000n, - "status": "fulfilled", - "validTo": 1782852109, - }, - { - "buyAmount": 1n, - "createdAt": 1782851510, - "executedBuyAmount": 859060488992611375n, - "executedSellAmount": 100000000000000000n, - "feeAmount": 0n, - "orderUid": "0x79128220bbb235a9181f25e754e51ecb6aadd014a02c06cc041a09b00529780e62587918b2f00176646679509217a5a4d1ebbfd56a4428e1", - "sellAmount": 100000000000000000n, - "status": "fulfilled", - "validTo": 1782851809, - }, - ], + "partOrdersCount": 2, "resolvedOwner": "0x016f34d4f2578c3e9dffc3f2b811ba30c0c9e7f3", "schedule": { "appData": "0xf7c9274467f87d681e0fa21b3fb886b44546162a0ab94fd44d346a2b78af430a", "buyToken": "0x177127622c4a00f3d409b75571e12cb3c8973d3c", "durationOfPart": 0n, - "effectiveStartTime": 1782851510n, + "effectiveStartTime": 1757527335n, "minPartLimit": 1n, "numberOfParts": 2n, "partSellAmount": 100000000000000000n, @@ -348,211 +256,165 @@ exports[`ProgrammaticOrderApi lists all known EOA TWAPs from the live programmat "timeBetweenParts": 300n, }, "status": "Completed", + "updatedAtBlock": 42056007n, }, { "chainId": 100, - "createdAt": 1782852985, - "eventId": "178285298500000000000001000000000046967969000000000000000450000000000000025", + "createdAt": 1757526710, + "eventId": "175752671000000000000001000000000042055888000000000000000150000000000000029", "executedAmounts": { "executedBuyAmount": 0n, + "executedFeeAmount": 0n, "executedSellAmount": 0n, }, - "hash": "0x979bcc44b12484836ed02e56b24190071c3881b39e3a6368138860d518d4e16d", + "hash": "0x24022b547eab0362735d633bd4ed6695a2bfad7e8d470b074e610f4b027f2f37", "owner": "0x62587918b2f00176646679509217a5a4d1ebbfd5", - "partOrders": [], + "partOrdersCount": 3, "resolvedOwner": "0x016f34d4f2578c3e9dffc3f2b811ba30c0c9e7f3", "schedule": { "appData": "0xf7c9274467f87d681e0fa21b3fb886b44546162a0ab94fd44d346a2b78af430a", "buyToken": "0x177127622c4a00f3d409b75571e12cb3c8973d3c", "durationOfPart": 0n, - "effectiveStartTime": 1782852985n, - "minPartLimit": 773026802095130550n, - "numberOfParts": 2n, - "partSellAmount": 100000000000000000n, + "effectiveStartTime": 1757526710n, + "minPartLimit": 1n, + "numberOfParts": 4n, + "partSellAmount": 25000000000000000n, "receiver": "0x016f34d4f2578c3e9dffc3f2b811ba30c0c9e7f3", "sellToken": "0xaf204776c7245bf4147c2612bf6e5972ee483701", - "timeBetweenParts": 60n, + "timeBetweenParts": 30n, }, "status": "Active", + "updatedAtBlock": 47352176n, }, { "chainId": 100, - "createdAt": 1782855780, - "eventId": "178285578000000000000001000000000046968514000000000000000450000000000000024", + "createdAt": 1757526200, + "eventId": "175752620000000000000001000000000042055786000000000000000050000000000000008", "executedAmounts": { - "executedBuyAmount": 0n, - "executedSellAmount": 0n, + "executedBuyAmount": 84756756849678668n, + "executedFeeAmount": 832386762201548n, + "executedSellAmount": 25000000000000000n, }, - "hash": "0x28b61a46088d4082b10aca722efa0d2404106686594f2a82936893f2d7d22f06", + "hash": "0x2bbf5f8727c87a4ccfd2ef1332092caa6ea7173a3b5b91ff11fe24df71e0e5f8", "owner": "0x62587918b2f00176646679509217a5a4d1ebbfd5", - "partOrders": [], + "partOrdersCount": 4, "resolvedOwner": "0x016f34d4f2578c3e9dffc3f2b811ba30c0c9e7f3", "schedule": { - "appData": "0xebe836e7e0257003712b4884f2747a51dc706691305723c19e4bf7bec1c111ff", + "appData": "0xf7c9274467f87d681e0fa21b3fb886b44546162a0ab94fd44d346a2b78af430a", "buyToken": "0x177127622c4a00f3d409b75571e12cb3c8973d3c", "durationOfPart": 0n, - "effectiveStartTime": 1782855780n, - "minPartLimit": 774421599485031327n, - "numberOfParts": 2n, - "partSellAmount": 100000000000000000n, + "effectiveStartTime": 1757526200n, + "minPartLimit": 1n, + "numberOfParts": 4n, + "partSellAmount": 25000000000000000n, "receiver": "0x016f34d4f2578c3e9dffc3f2b811ba30c0c9e7f3", "sellToken": "0xaf204776c7245bf4147c2612bf6e5972ee483701", - "timeBetweenParts": 60n, + "timeBetweenParts": 1800n, }, "status": "Active", + "updatedAtBlock": 47352176n, }, { "chainId": 100, - "createdAt": 1782856350, - "eventId": "178285635000000000000001000000000046968626000000000000001750000000000000087", + "createdAt": 1757525910, + "eventId": "175752591000000000000001000000000042055728000000000000000050000000000000015", "executedAmounts": { - "executedBuyAmount": 0n, - "executedSellAmount": 0n, + "executedBuyAmount": 84880520382659281n, + "executedFeeAmount": 832300053240359n, + "executedSellAmount": 25000000000000000n, }, - "hash": "0xf5e3a9d322d4985cd9fc10b84e16f0358c720592ec968ec3921dc65fd702be0b", + "hash": "0x4ac7633559e7c90792574306ae2c3bbae7588681a0219c3668b61cc6da2be10b", "owner": "0x62587918b2f00176646679509217a5a4d1ebbfd5", - "partOrders": [], + "partOrdersCount": 4, "resolvedOwner": "0x016f34d4f2578c3e9dffc3f2b811ba30c0c9e7f3", "schedule": { - "appData": "0xdfc54d9c8b6a28a0c03947debf1a0f929184919579c926cf3de23c5881eb524a", + "appData": "0xf7c9274467f87d681e0fa21b3fb886b44546162a0ab94fd44d346a2b78af430a", "buyToken": "0x177127622c4a00f3d409b75571e12cb3c8973d3c", "durationOfPart": 0n, - "effectiveStartTime": 1782856350n, - "minPartLimit": 774221321423698851n, - "numberOfParts": 2n, - "partSellAmount": 100000000000000000n, + "effectiveStartTime": 1757525910n, + "minPartLimit": 1n, + "numberOfParts": 4n, + "partSellAmount": 25000000000000000n, "receiver": "0x016f34d4f2578c3e9dffc3f2b811ba30c0c9e7f3", "sellToken": "0xaf204776c7245bf4147c2612bf6e5972ee483701", - "timeBetweenParts": 60n, + "timeBetweenParts": 1800n, }, "status": "Active", + "updatedAtBlock": 47352176n, }, { "chainId": 100, - "createdAt": 1782860530, - "eventId": "178286053000000000000001000000000046969430000000000000000850000000000000038", + "createdAt": 1757524940, + "eventId": "175752494000000000000001000000000042055539000000000000000050000000000000008", "executedAmounts": { - "executedBuyAmount": 862367902269494618n, - "executedSellAmount": 100000000000000000n, + "executedBuyAmount": 168880044359503662n, + "executedFeeAmount": 499999999999996n, + "executedSellAmount": 50000000000000000n, }, - "hash": "0x5ec4e09e073067d53acbcb81a94f926608ce075f0f04842e91a3b22ca2ea333d", + "hash": "0x618fc1893e8fc19a353734250d775dc3fa62df205ed545cc66a17a7ab1197a0c", "owner": "0x62587918b2f00176646679509217a5a4d1ebbfd5", - "partOrders": [ - { - "buyAmount": 776021953726338606n, - "createdAt": 1782860530, - "executedBuyAmount": 862367902269494618n, - "executedSellAmount": 100000000000000000n, - "feeAmount": 0n, - "orderUid": "0x24106d3b23805bd9b992bfdba43bd692de4582180acf44ca4edb0d698312562d62587918b2f00176646679509217a5a4d1ebbfd56a444b69", - "sellAmount": 100000000000000000n, - "status": "fulfilled", - "validTo": 1782860649, - }, - { - "buyAmount": 776021953726338606n, - "createdAt": 1782860530, - "executedBuyAmount": 0n, - "executedSellAmount": 0n, - "feeAmount": 0n, - "orderUid": "0xd8d939a5b9bfe1ede5357ec33c48748a1e59567530a6a44a634d615c2a35abfc62587918b2f00176646679509217a5a4d1ebbfd56a444be1", - "sellAmount": 100000000000000000n, - "status": "expired", - "validTo": 1782860769, - }, - ], + "partOrdersCount": 4, "resolvedOwner": "0x016f34d4f2578c3e9dffc3f2b811ba30c0c9e7f3", "schedule": { - "appData": "0x302493bc6206f0065533621e960c9e5411c58044ea0b5a80b4f61edac00ba2f3", + "appData": "0xf7c9274467f87d681e0fa21b3fb886b44546162a0ab94fd44d346a2b78af430a", "buyToken": "0x177127622c4a00f3d409b75571e12cb3c8973d3c", "durationOfPart": 0n, - "effectiveStartTime": 1782860530n, - "minPartLimit": 776021953726338606n, - "numberOfParts": 2n, - "partSellAmount": 100000000000000000n, + "effectiveStartTime": 1757524940n, + "minPartLimit": 1n, + "numberOfParts": 4n, + "partSellAmount": 25000000000000000n, "receiver": "0x016f34d4f2578c3e9dffc3f2b811ba30c0c9e7f3", "sellToken": "0xaf204776c7245bf4147c2612bf6e5972ee483701", - "timeBetweenParts": 120n, + "timeBetweenParts": 1800n, }, - "status": "Completed", + "status": "Active", + "updatedAtBlock": 47352176n, }, { "chainId": 100, - "createdAt": 1782860530, - "eventId": "178286053000000000000001000000000046969430000000000000000850000000000000041", + "createdAt": 1757524180, + "eventId": "175752418000000000000001000000000042055392000000000000000250000000000000021", "executedAmounts": { - "executedBuyAmount": 862488114171327656n, - "executedSellAmount": 100000000000000000n, + "executedBuyAmount": 0n, + "executedFeeAmount": 0n, + "executedSellAmount": 0n, }, - "hash": "0xc6e1f5bc9f8cd772e89ab65222c6e5511fc121be2bb3cae3889ab666fa506c80", + "hash": "0x296f02547402281d040fa79d59ef61631e4a18c317654cefa1bf2226224c9fd9", "owner": "0x62587918b2f00176646679509217a5a4d1ebbfd5", - "partOrders": [ - { - "buyAmount": 776195194471677430n, - "createdAt": 1782860530, - "executedBuyAmount": 0n, - "executedSellAmount": 0n, - "feeAmount": 0n, - "orderUid": "0x4f44aa86f906fc8b796a602e1cbf6edba7a0542c0d842a64e2108237f430098c62587918b2f00176646679509217a5a4d1ebbfd56a444be1", - "sellAmount": 100000000000000000n, - "status": "expired", - "validTo": 1782860769, - }, - { - "buyAmount": 776195194471677430n, - "createdAt": 1782860530, - "executedBuyAmount": 862488114171327656n, - "executedSellAmount": 100000000000000000n, - "feeAmount": 0n, - "orderUid": "0x693a30318b8208f78b5028b237bb3cf3cef8c7c7c5a74773b02d4a0dec6f334762587918b2f00176646679509217a5a4d1ebbfd56a444b69", - "sellAmount": 100000000000000000n, - "status": "fulfilled", - "validTo": 1782860649, - }, - ], + "partOrdersCount": 4, "resolvedOwner": "0x016f34d4f2578c3e9dffc3f2b811ba30c0c9e7f3", "schedule": { - "appData": "0xf248f0d7eee2efe935d59e82d615db8d2a47f7929698e4712416cb608e8877d9", + "appData": "0xf7c9274467f87d681e0fa21b3fb886b44546162a0ab94fd44d346a2b78af430a", "buyToken": "0x177127622c4a00f3d409b75571e12cb3c8973d3c", "durationOfPart": 0n, - "effectiveStartTime": 1782860530n, - "minPartLimit": 776195194471677430n, - "numberOfParts": 2n, - "partSellAmount": 100000000000000000n, + "effectiveStartTime": 1757524180n, + "minPartLimit": 0n, + "numberOfParts": 4n, + "partSellAmount": 25000000000000000n, "receiver": "0x016f34d4f2578c3e9dffc3f2b811ba30c0c9e7f3", "sellToken": "0xaf204776c7245bf4147c2612bf6e5972ee483701", - "timeBetweenParts": 120n, + "timeBetweenParts": 1800n, }, - "status": "Completed", + "status": "Active", + "updatedAtBlock": 47352176n, }, ] `; -exports[`ProgrammaticOrderApi lists all known Safe TWAPs from the live programmatic orders API 1`] = ` +exports[`ProgrammaticOrderApi lists the latest Safe TWAP parents from the live programmatic orders API 1`] = ` [ { "chainId": 100, "createdAt": 1784289275, "eventId": "178428927500000000000001000000000047248688000000000000002250000000000000129", "executedAmounts": { - "executedBuyAmount": 7938540649899270304n, - "executedSellAmount": 1150000000000000000n, + "executedBuyAmount": 15897778052264678730n, + "executedFeeAmount": 635102768951199n, + "executedSellAmount": 2300000000000000000n, }, "hash": "0x9aa36d973466810ad97a07041496c6f7776e7a2ab3cc63c47584006673f14050", "owner": "0xaa248d5328c7d781a96d93d7d013bcf393157bb4", - "partOrders": [ - { - "buyAmount": 7143588130746906513n, - "createdAt": 1784289275, - "executedBuyAmount": 7938540649899270304n, - "executedSellAmount": 1150000000000000000n, - "feeAmount": 0n, - "orderUid": "0x158849e34f900227b0fb25e349da2617168af20fba9fca677ed02a3b01a5a101aa248d5328c7d781a96d93d7d013bcf393157bb46a5a1f02", - "sellAmount": 1150000000000000000n, - "status": "fulfilled", - "validTo": 1784291074, - }, - ], + "partOrdersCount": 2, "resolvedOwner": "0xaa248d5328c7d781a96d93d7d013bcf393157bb4", "schedule": { "appData": "0x832ef833029247a34f08ea828c3d19301241443de679ecfe2d53ead11c983a59", @@ -566,7 +428,8 @@ exports[`ProgrammaticOrderApi lists all known Safe TWAPs from the live programma "sellToken": "0xe91d153e0b41518a2ce8dd3d7944fa863463a97d", "timeBetweenParts": 1800n, }, - "status": "Active", + "status": "Completed", + "updatedAtBlock": 47248688n, }, ] `; diff --git a/packages/composable/tests/graphql.spec.ts b/packages/composable/tests/graphql.spec.ts new file mode 100644 index 000000000..8ecc3ca05 --- /dev/null +++ b/packages/composable/tests/graphql.spec.ts @@ -0,0 +1,77 @@ +import * as v from 'valibot' + +import { GraphqlClient, GraphqlClientError } from '../src/programmaticOrders/graphql' + +describe('GraphqlClient', () => { + afterEach(() => jest.restoreAllMocks()) + + it('parses one page', async () => { + const fetchMock = jest.spyOn(globalThis, 'fetch').mockResolvedValueOnce(pageResponse(['first'], 3)) + const client = new GraphqlClient('https://example.com/api') + + const page = await client.queryPage({ + query: 'query Nodes($offset: Int!) { nodes(offset: $offset) { items totalCount } }', + page: 'nodes', + variables: { offset: 1 }, + itemSchema: v.string(), + }) + + expect(page).toEqual({ items: ['first'], totalCount: 3 }) + expect(fetchMock).toHaveBeenCalledWith('https://example.com/api/graphql', expect.any(Object)) + expect(JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body)).variables).toEqual({ offset: 1 }) + }) + + it('rejects invalid envelopes and pages', async () => { + const fetchMock = jest.spyOn(globalThis, 'fetch') + const client = new GraphqlClient('https://example.com/graphql') + + fetchMock.mockResolvedValueOnce(jsonResponse({ value: 1 })) + + await expect(client.query('query Value { value }', {})).rejects.toThrow('GraphQL response.data is missing') + + fetchMock.mockResolvedValueOnce(pageResponse([1], 1)) + + await expect( + client.queryPage({ + query: 'query Nodes { nodes }', + page: 'nodes', + variables: {}, + itemSchema: v.string(), + }), + ).rejects.toThrow('Invalid GraphQL page: nodes') + + fetchMock.mockResolvedValueOnce(pageResponse([], -1)) + + await expect( + client.queryPage({ + query: 'query Nodes { nodes }', + page: 'nodes', + variables: {}, + itemSchema: v.string(), + }), + ).rejects.toThrow('Invalid GraphQL page: nodes') + }) + + it('rejects invalid endpoints', () => { + expect(() => new GraphqlClient('not a URL')).toThrow(GraphqlClientError) + }) +}) + +function pageResponse(items: unknown[], totalCount: number): Promise { + return Promise.resolve( + jsonResponse({ + data: { + nodes: { + items, + totalCount, + }, + }, + }), + ) +} + +function jsonResponse(payload: unknown): Response { + return new Response(JSON.stringify(payload), { + headers: { 'Content-Type': 'application/json' }, + }) +} From 566faeba52a797538c8944df36f976d21e0cb2fe Mon Sep 17 00:00:00 2001 From: Daniel Constantin Date: Mon, 27 Jul 2026 13:36:27 +0300 Subject: [PATCH 7/8] refactor: return number not bigint for t0, n, t and span --- .../src/programmaticOrders/schemas.ts | 5 + .../src/programmaticOrders/twap-schemas.ts | 11 +- .../src/programmaticOrders/twap-types.ts | 8 +- .../tests/ProgrammaticOrderApi.spec.ts | 6 + .../ProgrammaticOrderApi.spec.ts.snap | 112 +++++++++--------- 5 files changed, 77 insertions(+), 65 deletions(-) diff --git a/packages/composable/src/programmaticOrders/schemas.ts b/packages/composable/src/programmaticOrders/schemas.ts index 8329f688d..b177c2f34 100644 --- a/packages/composable/src/programmaticOrders/schemas.ts +++ b/packages/composable/src/programmaticOrders/schemas.ts @@ -51,6 +51,11 @@ export const UINT256_SCHEMA = v.pipe( v.transform((value) => BigInt(value)), v.maxValue(MAX_UINT256, 'must fit uint256'), ) +export const SAFE_UINT256_NUMBER_SCHEMA = v.pipe( + UINT256_SCHEMA, + v.transform(Number), + v.safeInteger('must be a safe integer'), +) export const TIMESTAMP_SCHEMA = v.pipe( UINT256_SCHEMA, v.maxValue(MAX_DATE_SECONDS, 'is outside the supported date range'), diff --git a/packages/composable/src/programmaticOrders/twap-schemas.ts b/packages/composable/src/programmaticOrders/twap-schemas.ts index 997e0e131..78697b6f9 100644 --- a/packages/composable/src/programmaticOrders/twap-schemas.ts +++ b/packages/composable/src/programmaticOrders/twap-schemas.ts @@ -8,6 +8,7 @@ import { ORDER_UID_SCHEMA, PROGRAMMATIC_ORDER_STATUS_SCHEMA, SAFE_INTEGER_SCHEMA, + SAFE_UINT256_NUMBER_SCHEMA, SUPPORTED_EVM_CHAIN_ID_SCHEMA, TIMESTAMP_SCHEMA, UINT32_SCHEMA, @@ -34,10 +35,10 @@ const TWAP_SCHEDULE_SCHEMA = v.object({ receiver: ADDRESS_SCHEMA, partSellAmount: UINT256_SCHEMA, minPartLimit: UINT256_SCHEMA, - t0: UINT256_SCHEMA, - n: UINT256_SCHEMA, - t: UINT256_SCHEMA, - span: UINT256_SCHEMA, + t0: SAFE_UINT256_NUMBER_SCHEMA, + n: SAFE_UINT256_NUMBER_SCHEMA, + t: SAFE_UINT256_NUMBER_SCHEMA, + span: SAFE_UINT256_NUMBER_SCHEMA, appData: BYTES_32_SCHEMA, }) @@ -82,7 +83,7 @@ export const TWAP_PARENT_SCHEMA = v.pipe( }, schedule: { ...scheduleParams, - effectiveStartTime: t0 === 0n ? BigInt(createdAt) : t0, + effectiveStartTime: t0 === 0 ? createdAt : t0, numberOfParts: n, timeBetweenParts: t, durationOfPart: span, diff --git a/packages/composable/src/programmaticOrders/twap-types.ts b/packages/composable/src/programmaticOrders/twap-types.ts index 506c362c5..8acbb98aa 100644 --- a/packages/composable/src/programmaticOrders/twap-types.ts +++ b/packages/composable/src/programmaticOrders/twap-types.ts @@ -48,12 +48,12 @@ export interface TwapSchedule { partSellAmount: bigint minPartLimit: bigint /** Effective Unix start time in seconds. */ - effectiveStartTime: bigint - numberOfParts: bigint + effectiveStartTime: number + numberOfParts: number /** Seconds between consecutive parts. */ - timeBetweenParts: bigint + timeBetweenParts: number /** Part validity in seconds; zero means the full interval. */ - durationOfPart: bigint + durationOfPart: number appData: string } diff --git a/packages/composable/tests/ProgrammaticOrderApi.spec.ts b/packages/composable/tests/ProgrammaticOrderApi.spec.ts index bdc661f0d..3c46ab194 100644 --- a/packages/composable/tests/ProgrammaticOrderApi.spec.ts +++ b/packages/composable/tests/ProgrammaticOrderApi.spec.ts @@ -127,6 +127,12 @@ describe('ProgrammaticOrderApi', () => { { eventId: 'newer-event', createdAt: 200 }, { eventId: 'older-event', createdAt: 100 }, ]) + expect(page.items[0]?.schedule).toMatchObject({ + effectiveStartTime: 200, + numberOfParts: 1, + timeBetweenParts: 1, + durationOfPart: 0, + }) expect(page.items[0]).not.toHaveProperty('partOrders') }) diff --git a/packages/composable/tests/__snapshots__/ProgrammaticOrderApi.spec.ts.snap b/packages/composable/tests/__snapshots__/ProgrammaticOrderApi.spec.ts.snap index aabd3d996..20e0a64e1 100644 --- a/packages/composable/tests/__snapshots__/ProgrammaticOrderApi.spec.ts.snap +++ b/packages/composable/tests/__snapshots__/ProgrammaticOrderApi.spec.ts.snap @@ -50,14 +50,14 @@ exports[`ProgrammaticOrderApi lists the latest EOA TWAP parents from the live pr "schedule": { "appData": "0xf248f0d7eee2efe935d59e82d615db8d2a47f7929698e4712416cb608e8877d9", "buyToken": "0x177127622c4a00f3d409b75571e12cb3c8973d3c", - "durationOfPart": 0n, - "effectiveStartTime": 1782860530n, + "durationOfPart": 0, + "effectiveStartTime": 1782860530, "minPartLimit": 776195194471677430n, - "numberOfParts": 2n, + "numberOfParts": 2, "partSellAmount": 100000000000000000n, "receiver": "0x016f34d4f2578c3e9dffc3f2b811ba30c0c9e7f3", "sellToken": "0xaf204776c7245bf4147c2612bf6e5972ee483701", - "timeBetweenParts": 120n, + "timeBetweenParts": 120, }, "status": "Completed", "updatedAtBlock": 46969430n, @@ -78,14 +78,14 @@ exports[`ProgrammaticOrderApi lists the latest EOA TWAP parents from the live pr "schedule": { "appData": "0x302493bc6206f0065533621e960c9e5411c58044ea0b5a80b4f61edac00ba2f3", "buyToken": "0x177127622c4a00f3d409b75571e12cb3c8973d3c", - "durationOfPart": 0n, - "effectiveStartTime": 1782860530n, + "durationOfPart": 0, + "effectiveStartTime": 1782860530, "minPartLimit": 776021953726338606n, - "numberOfParts": 2n, + "numberOfParts": 2, "partSellAmount": 100000000000000000n, "receiver": "0x016f34d4f2578c3e9dffc3f2b811ba30c0c9e7f3", "sellToken": "0xaf204776c7245bf4147c2612bf6e5972ee483701", - "timeBetweenParts": 120n, + "timeBetweenParts": 120, }, "status": "Completed", "updatedAtBlock": 46969430n, @@ -106,14 +106,14 @@ exports[`ProgrammaticOrderApi lists the latest EOA TWAP parents from the live pr "schedule": { "appData": "0xdfc54d9c8b6a28a0c03947debf1a0f929184919579c926cf3de23c5881eb524a", "buyToken": "0x177127622c4a00f3d409b75571e12cb3c8973d3c", - "durationOfPart": 0n, - "effectiveStartTime": 1782856350n, + "durationOfPart": 0, + "effectiveStartTime": 1782856350, "minPartLimit": 774221321423698851n, - "numberOfParts": 2n, + "numberOfParts": 2, "partSellAmount": 100000000000000000n, "receiver": "0x016f34d4f2578c3e9dffc3f2b811ba30c0c9e7f3", "sellToken": "0xaf204776c7245bf4147c2612bf6e5972ee483701", - "timeBetweenParts": 60n, + "timeBetweenParts": 60, }, "status": "Active", "updatedAtBlock": 47352216n, @@ -134,14 +134,14 @@ exports[`ProgrammaticOrderApi lists the latest EOA TWAP parents from the live pr "schedule": { "appData": "0xebe836e7e0257003712b4884f2747a51dc706691305723c19e4bf7bec1c111ff", "buyToken": "0x177127622c4a00f3d409b75571e12cb3c8973d3c", - "durationOfPart": 0n, - "effectiveStartTime": 1782855780n, + "durationOfPart": 0, + "effectiveStartTime": 1782855780, "minPartLimit": 774421599485031327n, - "numberOfParts": 2n, + "numberOfParts": 2, "partSellAmount": 100000000000000000n, "receiver": "0x016f34d4f2578c3e9dffc3f2b811ba30c0c9e7f3", "sellToken": "0xaf204776c7245bf4147c2612bf6e5972ee483701", - "timeBetweenParts": 60n, + "timeBetweenParts": 60, }, "status": "Active", "updatedAtBlock": 47352216n, @@ -162,14 +162,14 @@ exports[`ProgrammaticOrderApi lists the latest EOA TWAP parents from the live pr "schedule": { "appData": "0xf7c9274467f87d681e0fa21b3fb886b44546162a0ab94fd44d346a2b78af430a", "buyToken": "0x177127622c4a00f3d409b75571e12cb3c8973d3c", - "durationOfPart": 0n, - "effectiveStartTime": 1782852985n, + "durationOfPart": 0, + "effectiveStartTime": 1782852985, "minPartLimit": 773026802095130550n, - "numberOfParts": 2n, + "numberOfParts": 2, "partSellAmount": 100000000000000000n, "receiver": "0x016f34d4f2578c3e9dffc3f2b811ba30c0c9e7f3", "sellToken": "0xaf204776c7245bf4147c2612bf6e5972ee483701", - "timeBetweenParts": 60n, + "timeBetweenParts": 60, }, "status": "Active", "updatedAtBlock": 47352216n, @@ -190,14 +190,14 @@ exports[`ProgrammaticOrderApi lists the latest EOA TWAP parents from the live pr "schedule": { "appData": "0xf7c9274467f87d681e0fa21b3fb886b44546162a0ab94fd44d346a2b78af430a", "buyToken": "0x177127622c4a00f3d409b75571e12cb3c8973d3c", - "durationOfPart": 0n, - "effectiveStartTime": 1782851510n, + "durationOfPart": 0, + "effectiveStartTime": 1782851510, "minPartLimit": 1n, - "numberOfParts": 2n, + "numberOfParts": 2, "partSellAmount": 100000000000000000n, "receiver": "0x016f34d4f2578c3e9dffc3f2b811ba30c0c9e7f3", "sellToken": "0xaf204776c7245bf4147c2612bf6e5972ee483701", - "timeBetweenParts": 300n, + "timeBetweenParts": 300, }, "status": "Completed", "updatedAtBlock": 46967681n, @@ -218,14 +218,14 @@ exports[`ProgrammaticOrderApi lists the latest EOA TWAP parents from the live pr "schedule": { "appData": "0xf7c9274467f87d681e0fa21b3fb886b44546162a0ab94fd44d346a2b78af430a", "buyToken": "0x177127622c4a00f3d409b75571e12cb3c8973d3c", - "durationOfPart": 0n, - "effectiveStartTime": 1782842370n, + "durationOfPart": 0, + "effectiveStartTime": 1782842370, "minPartLimit": 1n, - "numberOfParts": 2n, + "numberOfParts": 2, "partSellAmount": 100000000000000000n, "receiver": "0x016f34d4f2578c3e9dffc3f2b811ba30c0c9e7f3", "sellToken": "0xaf204776c7245bf4147c2612bf6e5972ee483701", - "timeBetweenParts": 300n, + "timeBetweenParts": 300, }, "status": "Completed", "updatedAtBlock": 46965898n, @@ -246,14 +246,14 @@ exports[`ProgrammaticOrderApi lists the latest EOA TWAP parents from the live pr "schedule": { "appData": "0xf7c9274467f87d681e0fa21b3fb886b44546162a0ab94fd44d346a2b78af430a", "buyToken": "0x177127622c4a00f3d409b75571e12cb3c8973d3c", - "durationOfPart": 0n, - "effectiveStartTime": 1757527335n, + "durationOfPart": 0, + "effectiveStartTime": 1757527335, "minPartLimit": 1n, - "numberOfParts": 2n, + "numberOfParts": 2, "partSellAmount": 100000000000000000n, "receiver": "0x016f34d4f2578c3e9dffc3f2b811ba30c0c9e7f3", "sellToken": "0xaf204776c7245bf4147c2612bf6e5972ee483701", - "timeBetweenParts": 300n, + "timeBetweenParts": 300, }, "status": "Completed", "updatedAtBlock": 42056007n, @@ -274,14 +274,14 @@ exports[`ProgrammaticOrderApi lists the latest EOA TWAP parents from the live pr "schedule": { "appData": "0xf7c9274467f87d681e0fa21b3fb886b44546162a0ab94fd44d346a2b78af430a", "buyToken": "0x177127622c4a00f3d409b75571e12cb3c8973d3c", - "durationOfPart": 0n, - "effectiveStartTime": 1757526710n, + "durationOfPart": 0, + "effectiveStartTime": 1757526710, "minPartLimit": 1n, - "numberOfParts": 4n, + "numberOfParts": 4, "partSellAmount": 25000000000000000n, "receiver": "0x016f34d4f2578c3e9dffc3f2b811ba30c0c9e7f3", "sellToken": "0xaf204776c7245bf4147c2612bf6e5972ee483701", - "timeBetweenParts": 30n, + "timeBetweenParts": 30, }, "status": "Active", "updatedAtBlock": 47352176n, @@ -302,14 +302,14 @@ exports[`ProgrammaticOrderApi lists the latest EOA TWAP parents from the live pr "schedule": { "appData": "0xf7c9274467f87d681e0fa21b3fb886b44546162a0ab94fd44d346a2b78af430a", "buyToken": "0x177127622c4a00f3d409b75571e12cb3c8973d3c", - "durationOfPart": 0n, - "effectiveStartTime": 1757526200n, + "durationOfPart": 0, + "effectiveStartTime": 1757526200, "minPartLimit": 1n, - "numberOfParts": 4n, + "numberOfParts": 4, "partSellAmount": 25000000000000000n, "receiver": "0x016f34d4f2578c3e9dffc3f2b811ba30c0c9e7f3", "sellToken": "0xaf204776c7245bf4147c2612bf6e5972ee483701", - "timeBetweenParts": 1800n, + "timeBetweenParts": 1800, }, "status": "Active", "updatedAtBlock": 47352176n, @@ -330,14 +330,14 @@ exports[`ProgrammaticOrderApi lists the latest EOA TWAP parents from the live pr "schedule": { "appData": "0xf7c9274467f87d681e0fa21b3fb886b44546162a0ab94fd44d346a2b78af430a", "buyToken": "0x177127622c4a00f3d409b75571e12cb3c8973d3c", - "durationOfPart": 0n, - "effectiveStartTime": 1757525910n, + "durationOfPart": 0, + "effectiveStartTime": 1757525910, "minPartLimit": 1n, - "numberOfParts": 4n, + "numberOfParts": 4, "partSellAmount": 25000000000000000n, "receiver": "0x016f34d4f2578c3e9dffc3f2b811ba30c0c9e7f3", "sellToken": "0xaf204776c7245bf4147c2612bf6e5972ee483701", - "timeBetweenParts": 1800n, + "timeBetweenParts": 1800, }, "status": "Active", "updatedAtBlock": 47352176n, @@ -358,14 +358,14 @@ exports[`ProgrammaticOrderApi lists the latest EOA TWAP parents from the live pr "schedule": { "appData": "0xf7c9274467f87d681e0fa21b3fb886b44546162a0ab94fd44d346a2b78af430a", "buyToken": "0x177127622c4a00f3d409b75571e12cb3c8973d3c", - "durationOfPart": 0n, - "effectiveStartTime": 1757524940n, + "durationOfPart": 0, + "effectiveStartTime": 1757524940, "minPartLimit": 1n, - "numberOfParts": 4n, + "numberOfParts": 4, "partSellAmount": 25000000000000000n, "receiver": "0x016f34d4f2578c3e9dffc3f2b811ba30c0c9e7f3", "sellToken": "0xaf204776c7245bf4147c2612bf6e5972ee483701", - "timeBetweenParts": 1800n, + "timeBetweenParts": 1800, }, "status": "Active", "updatedAtBlock": 47352176n, @@ -386,14 +386,14 @@ exports[`ProgrammaticOrderApi lists the latest EOA TWAP parents from the live pr "schedule": { "appData": "0xf7c9274467f87d681e0fa21b3fb886b44546162a0ab94fd44d346a2b78af430a", "buyToken": "0x177127622c4a00f3d409b75571e12cb3c8973d3c", - "durationOfPart": 0n, - "effectiveStartTime": 1757524180n, + "durationOfPart": 0, + "effectiveStartTime": 1757524180, "minPartLimit": 0n, - "numberOfParts": 4n, + "numberOfParts": 4, "partSellAmount": 25000000000000000n, "receiver": "0x016f34d4f2578c3e9dffc3f2b811ba30c0c9e7f3", "sellToken": "0xaf204776c7245bf4147c2612bf6e5972ee483701", - "timeBetweenParts": 1800n, + "timeBetweenParts": 1800, }, "status": "Active", "updatedAtBlock": 47352176n, @@ -419,14 +419,14 @@ exports[`ProgrammaticOrderApi lists the latest Safe TWAP parents from the live p "schedule": { "appData": "0x832ef833029247a34f08ea828c3d19301241443de679ecfe2d53ead11c983a59", "buyToken": "0x177127622c4a00f3d409b75571e12cb3c8973d3c", - "durationOfPart": 0n, - "effectiveStartTime": 1784289275n, + "durationOfPart": 0, + "effectiveStartTime": 1784289275, "minPartLimit": 7143588130746906513n, - "numberOfParts": 2n, + "numberOfParts": 2, "partSellAmount": 1150000000000000000n, "receiver": "0xaa248d5328c7d781a96d93d7d013bcf393157bb4", "sellToken": "0xe91d153e0b41518a2ce8dd3d7944fa863463a97d", - "timeBetweenParts": 1800n, + "timeBetweenParts": 1800, }, "status": "Completed", "updatedAtBlock": 47248688n, From f62dc6e6650cd1374a72fd24384963d5a4222162 Mon Sep 17 00:00:00 2001 From: Daniel Constantin Date: Mon, 27 Jul 2026 18:11:56 +0300 Subject: [PATCH 8/8] ci: fix tests --- .../ProgrammaticOrderApi.spec.ts.snap | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/packages/composable/tests/__snapshots__/ProgrammaticOrderApi.spec.ts.snap b/packages/composable/tests/__snapshots__/ProgrammaticOrderApi.spec.ts.snap index 20e0a64e1..2dfd966a2 100644 --- a/packages/composable/tests/__snapshots__/ProgrammaticOrderApi.spec.ts.snap +++ b/packages/composable/tests/__snapshots__/ProgrammaticOrderApi.spec.ts.snap @@ -2,19 +2,7 @@ exports[`ProgrammaticOrderApi lists one page of EOA TWAP part orders from the live programmatic orders API 1`] = ` { - "partOrders": [ - { - "buyAmount": 776195194471677430n, - "createdAt": 1782860530, - "executedBuyAmount": 0n, - "executedFeeAmount": null, - "executedSellAmount": 0n, - "feeAmount": 0n, - "orderUid": "0x4f44aa86f906fc8b796a602e1cbf6edba7a0542c0d842a64e2108237f430098c62587918b2f00176646679509217a5a4d1ebbfd56a444be1", - "sellAmount": 100000000000000000n, - "status": "expired", - "validTo": 1782860769, - }, + "items": [ { "buyAmount": 776195194471677430n, "createdAt": 1782860530, @@ -27,6 +15,18 @@ exports[`ProgrammaticOrderApi lists one page of EOA TWAP part orders from the li "status": "fulfilled", "validTo": 1782860649, }, + { + "buyAmount": 776195194471677430n, + "createdAt": 1782860530, + "executedBuyAmount": 0n, + "executedFeeAmount": null, + "executedSellAmount": 0n, + "feeAmount": 0n, + "orderUid": "0x4f44aa86f906fc8b796a602e1cbf6edba7a0542c0d842a64e2108237f430098c62587918b2f00176646679509217a5a4d1ebbfd56a444be1", + "sellAmount": 100000000000000000n, + "status": "expired", + "validTo": 1782860769, + }, ], "totalCount": 2, }