Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 33 additions & 3 deletions packages/composable/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>` 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
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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:

Expand All @@ -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:

Expand Down
3 changes: 2 additions & 1 deletion packages/composable/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Regarding valibot vs zod (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.

},
"devDependencies": {
"@cowprotocol/sdk-ethers-v5-adapter": "workspace:*",
Expand Down
1 change: 1 addition & 0 deletions packages/composable/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ export * from './ConditionalOrder'
export * from './Multiplexer'
export * from './ConditionalOrderFactory'
export * from './orderTypes'
export * from './programmaticOrders'
118 changes: 118 additions & 0 deletions packages/composable/src/programmaticOrders/ProgrammaticOrderApi.ts
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 })
}
}
}
118 changes: 118 additions & 0 deletions packages/composable/src/programmaticOrders/graphql.ts
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 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

How come you need a custom GraphQL client instad of using a lib like Apollo or similar?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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 packages/composable/src/programmaticOrders/graphql.ts:41. A stalled upstream can leave query(), pagination, and getTwapOrders() pending indefinitely; thread an AbortSignal/timeout through GraphqlClient, map expiry to GraphqlClientError, and add a stalled-fetch test.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/composable/src/programmaticOrders/graphql.ts` around lines 37 - 50,
Update GraphqlClient.query to enforce a request timeout by creating or accepting
an AbortSignal and passing it to fetch, including the signal through pagination
and getTwapOrders call paths. Catch aborts caused by timeout and map them to
GraphqlClientError with clear timeout context, while preserving existing
handling for other request failures; add a test that verifies a stalled fetch
rejects within the configured timeout.

}

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
}
}
17 changes: 17 additions & 0 deletions packages/composable/src/programmaticOrders/index.ts
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'
64 changes: 64 additions & 0 deletions packages/composable/src/programmaticOrders/schemas.ts
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'])
Loading
Loading