diff --git a/package.json b/package.json index 05f38631..9a52909b 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,9 @@ "prepublishOnly": "npm test && npm run lint", "graphql:codegen": "graphql-codegen --config graphql-codegen.yml", "swagger:codegen": " openapi --input https://raw.githubusercontent.com/cowprotocol/services/v2.272.1/crates/orderbook/openapi.yml --output src/order-book/generated --exportServices false --exportCore false", - "typechain:codegen": "typechain --target ethers-v5 --out-dir ./src/composable/generated './abi/*.json'" + "typechain:codegen": "typechain --target ethers-v5 --out-dir ./src/composable/generated './abi/*.json'", + "trading-cli": "ts-node --project tsconfig.scripts.json ./src/trading/cli/index.ts", + "test-trading-cli-py": "python src/trading/example.py" }, "dependencies": { "@cowprotocol/app-data": "^2.1.0", @@ -55,6 +57,7 @@ "@typechain/ethers-v5": "^11.0.0", "@types/jest": "^29.4.0", "@types/node": "^18.13.0", + "@types/prompts": "^2.4.9", "@typescript-eslint/eslint-plugin": "^5.51.0", "@typescript-eslint/parser": "^5.51.0", "babel-plugin-inline-import": "^3.0.0", @@ -66,13 +69,16 @@ "ethers": "^5.7.2", "jest": "^29.6.4", "jest-fetch-mock": "^3.0.3", + "kleur": "^4.1.5", "microbundle": "^0.15.1", "openapi-typescript-codegen": "^0.23.0", "prettier": "^2.5.1", + "prompts": "^2.4.2", "ts-mockito": "^2.6.1", "tsc-watch": "^6.0.0", "typechain": "^8.2.0", - "typescript": "^4.9.5" + "typescript": "^4.9.5", + "yargs": "^17.7.2" }, "jest": { "automock": false, diff --git a/src/trading/README.md b/src/trading/README.md index 5fbde60d..1946377f 100644 --- a/src/trading/README.md +++ b/src/trading/README.md @@ -243,3 +243,63 @@ console.log('Order created, id: ', orderId) #### Limit order Same as for the swap order but without the `quoteRequest` parameter. + +## CLI + +### Example + +```shell +yarn run trading-cli --\ +--action=getQuote \ +--chainId=11155111 \ +--signer=574ed8a3c3baf4ed2c2fe5225a3bee6999b6a5ab735d297aca9dfc6c3f540000 \ +--appCode="UtilsCliExample" \ +--orderKind="sell" \ +--sellToken="0x0625afb445c3b6b7b929342a04a22599fd5dbb59" \ +--sellTokenDecimals=18 \ +--buyToken="0xbe72e441bf55620febc26715db68d3494213d8cb" \ +--buyTokenDecimals=6 \ +--amount=12222000000000000000000 \ +--env="prod" \ +--partiallyFillable=false \ +--slippageBps=0 \ +--receiver="" \ +--validFor=300 \ +--partnerFeeBps=0 \ +--partnerFeeRecipient="" +``` + +### The CLI can be called from any other programming language + +```py +from subprocess import check_output +import json + +args = [ + '--action=getQuote', + '--chainId=11155111', + '--signer=574ed8a3c3baf4ed2c2fe5225a3bee6999b6a5ab735d297aca9dfc6c3f540000', + '--appCode="UtilsCliExample"', + '--orderKind="sell"', + '--sellToken="0x0625afb445c3b6b7b929342a04a22599fd5dbb59"', + '--sellTokenDecimals=18', + '--buyToken="0xbe72e441bf55620febc26715db68d3494213d8cb"', + '--buyTokenDecimals=6', + '--amount=12222000000000000000000', + '--env="prod"', + '--partiallyFillable=false', + '--slippageBps=0', + '--receiver=""', + '--validFor=300', + '--partnerFeeBps=0', + '--partnerFeeRecipient=""' +] + +rawResult = check_output(['npx', 'cow-sdk-trading-cli'] + args) +result = json.loads(rawResult) + +print('orderToSign:', result['orderToSign']) +print('amountsAndCosts:', result['amountsAndCosts']) +print('quoteResponse:', result['quoteResponse']) + +``` diff --git a/src/trading/cli/actions/getQuoteAction.ts b/src/trading/cli/actions/getQuoteAction.ts new file mode 100644 index 00000000..0935a9d8 --- /dev/null +++ b/src/trading/cli/actions/getQuoteAction.ts @@ -0,0 +1,20 @@ +import prompts from 'prompts' +import { swapParametersSchema } from '../consts' +import { SwapParameters } from '../../types' +import { getQuote } from '../../getQuote' +import kleur from 'kleur' +import { printJSON } from '../utils' + +export async function getQuoteAction(hasArgv: boolean) { + const params = (await prompts(swapParametersSchema)) as SwapParameters + + const quote = await getQuote(params) + + if (hasArgv) { + console.log(printJSON(quote)) + return + } + + console.log(kleur.green().bold('Quote: ')) + console.log(kleur.white().underline(printJSON(quote))) +} diff --git a/src/trading/cli/actions/postLimitOrderAction.ts b/src/trading/cli/actions/postLimitOrderAction.ts new file mode 100644 index 00000000..fb26dfeb --- /dev/null +++ b/src/trading/cli/actions/postLimitOrderAction.ts @@ -0,0 +1,19 @@ +import prompts from 'prompts' +import { limitParametersSchema } from '../consts' +import { LimitOrderParameters } from '../../types' +import kleur from 'kleur' +import { postLimitOrder } from '../../postLimitOrder' + +export async function postLimitOrderAction(hasArgv: boolean) { + const params = (await prompts(limitParametersSchema)) as LimitOrderParameters + + const orderId = await postLimitOrder(params) + + if (hasArgv) { + console.log(orderId) + return + } + + console.log(kleur.green().bold('Order id: ')) + console.log(kleur.white().underline(orderId)) +} diff --git a/src/trading/cli/actions/postSwapOrderAction.ts b/src/trading/cli/actions/postSwapOrderAction.ts new file mode 100644 index 00000000..1afbb459 --- /dev/null +++ b/src/trading/cli/actions/postSwapOrderAction.ts @@ -0,0 +1,19 @@ +import prompts from 'prompts' +import { swapParametersSchema } from '../consts' +import { SwapParameters } from '../../types' +import kleur from 'kleur' +import { postSwapOrder } from '../../postSwapOrder' + +export async function postSwapOrderAction(hasArgv: boolean) { + const params = (await prompts(swapParametersSchema)) as SwapParameters + + const orderId = await postSwapOrder(params) + + if (hasArgv) { + console.log(orderId) + return + } + + console.log(kleur.green().bold('Order id: ')) + console.log(kleur.white().underline(orderId)) +} diff --git a/src/trading/cli/consts.ts b/src/trading/cli/consts.ts new file mode 100644 index 00000000..f15abdd0 --- /dev/null +++ b/src/trading/cli/consts.ts @@ -0,0 +1,165 @@ +import { PromptObject } from 'prompts' +import { ALL_SUPPORTED_CHAIN_IDS, SupportedChainId } from '../../common' +import { OrderKind } from '../../order-book' + +export const traderParametersSchema: PromptObject[] = [ + { + type: 'select', + name: 'chainId', + message: 'Network', + choices: ALL_SUPPORTED_CHAIN_IDS.map((key) => ({ + title: key.toString(), + value: SupportedChainId[key], + })), + initial: SupportedChainId.MAINNET, + }, + { + type: 'password', + name: 'signer', + message: 'Signer`s private key', + }, + { + type: 'text', + name: 'appCode', + message: 'Your app code (for analytics purposes)', + }, +] + +export const tradeBaseParametersSchema: PromptObject[] = [ + { + type: 'select', + name: 'orderKind', + choices: [ + { title: 'Sell', value: OrderKind.SELL }, + { title: 'Buy', value: OrderKind.BUY }, + ], + message: 'Order kind', + }, + { + type: 'text', + name: 'sellToken', + message: 'Sell token address', + }, + { + type: 'number', + name: 'sellTokenDecimals', + message: 'Sell token decimals', + }, + { + type: 'text', + name: 'buyToken', + message: 'Buy token address', + }, + { + type: 'number', + name: 'buyTokenDecimals', + message: 'Buy token decimals', + }, + { + type: 'text', + name: 'amount', + message: 'Amount to trade (in units)', + }, +] + +export const tradeOptionalParametersSchema: PromptObject[] = [ + { + type: 'select', + name: 'env', + choices: [ + { title: 'Prod', value: 'prod' }, + { title: 'Staging', value: 'staging' }, + ], + message: 'Environment', + }, + { + type: 'toggle', + name: 'partiallyFillable', + message: 'Is order partially fillable?', + initial: false, + }, + { + type: 'number', + name: 'slippageBps', + message: 'Slippage in BPS', + initial: 0, + }, + { + type: 'text', + name: 'receiver', + message: 'Receiver address', + initial: '', + }, + { + type: 'number', + name: 'validFor', + message: 'Order time to life (in seconds)', + initial: 300, + format: (val) => +val, + }, + { + type: 'number', + name: 'partnerFeeBps', + message: 'Partner fee percent (in BPS)', + initial: 0, + }, + { + type: 'text', + name: 'partnerFeeRecipient', + message: 'Partner fee recipient address', + initial: '', + }, +] + +export const limitSpecificParametersSchema: PromptObject[] = [ + { + type: 'text', + name: 'sellAmount', + message: 'Sell amount (in units)', + }, + { + type: 'text', + name: 'buyAmount', + message: 'Buy amount (in units)', + }, +] + +export const orderToSignParametersSchema: PromptObject[] = [ + { + type: 'text', + name: 'from', + message: 'Account address', + }, + { + type: 'text', + name: 'networkCostsAmount', + message: 'Network costs amount (in units) (from quote result)', + }, +] + +export const actionsSchema: PromptObject[] = [ + { + type: 'select', + name: 'action', + choices: [ + { title: 'Get quote', value: 'getQuote' }, + { title: 'Post swap order', value: 'postSwapOrder' }, + { title: 'Post limit order', value: 'postLimitOrder' }, + { title: 'Get order to sign', value: 'getOrderToSign' }, + ], + message: 'Choose action to do:', + }, +] + +export const swapParametersSchema = [ + ...traderParametersSchema, + ...tradeBaseParametersSchema, + ...tradeOptionalParametersSchema, +] + +export const limitParametersSchema = [ + ...traderParametersSchema, + ...tradeBaseParametersSchema.filter((p) => p.name !== 'amount'), + ...limitSpecificParametersSchema, + ...tradeOptionalParametersSchema, +] diff --git a/src/trading/cli/index.ts b/src/trading/cli/index.ts new file mode 100644 index 00000000..596702d6 --- /dev/null +++ b/src/trading/cli/index.ts @@ -0,0 +1,41 @@ +import yargvParser from 'yargs-parser' +import prompts from 'prompts' +import { actionsSchema, swapParametersSchema } from './consts' +import { getQuoteAction } from './actions/getQuoteAction' +import { toggleLog } from '../consts' +import { postSwapOrderAction } from './actions/postSwapOrderAction' +import { postLimitOrderAction } from './actions/postLimitOrderAction' + +const promptSchemas = [swapParametersSchema] + +// IIFE +;(async () => { + const argv = yargvParser(process.argv.slice(2), { configuration: { 'parse-numbers': false } }) + const argvKeys = Object.keys(argv) + const hasArgv = promptSchemas.some((schema) => + schema.map((i) => i.name as string).every((param) => argvKeys.includes(param)) + ) + + prompts.override(argv) + + if (hasArgv) { + toggleLog(false) + } + + const actionsResult = await prompts(actionsSchema) + + switch (actionsResult.action) { + case 'getQuote': + await getQuoteAction(hasArgv) + break + case 'postSwapOrder': + await postSwapOrderAction(hasArgv) + break + case 'postLimitOrder': + await postLimitOrderAction(hasArgv) + break + default: + console.log('Unknown action: ', actionsResult.action) + break + } +})() diff --git a/src/trading/cli/utils.ts b/src/trading/cli/utils.ts new file mode 100644 index 00000000..410e9d8b --- /dev/null +++ b/src/trading/cli/utils.ts @@ -0,0 +1,3 @@ +export function printJSON(data: any): string { + return JSON.stringify(data, (_, value) => (typeof value === 'bigint' ? value.toString() : value), 4) +} diff --git a/src/trading/consts.ts b/src/trading/consts.ts index d3c69f3f..7487d679 100644 --- a/src/trading/consts.ts +++ b/src/trading/consts.ts @@ -1,6 +1,16 @@ import { EcdsaSigningScheme, SigningScheme } from '../order-book' -export const log = (text: string) => console.log(`[COW TRADING SDK] ${text}`) +let isLogEnabled = true + +export const toggleLog = (enabled: boolean) => { + isLogEnabled = enabled +} + +export const log = (text: string) => { + if (!isLogEnabled) return + + console.log(`[COW TRADING SDK] ${text}`) +} export const DEFAULT_QUOTE_VALIDITY = 60 * 10 // 10 min diff --git a/src/trading/example.py b/src/trading/example.py new file mode 100644 index 00000000..bf6b8ef1 --- /dev/null +++ b/src/trading/example.py @@ -0,0 +1,32 @@ +from subprocess import check_output +import json + +args = [ +'--action=getQuote', +'--chainId=11155111', +'--signer=574ed8a3c3baf4ed2c2fe5225a3bee6999b6a5ab735d297aca9dfc6c3f540000', +'--appCode="UtilsCliExample"', +'--orderKind="sell"', +'--sellToken="0x0625afb445c3b6b7b929342a04a22599fd5dbb59"', +'--sellTokenDecimals=18', +'--buyToken="0xbe72e441bf55620febc26715db68d3494213d8cb"', +'--buyTokenDecimals=6', +'--amount=12222000000000000000000', +'--env="prod"', +'--partiallyFillable=false', +'--slippageBps=0', +'--receiver=""', +'--validFor=300', +'--partnerFeeBps=0', +'--partnerFeeRecipient=""' +] + +# after `npm install @cowprotocol/cow-sdk` +# rawResult = check_output(['npx', 'cow-sdk-trading-cli'] + args) + +rawResult = check_output(['yarn', 'run', '--silent', 'trading-cli'] + args) +result = json.loads(rawResult) + +print('orderToSign:', result['orderToSign']) +print('amountsAndCosts:', result['amountsAndCosts']) +print('quoteResponse:', result['quoteResponse']) diff --git a/src/trading/getOrderToSign.ts b/src/trading/getOrderToSign.ts index 818b72d0..c05547ad 100644 --- a/src/trading/getOrderToSign.ts +++ b/src/trading/getOrderToSign.ts @@ -3,7 +3,7 @@ import { UnsignedOrder } from '../order-signing' import { AppDataInfo, LimitOrderParameters } from './types' import { DEFAULT_QUOTE_VALIDITY } from './consts' -interface OrderToSignParams { +export interface OrderToSignParams { from: string networkCostsAmount: string } diff --git a/tsconfig.scripts.json b/tsconfig.scripts.json new file mode 100644 index 00000000..e557407b --- /dev/null +++ b/tsconfig.scripts.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "experimentalDecorators": true, + "moduleResolution": "node", + "importHelpers": true, + "module": "commonjs", + "target": "ES2017", + "resolveJsonModule": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "lib": ["es2018", "dom", "es2019", "esnext"], + "types": ["node"] + } +} diff --git a/yarn.lock b/yarn.lock index daa5d44f..6317dbc4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3310,6 +3310,14 @@ resolved "https://registry.yarnpkg.com/@types/prettier/-/prettier-2.7.3.tgz#3e51a17e291d01d17d3fc61422015a933af7a08f" integrity sha512-+68kP9yzs4LMp7VNh8gdzMSPZFL44MLGqiHWvttYJe+6qnuVr4Ek9wSBQoveqY/r+LwjCcU29kNVkidwim+kYA== +"@types/prompts@^2.4.9": + version "2.4.9" + resolved "https://registry.yarnpkg.com/@types/prompts/-/prompts-2.4.9.tgz#8775a31e40ad227af511aa0d7f19a044ccbd371e" + integrity sha512-qTxFi6Buiu8+50/+3DGIWLHM6QuWsEKugJnnP6iv2Mc4ncxE4A/OJkjuVOA+5X0X1S/nq5VJRa8Lu+nwcvbrKA== + dependencies: + "@types/node" "*" + kleur "^3.0.3" + "@types/resolve@1.17.1": version "1.17.1" resolved "https://registry.yarnpkg.com/@types/resolve/-/resolve-1.17.1.tgz#3afd6ad8967c77e4376c598a82ddd58f46ec45d6" @@ -7001,7 +7009,7 @@ kleur@^3.0.3: resolved "https://registry.yarnpkg.com/kleur/-/kleur-3.0.3.tgz#a79c9ecc86ee1ce3fa6206d1216c501f147fc07e" integrity sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w== -kleur@^4.1.3: +kleur@^4.1.3, kleur@^4.1.5: version "4.1.5" resolved "https://registry.yarnpkg.com/kleur/-/kleur-4.1.5.tgz#95106101795f7050c6c650f350c683febddb1780" integrity sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ== @@ -8215,7 +8223,7 @@ promise@^7.1.1: dependencies: asap "~2.0.3" -prompts@^2.0.1: +prompts@^2.0.1, prompts@^2.4.2: version "2.4.2" resolved "https://registry.yarnpkg.com/prompts/-/prompts-2.4.2.tgz#7b57e73b3a48029ad10ebd44f74b01722a4cb069" integrity sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q== @@ -9816,7 +9824,7 @@ yargs@^15.3.1: y18n "^4.0.0" yargs-parser "^18.1.2" -yargs@^17.0.0: +yargs@^17.0.0, yargs@^17.7.2: version "17.7.2" resolved "https://registry.yarnpkg.com/yargs/-/yargs-17.7.2.tgz#991df39aca675a192b816e1e0363f9d75d2aa269" integrity sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==