-
Notifications
You must be signed in to change notification settings - Fork 48
feat: add new getTwapOrders method #928
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
b9bd235
a1f3dee
84bcee3
1c1b3eb
1a2ed62
ae8936f
566faeb
f62dc6e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,118 @@ | ||
| import { GraphqlClient } from './graphql' | ||
| 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 that uses the default programmatic orders API. | ||
| * | ||
| * @param options - API endpoint settings. | ||
| * @throws {@link ProgrammaticOrderApiError} when `apiUrl` is invalid. | ||
| */ | ||
| constructor(options: ProgrammaticOrderApiOptions = {}) { | ||
| try { | ||
| this.graphql = new GraphqlClient(options.apiUrl ?? DEFAULT_API_URL) | ||
| } catch (cause) { | ||
| throw new ProgrammaticOrderApiError('Invalid programmatic orders API URL', { cause }) | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Returns one page of TWAP orders created by an EOA or Safe. | ||
| * | ||
| * Results are sorted by creation time, newest first by default. Use {@link getTwapPartOrders} to fetch part orders. | ||
| * | ||
| * @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, options: QueryOptions = {}): Promise<QueryPage<TwapOrder>> { | ||
| 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) | ||
|
|
||
| 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 }) | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * 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<QueryPage<TwapPartOrder>> { | ||
| 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 page = await this.graphql.queryPage({ | ||
| query: TWAP_PART_ORDERS_QUERY, | ||
| page: 'partOrders', | ||
| variables: { | ||
| chainId, | ||
| parentEventId: eventId, | ||
| offset, | ||
| limit, | ||
| direction, | ||
| }, | ||
| itemSchema: TWAP_PART_ORDER_SCHEMA, | ||
| }) | ||
|
|
||
| return page | ||
| } catch (cause) { | ||
| throw new ProgrammaticOrderApiError('Failed to fetch TWAP part orders', { cause }) | ||
| } | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,118 @@ | ||
| 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 GraphqlPageOperation<TItemSchema extends v.GenericSchema> { | ||
| query: string | ||
| page: string | ||
| variables: Record<string, unknown> | ||
| itemSchema: TItemSchema | ||
| } | ||
|
|
||
| export interface GraphqlPage<TItem> { | ||
| items: TItem[] | ||
| totalCount: number | ||
| } | ||
|
|
||
| export class GraphqlClientError extends Error { | ||
| readonly name = 'GraphqlClientError' | ||
| } | ||
|
|
||
| export class GraphqlClient { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. How come you need a custom
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Mostly to keep the bundle small and the npm supply chain attack vector small. This is the only place where we use graphql in the sdk, it felt a bit overkill to pull in Apollo, but I'm not against exploring it. |
||
| 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<string, unknown>): Promise<unknown> { | ||
| 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 }) | ||
|
Comment on lines
+42
to
+55
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -euo pipefail
# Map the relevant file and nearby symbols first.
ast-grep outline packages/composable/src/programmaticOrders/graphql.ts --view expanded
# Inspect the file with line numbers.
cat -n packages/composable/src/programmaticOrders/graphql.ts | sed -n '1,220p'
# Find callers and any timeout/AbortSignal patterns in the repo.
rg -n "AbortSignal|AbortController|timeout\(|fetch\(" packages/composable packages -g '!**/dist/**' -g '!**/build/**'
# Check package/runtime targets.
git ls-files 'packages/**/package.json' 'package.json' 'tsconfig*.json' 'packages/**/tsconfig*.json' | xargs -r rg -n '"(engines|target|lib|types|type)":|"typescript"'Repository: cowprotocol/cow-sdk Length of output: 11871 🏁 Script executed: #!/bin/bash
set -euo pipefail
# Inspect the root and composable package/runtime targets.
cat -n package.json | sed -n '1,120p'
printf '\n--- composable package ---\n'
cat -n packages/composable/package.json | sed -n '1,120p'
printf '\n--- composable tsconfig ---\n'
cat -n packages/composable/tsconfig.json | sed -n '1,120p'
# Look for any request context/abort signal plumbing in composable.
rg -n "signal:|AbortSignal|AbortController|timeout\(|contextOverride|timeoutMs|request.*timeout" packages/composable packages/order-book packages/contracts-ts -g '!**/dist/**' -g '!**/build/**'Repository: cowprotocol/cow-sdk Length of output: 11335 🏁 Script executed: #!/bin/bash
set -euo pipefail
# Find GraphqlClient usage and the public TWAP surface.
rg -n "new GraphqlClient|GraphqlClient\\b|getTwapOrders|paginate\\(" packages/composable -g '!**/dist/**' -g '!**/build/**'
# Inspect the TWAP-related entrypoints.
ast-grep outline packages/composable/src --view expanded
rg -n "TWAP|twap|programmaticOrders|graphql" packages/composable/src -g '!**/dist/**' -g '!**/build/**'
# Inspect the composable package exports for a public API surface that might carry context/options.
cat -n packages/composable/src/index.ts | sed -n '1,220p'Repository: cowprotocol/cow-sdk Length of output: 29697 Bound the GraphQL request in 🤖 Prompt for AI Agents |
||
| } | ||
|
|
||
| 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 queryPage<TItemSchema extends v.GenericSchema>( | ||
| operation: GraphqlPageOperation<TItemSchema>, | ||
| ): Promise<GraphqlPage<v.InferOutput<TItemSchema>>> { | ||
| 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`) | ||
|
|
||
| return page | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,17 @@ | ||
| export { ProgrammaticOrderApi } from './ProgrammaticOrderApi' | ||
| export { ProgrammaticOrderApiError } from './types' | ||
| export type { | ||
| ProgrammaticOrderApiOptions, | ||
| ProgrammaticOrderStatus, | ||
| QueryDirection, | ||
| QueryOptions, | ||
| QueryPage, | ||
| } from './types' | ||
| export type { | ||
| GetTwapOrdersParams, | ||
| GetTwapPartOrdersParams, | ||
| TwapOrder, | ||
| TwapPartOrder, | ||
| TwapPartOrderStatus, | ||
| TwapSchedule, | ||
| } from './twap-types' |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,64 @@ | ||
| 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 | ||
|
|
||
| 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'), | ||
| 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 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'), | ||
| 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 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'), | ||
| v.transform((value) => Number(value)), | ||
| ) | ||
| export const PROGRAMMATIC_ORDER_STATUS_SCHEMA = v.picklist(['Active', 'Cancelled', 'Completed']) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Regarding
valibotvszod(4), no preference, but I think it would make sense to use the same one in both projects so that we have a single dependency. Maybe even declare it as a peer dependency here.