diff --git a/packages/composable/README.md b/packages/composable/README.md index eb434d08c..8d0afa8f5 100644 --- a/packages/composable/README.md +++ b/packages/composable/README.md @@ -22,6 +22,32 @@ or yarn add @cowprotocol/sdk-composable ``` +## Read TWAP history + +```typescript +import { ProgrammaticOrderApi } from '@cowprotocol/sdk-composable' +import { SupportedChainId } from '@cowprotocol/sdk-config' + +const api = new ProgrammaticOrderApi() +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', + }, +) +``` + +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 ### ConditionalOrderFactory @@ -73,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 } @@ -117,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: @@ -132,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/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..829608aec --- /dev/null +++ b/packages/composable/src/programmaticOrders/ProgrammaticOrderApi.ts @@ -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> { + 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> { + 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 }) + } + } +} diff --git a/packages/composable/src/programmaticOrders/graphql.ts b/packages/composable/src/programmaticOrders/graphql.ts new file mode 100644 index 000000000..fdf1a5f57 --- /dev/null +++ b/packages/composable/src/programmaticOrders/graphql.ts @@ -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 { + query: string + page: string + variables: Record + itemSchema: TItemSchema +} + +export interface GraphqlPage { + items: TItem[] + totalCount: number +} + +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 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`) + + return page + } +} diff --git a/packages/composable/src/programmaticOrders/index.ts b/packages/composable/src/programmaticOrders/index.ts new file mode 100644 index 000000000..62e82305d --- /dev/null +++ b/packages/composable/src/programmaticOrders/index.ts @@ -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' diff --git a/packages/composable/src/programmaticOrders/schemas.ts b/packages/composable/src/programmaticOrders/schemas.ts new file mode 100644 index 000000000..b177c2f34 --- /dev/null +++ b/packages/composable/src/programmaticOrders/schemas.ts @@ -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']) diff --git a/packages/composable/src/programmaticOrders/twap-queries.ts b/packages/composable/src/programmaticOrders/twap-queries.ts new file mode 100644 index 000000000..907ebb585 --- /dev/null +++ b/packages/composable/src/programmaticOrders/twap-queries.ts @@ -0,0 +1,63 @@ +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 + } + offset: $offset + limit: $limit + orderBy: "eventId" + orderDirection: $direction + ) { + items { + eventId + chainId + hash + owner + resolvedOwner + status + updatedAtBlock + additionalData + partOrders: discreteOrders(limit: 1) { + totalCount + } + schedule: decodedParams + transaction { + blockTimestamp + } + } + totalCount + } + } +` + +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 + } + offset: $offset + limit: $limit + orderBy: "creationDate" + orderDirection: $direction + ) { + items { + orderUid + status + sellAmount + buyAmount + feeAmount + validTo + createdAt: creationDate + executedSellAmount + executedBuyAmount + executedFeeAmount: executedFee + } + totalCount + } + } +` diff --git a/packages/composable/src/programmaticOrders/twap-schemas.ts b/packages/composable/src/programmaticOrders/twap-schemas.ts new file mode 100644 index 000000000..78697b6f9 --- /dev/null +++ b/packages/composable/src/programmaticOrders/twap-schemas.ts @@ -0,0 +1,108 @@ +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, + SAFE_UINT256_NUMBER_SCHEMA, + SUPPORTED_EVM_CHAIN_ID_SCHEMA, + TIMESTAMP_SCHEMA, + UINT32_SCHEMA, + UINT256_SCHEMA, +} from './schemas' + +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({ + sellToken: ADDRESS_SCHEMA, + buyToken: ADDRESS_SCHEMA, + receiver: ADDRESS_SCHEMA, + partSellAmount: UINT256_SCHEMA, + minPartLimit: 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, +}) + +/** @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: SUPPORTED_EVM_CHAIN_ID_SCHEMA, + owner: 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( + ({ + additionalData: { executedSellAmount, executedBuyAmount, executedFee }, + partOrders, + schedule, + transaction, + ...parent + }) => { + const { t0, n, t, span, ...scheduleParams } = schedule + const createdAt = transaction.blockTimestamp + + return { + ...parent, + createdAt, + partOrdersCount: partOrders.totalCount, + executedAmounts: { + executedSellAmount, + executedBuyAmount, + executedFeeAmount: executedFee, // TODO rename in indexer + }, + schedule: { + ...scheduleParams, + effectiveStartTime: t0 === 0 ? 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, + 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), + executedFeeAmount: v.nullable(UINT256_SCHEMA), +}) diff --git a/packages/composable/src/programmaticOrders/twap-types.ts b/packages/composable/src/programmaticOrders/twap-types.ts new file mode 100644 index 000000000..8acbb98aa --- /dev/null +++ b/packages/composable/src/programmaticOrders/twap-types.ts @@ -0,0 +1,111 @@ +import type { SupportedChainId } from '@cowprotocol/sdk-config' +import type { OrderStatus } from '@cowprotocol/sdk-order-book' + +import type { ProgrammaticOrderStatus } from './types' + +/** Input for querying TWAP orders. */ +export interface GetTwapOrdersParams { + /** EOA or Safe that created the TWAP order. Do not pass a CoWShed proxy address. */ + resolvedOwner: string + /** 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 +} + +/** + * 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' + +/** + * 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 + */ +export interface TwapSchedule { + sellToken: string + buyToken: string + receiver: string + partSellAmount: bigint + minPartLimit: bigint + /** Effective Unix start time in seconds. */ + effectiveStartTime: number + numberOfParts: number + /** Seconds between consecutive parts. */ + timeBetweenParts: number + /** Part validity in seconds; zero means the full interval. */ + durationOfPart: number + 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 + /** Actual fee charged at settlement, in the sell token. */ + executedFeeAmount: bigint | null +} + +export interface TwapExecutedAmounts { + /** Execution totals for all part orders. */ + executedSellAmount: bigint + executedBuyAmount: bigint + executedFeeAmount: bigint +} + +/** + * 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 { + /** Creation event ID. Unique within a chain. */ + eventId: string + /** ComposableCoW order hash. More than one creation event can have the same hash. */ + hash: string + chainId: SupportedChainId + /** Address that owns the generated CoW orders: a CoWShed proxy or Safe. */ + owner: string + /** 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 + executedAmounts: TwapExecutedAmounts +} diff --git a/packages/composable/src/programmaticOrders/types.ts b/packages/composable/src/programmaticOrders/types.ts new file mode 100644 index 000000000..477e37bb5 --- /dev/null +++ b/packages/composable/src/programmaticOrders/types.ts @@ -0,0 +1,31 @@ +export interface ProgrammaticOrderApiOptions { + /** Programmatic orders API base URL or full GraphQL URL. */ + 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 +} + +/** + * 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 + */ +export type ProgrammaticOrderStatus = 'Active' | 'Cancelled' | 'Completed' + +export class ProgrammaticOrderApiError extends Error { + readonly name = 'ProgrammaticOrderApiError' +} 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/ProgrammaticOrderApi.spec.ts b/packages/composable/tests/ProgrammaticOrderApi.spec.ts new file mode 100644 index 000000000..3c46ab194 --- /dev/null +++ b/packages/composable/tests/ProgrammaticOrderApi.spec.ts @@ -0,0 +1,249 @@ +import { SupportedChainId } from '@cowprotocol/sdk-config' + +import { + ProgrammaticOrderApi, + type GetTwapOrdersParams, + type GetTwapPartOrdersParams, + type QueryDirection, +} from '../src' + +const EOA = '0x016f34D4f2578c3e9DFfC3f2b811Ba30c0c9e7f3' +const SAFE = '0xaA248D5328c7D781a96D93d7D013bcF393157bB4' + +describe('ProgrammaticOrderApi', () => { + 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]?.schedule).toMatchObject({ + effectiveStartTime: 200, + numberOfParts: 1, + timeBetweenParts: 1, + durationOfPart: 0, + }) + 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, + }) + + expect(page.items).toMatchSnapshot() + }, 30_000) + + 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(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 new file mode 100644 index 000000000..2dfd966a2 --- /dev/null +++ b/packages/composable/tests/__snapshots__/ProgrammaticOrderApi.spec.ts.snap @@ -0,0 +1,435 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ProgrammaticOrderApi lists one page of EOA TWAP part orders from the live programmatic orders API 1`] = ` +{ + "items": [ + { + "buyAmount": 776195194471677430n, + "createdAt": 1782860530, + "executedBuyAmount": 862488114171327656n, + "executedFeeAmount": 20028480706687n, + "executedSellAmount": 100000000000000000n, + "feeAmount": 0n, + "orderUid": "0x693a30318b8208f78b5028b237bb3cf3cef8c7c7c5a74773b02d4a0dec6f334762587918b2f00176646679509217a5a4d1ebbfd56a444b69", + "sellAmount": 100000000000000000n, + "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, +} +`; + +exports[`ProgrammaticOrderApi lists the latest EOA TWAP parents from the live programmatic orders API 1`] = ` +[ + { + "chainId": 100, + "createdAt": 1782860530, + "eventId": "178286053000000000000001000000000046969430000000000000000850000000000000041", + "executedAmounts": { + "executedBuyAmount": 862488114171327656n, + "executedFeeAmount": 20028480706687n, + "executedSellAmount": 100000000000000000n, + }, + "hash": "0xc6e1f5bc9f8cd772e89ab65222c6e5511fc121be2bb3cae3889ab666fa506c80", + "owner": "0x62587918b2f00176646679509217a5a4d1ebbfd5", + "partOrdersCount": 2, + "resolvedOwner": "0x016f34d4f2578c3e9dffc3f2b811ba30c0c9e7f3", + "schedule": { + "appData": "0xf248f0d7eee2efe935d59e82d615db8d2a47f7929698e4712416cb608e8877d9", + "buyToken": "0x177127622c4a00f3d409b75571e12cb3c8973d3c", + "durationOfPart": 0, + "effectiveStartTime": 1782860530, + "minPartLimit": 776195194471677430n, + "numberOfParts": 2, + "partSellAmount": 100000000000000000n, + "receiver": "0x016f34d4f2578c3e9dffc3f2b811ba30c0c9e7f3", + "sellToken": "0xaf204776c7245bf4147c2612bf6e5972ee483701", + "timeBetweenParts": 120, + }, + "status": "Completed", + "updatedAtBlock": 46969430n, + }, + { + "chainId": 100, + "createdAt": 1782860530, + "eventId": "178286053000000000000001000000000046969430000000000000000850000000000000038", + "executedAmounts": { + "executedBuyAmount": 862367902269494618n, + "executedFeeAmount": 20054871788565n, + "executedSellAmount": 100000000000000000n, + }, + "hash": "0x5ec4e09e073067d53acbcb81a94f926608ce075f0f04842e91a3b22ca2ea333d", + "owner": "0x62587918b2f00176646679509217a5a4d1ebbfd5", + "partOrdersCount": 2, + "resolvedOwner": "0x016f34d4f2578c3e9dffc3f2b811ba30c0c9e7f3", + "schedule": { + "appData": "0x302493bc6206f0065533621e960c9e5411c58044ea0b5a80b4f61edac00ba2f3", + "buyToken": "0x177127622c4a00f3d409b75571e12cb3c8973d3c", + "durationOfPart": 0, + "effectiveStartTime": 1782860530, + "minPartLimit": 776021953726338606n, + "numberOfParts": 2, + "partSellAmount": 100000000000000000n, + "receiver": "0x016f34d4f2578c3e9dffc3f2b811ba30c0c9e7f3", + "sellToken": "0xaf204776c7245bf4147c2612bf6e5972ee483701", + "timeBetweenParts": 120, + }, + "status": "Completed", + "updatedAtBlock": 46969430n, + }, + { + "chainId": 100, + "createdAt": 1782856350, + "eventId": "178285635000000000000001000000000046968626000000000000001750000000000000087", + "executedAmounts": { + "executedBuyAmount": 0n, + "executedFeeAmount": 0n, + "executedSellAmount": 0n, + }, + "hash": "0xf5e3a9d322d4985cd9fc10b84e16f0358c720592ec968ec3921dc65fd702be0b", + "owner": "0x62587918b2f00176646679509217a5a4d1ebbfd5", + "partOrdersCount": 2, + "resolvedOwner": "0x016f34d4f2578c3e9dffc3f2b811ba30c0c9e7f3", + "schedule": { + "appData": "0xdfc54d9c8b6a28a0c03947debf1a0f929184919579c926cf3de23c5881eb524a", + "buyToken": "0x177127622c4a00f3d409b75571e12cb3c8973d3c", + "durationOfPart": 0, + "effectiveStartTime": 1782856350, + "minPartLimit": 774221321423698851n, + "numberOfParts": 2, + "partSellAmount": 100000000000000000n, + "receiver": "0x016f34d4f2578c3e9dffc3f2b811ba30c0c9e7f3", + "sellToken": "0xaf204776c7245bf4147c2612bf6e5972ee483701", + "timeBetweenParts": 60, + }, + "status": "Active", + "updatedAtBlock": 47352216n, + }, + { + "chainId": 100, + "createdAt": 1782855780, + "eventId": "178285578000000000000001000000000046968514000000000000000450000000000000024", + "executedAmounts": { + "executedBuyAmount": 0n, + "executedFeeAmount": 0n, + "executedSellAmount": 0n, + }, + "hash": "0x28b61a46088d4082b10aca722efa0d2404106686594f2a82936893f2d7d22f06", + "owner": "0x62587918b2f00176646679509217a5a4d1ebbfd5", + "partOrdersCount": 2, + "resolvedOwner": "0x016f34d4f2578c3e9dffc3f2b811ba30c0c9e7f3", + "schedule": { + "appData": "0xebe836e7e0257003712b4884f2747a51dc706691305723c19e4bf7bec1c111ff", + "buyToken": "0x177127622c4a00f3d409b75571e12cb3c8973d3c", + "durationOfPart": 0, + "effectiveStartTime": 1782855780, + "minPartLimit": 774421599485031327n, + "numberOfParts": 2, + "partSellAmount": 100000000000000000n, + "receiver": "0x016f34d4f2578c3e9dffc3f2b811ba30c0c9e7f3", + "sellToken": "0xaf204776c7245bf4147c2612bf6e5972ee483701", + "timeBetweenParts": 60, + }, + "status": "Active", + "updatedAtBlock": 47352216n, + }, + { + "chainId": 100, + "createdAt": 1782852985, + "eventId": "178285298500000000000001000000000046967969000000000000000450000000000000025", + "executedAmounts": { + "executedBuyAmount": 0n, + "executedFeeAmount": 0n, + "executedSellAmount": 0n, + }, + "hash": "0x979bcc44b12484836ed02e56b24190071c3881b39e3a6368138860d518d4e16d", + "owner": "0x62587918b2f00176646679509217a5a4d1ebbfd5", + "partOrdersCount": 2, + "resolvedOwner": "0x016f34d4f2578c3e9dffc3f2b811ba30c0c9e7f3", + "schedule": { + "appData": "0xf7c9274467f87d681e0fa21b3fb886b44546162a0ab94fd44d346a2b78af430a", + "buyToken": "0x177127622c4a00f3d409b75571e12cb3c8973d3c", + "durationOfPart": 0, + "effectiveStartTime": 1782852985, + "minPartLimit": 773026802095130550n, + "numberOfParts": 2, + "partSellAmount": 100000000000000000n, + "receiver": "0x016f34d4f2578c3e9dffc3f2b811ba30c0c9e7f3", + "sellToken": "0xaf204776c7245bf4147c2612bf6e5972ee483701", + "timeBetweenParts": 60, + }, + "status": "Active", + "updatedAtBlock": 47352216n, + }, + { + "chainId": 100, + "createdAt": 1782851510, + "eventId": "178285151000000000000001000000000046967681000000000000000450000000000000034", + "executedAmounts": { + "executedBuyAmount": 1718396656651015389n, + "executedFeeAmount": 65038959954542n, + "executedSellAmount": 200000000000000000n, + }, + "hash": "0x87040d5bec3aad44a511b6b35be5f7078796947eab1a880c741c7763e27cd490", + "owner": "0x62587918b2f00176646679509217a5a4d1ebbfd5", + "partOrdersCount": 2, + "resolvedOwner": "0x016f34d4f2578c3e9dffc3f2b811ba30c0c9e7f3", + "schedule": { + "appData": "0xf7c9274467f87d681e0fa21b3fb886b44546162a0ab94fd44d346a2b78af430a", + "buyToken": "0x177127622c4a00f3d409b75571e12cb3c8973d3c", + "durationOfPart": 0, + "effectiveStartTime": 1782851510, + "minPartLimit": 1n, + "numberOfParts": 2, + "partSellAmount": 100000000000000000n, + "receiver": "0x016f34d4f2578c3e9dffc3f2b811ba30c0c9e7f3", + "sellToken": "0xaf204776c7245bf4147c2612bf6e5972ee483701", + "timeBetweenParts": 300, + }, + "status": "Completed", + "updatedAtBlock": 46967681n, + }, + { + "chainId": 100, + "createdAt": 1782842370, + "eventId": "178284237000000000000001000000000046965898000000000000000250000000000000013", + "executedAmounts": { + "executedBuyAmount": 1703025006672763226n, + "executedFeeAmount": 64530465104521n, + "executedSellAmount": 200000000000000000n, + }, + "hash": "0xa05b5dabbe1703fe82ee0d2ea89dc0397dcebfeb8ba1ec64591fb1d40de06422", + "owner": "0x62587918b2f00176646679509217a5a4d1ebbfd5", + "partOrdersCount": 2, + "resolvedOwner": "0x016f34d4f2578c3e9dffc3f2b811ba30c0c9e7f3", + "schedule": { + "appData": "0xf7c9274467f87d681e0fa21b3fb886b44546162a0ab94fd44d346a2b78af430a", + "buyToken": "0x177127622c4a00f3d409b75571e12cb3c8973d3c", + "durationOfPart": 0, + "effectiveStartTime": 1782842370, + "minPartLimit": 1n, + "numberOfParts": 2, + "partSellAmount": 100000000000000000n, + "receiver": "0x016f34d4f2578c3e9dffc3f2b811ba30c0c9e7f3", + "sellToken": "0xaf204776c7245bf4147c2612bf6e5972ee483701", + "timeBetweenParts": 300, + }, + "status": "Completed", + "updatedAtBlock": 46965898n, + }, + { + "chainId": 100, + "createdAt": 1757527335, + "eventId": "175752733500000000000001000000000042056007000000000000000050000000000000013", + "executedAmounts": { + "executedBuyAmount": 678620197252801976n, + "executedFeeAmount": 722702729572645n, + "executedSellAmount": 200000000000000000n, + }, + "hash": "0xf8edb9707569dec76a362bb6bac1909fdf43d5afe70beca3e422ec7b1bbaa237", + "owner": "0x62587918b2f00176646679509217a5a4d1ebbfd5", + "partOrdersCount": 2, + "resolvedOwner": "0x016f34d4f2578c3e9dffc3f2b811ba30c0c9e7f3", + "schedule": { + "appData": "0xf7c9274467f87d681e0fa21b3fb886b44546162a0ab94fd44d346a2b78af430a", + "buyToken": "0x177127622c4a00f3d409b75571e12cb3c8973d3c", + "durationOfPart": 0, + "effectiveStartTime": 1757527335, + "minPartLimit": 1n, + "numberOfParts": 2, + "partSellAmount": 100000000000000000n, + "receiver": "0x016f34d4f2578c3e9dffc3f2b811ba30c0c9e7f3", + "sellToken": "0xaf204776c7245bf4147c2612bf6e5972ee483701", + "timeBetweenParts": 300, + }, + "status": "Completed", + "updatedAtBlock": 42056007n, + }, + { + "chainId": 100, + "createdAt": 1757526710, + "eventId": "175752671000000000000001000000000042055888000000000000000150000000000000029", + "executedAmounts": { + "executedBuyAmount": 0n, + "executedFeeAmount": 0n, + "executedSellAmount": 0n, + }, + "hash": "0x24022b547eab0362735d633bd4ed6695a2bfad7e8d470b074e610f4b027f2f37", + "owner": "0x62587918b2f00176646679509217a5a4d1ebbfd5", + "partOrdersCount": 3, + "resolvedOwner": "0x016f34d4f2578c3e9dffc3f2b811ba30c0c9e7f3", + "schedule": { + "appData": "0xf7c9274467f87d681e0fa21b3fb886b44546162a0ab94fd44d346a2b78af430a", + "buyToken": "0x177127622c4a00f3d409b75571e12cb3c8973d3c", + "durationOfPart": 0, + "effectiveStartTime": 1757526710, + "minPartLimit": 1n, + "numberOfParts": 4, + "partSellAmount": 25000000000000000n, + "receiver": "0x016f34d4f2578c3e9dffc3f2b811ba30c0c9e7f3", + "sellToken": "0xaf204776c7245bf4147c2612bf6e5972ee483701", + "timeBetweenParts": 30, + }, + "status": "Active", + "updatedAtBlock": 47352176n, + }, + { + "chainId": 100, + "createdAt": 1757526200, + "eventId": "175752620000000000000001000000000042055786000000000000000050000000000000008", + "executedAmounts": { + "executedBuyAmount": 84756756849678668n, + "executedFeeAmount": 832386762201548n, + "executedSellAmount": 25000000000000000n, + }, + "hash": "0x2bbf5f8727c87a4ccfd2ef1332092caa6ea7173a3b5b91ff11fe24df71e0e5f8", + "owner": "0x62587918b2f00176646679509217a5a4d1ebbfd5", + "partOrdersCount": 4, + "resolvedOwner": "0x016f34d4f2578c3e9dffc3f2b811ba30c0c9e7f3", + "schedule": { + "appData": "0xf7c9274467f87d681e0fa21b3fb886b44546162a0ab94fd44d346a2b78af430a", + "buyToken": "0x177127622c4a00f3d409b75571e12cb3c8973d3c", + "durationOfPart": 0, + "effectiveStartTime": 1757526200, + "minPartLimit": 1n, + "numberOfParts": 4, + "partSellAmount": 25000000000000000n, + "receiver": "0x016f34d4f2578c3e9dffc3f2b811ba30c0c9e7f3", + "sellToken": "0xaf204776c7245bf4147c2612bf6e5972ee483701", + "timeBetweenParts": 1800, + }, + "status": "Active", + "updatedAtBlock": 47352176n, + }, + { + "chainId": 100, + "createdAt": 1757525910, + "eventId": "175752591000000000000001000000000042055728000000000000000050000000000000015", + "executedAmounts": { + "executedBuyAmount": 84880520382659281n, + "executedFeeAmount": 832300053240359n, + "executedSellAmount": 25000000000000000n, + }, + "hash": "0x4ac7633559e7c90792574306ae2c3bbae7588681a0219c3668b61cc6da2be10b", + "owner": "0x62587918b2f00176646679509217a5a4d1ebbfd5", + "partOrdersCount": 4, + "resolvedOwner": "0x016f34d4f2578c3e9dffc3f2b811ba30c0c9e7f3", + "schedule": { + "appData": "0xf7c9274467f87d681e0fa21b3fb886b44546162a0ab94fd44d346a2b78af430a", + "buyToken": "0x177127622c4a00f3d409b75571e12cb3c8973d3c", + "durationOfPart": 0, + "effectiveStartTime": 1757525910, + "minPartLimit": 1n, + "numberOfParts": 4, + "partSellAmount": 25000000000000000n, + "receiver": "0x016f34d4f2578c3e9dffc3f2b811ba30c0c9e7f3", + "sellToken": "0xaf204776c7245bf4147c2612bf6e5972ee483701", + "timeBetweenParts": 1800, + }, + "status": "Active", + "updatedAtBlock": 47352176n, + }, + { + "chainId": 100, + "createdAt": 1757524940, + "eventId": "175752494000000000000001000000000042055539000000000000000050000000000000008", + "executedAmounts": { + "executedBuyAmount": 168880044359503662n, + "executedFeeAmount": 499999999999996n, + "executedSellAmount": 50000000000000000n, + }, + "hash": "0x618fc1893e8fc19a353734250d775dc3fa62df205ed545cc66a17a7ab1197a0c", + "owner": "0x62587918b2f00176646679509217a5a4d1ebbfd5", + "partOrdersCount": 4, + "resolvedOwner": "0x016f34d4f2578c3e9dffc3f2b811ba30c0c9e7f3", + "schedule": { + "appData": "0xf7c9274467f87d681e0fa21b3fb886b44546162a0ab94fd44d346a2b78af430a", + "buyToken": "0x177127622c4a00f3d409b75571e12cb3c8973d3c", + "durationOfPart": 0, + "effectiveStartTime": 1757524940, + "minPartLimit": 1n, + "numberOfParts": 4, + "partSellAmount": 25000000000000000n, + "receiver": "0x016f34d4f2578c3e9dffc3f2b811ba30c0c9e7f3", + "sellToken": "0xaf204776c7245bf4147c2612bf6e5972ee483701", + "timeBetweenParts": 1800, + }, + "status": "Active", + "updatedAtBlock": 47352176n, + }, + { + "chainId": 100, + "createdAt": 1757524180, + "eventId": "175752418000000000000001000000000042055392000000000000000250000000000000021", + "executedAmounts": { + "executedBuyAmount": 0n, + "executedFeeAmount": 0n, + "executedSellAmount": 0n, + }, + "hash": "0x296f02547402281d040fa79d59ef61631e4a18c317654cefa1bf2226224c9fd9", + "owner": "0x62587918b2f00176646679509217a5a4d1ebbfd5", + "partOrdersCount": 4, + "resolvedOwner": "0x016f34d4f2578c3e9dffc3f2b811ba30c0c9e7f3", + "schedule": { + "appData": "0xf7c9274467f87d681e0fa21b3fb886b44546162a0ab94fd44d346a2b78af430a", + "buyToken": "0x177127622c4a00f3d409b75571e12cb3c8973d3c", + "durationOfPart": 0, + "effectiveStartTime": 1757524180, + "minPartLimit": 0n, + "numberOfParts": 4, + "partSellAmount": 25000000000000000n, + "receiver": "0x016f34d4f2578c3e9dffc3f2b811ba30c0c9e7f3", + "sellToken": "0xaf204776c7245bf4147c2612bf6e5972ee483701", + "timeBetweenParts": 1800, + }, + "status": "Active", + "updatedAtBlock": 47352176n, + }, +] +`; + +exports[`ProgrammaticOrderApi lists the latest Safe TWAP parents from the live programmatic orders API 1`] = ` +[ + { + "chainId": 100, + "createdAt": 1784289275, + "eventId": "178428927500000000000001000000000047248688000000000000002250000000000000129", + "executedAmounts": { + "executedBuyAmount": 15897778052264678730n, + "executedFeeAmount": 635102768951199n, + "executedSellAmount": 2300000000000000000n, + }, + "hash": "0x9aa36d973466810ad97a07041496c6f7776e7a2ab3cc63c47584006673f14050", + "owner": "0xaa248d5328c7d781a96d93d7d013bcf393157bb4", + "partOrdersCount": 2, + "resolvedOwner": "0xaa248d5328c7d781a96d93d7d013bcf393157bb4", + "schedule": { + "appData": "0x832ef833029247a34f08ea828c3d19301241443de679ecfe2d53ead11c983a59", + "buyToken": "0x177127622c4a00f3d409b75571e12cb3c8973d3c", + "durationOfPart": 0, + "effectiveStartTime": 1784289275, + "minPartLimit": 7143588130746906513n, + "numberOfParts": 2, + "partSellAmount": 1150000000000000000n, + "receiver": "0xaa248d5328c7d781a96d93d7d013bcf393157bb4", + "sellToken": "0xe91d153e0b41518a2ce8dd3d7944fa863463a97d", + "timeBetweenParts": 1800, + }, + "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' }, + }) +} 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: