diff --git a/src/abis/ComposableCoW.json b/src/abis/ComposableCoW.json new file mode 100644 index 0000000000..bc5cfed9ef --- /dev/null +++ b/src/abis/ComposableCoW.json @@ -0,0 +1,37 @@ +[ + { + "inputs": [ + { + "components": [ + { + "internalType": "contract IConditionalOrder", + "name": "handler", + "type": "address" + }, + { + "internalType": "bytes32", + "name": "salt", + "type": "bytes32" + }, + { + "internalType": "bytes", + "name": "staticInput", + "type": "bytes" + } + ], + "internalType": "struct IConditionalOrder.ConditionalOrderParams", + "name": "params", + "type": "tuple" + }, + { + "internalType": "bool", + "name": "dispatch", + "type": "bool" + } + ], + "name": "create", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + } +] diff --git a/src/abis/ExtensibleFallbackHandler.json b/src/abis/ExtensibleFallbackHandler.json new file mode 100644 index 0000000000..5e611902c5 --- /dev/null +++ b/src/abis/ExtensibleFallbackHandler.json @@ -0,0 +1,148 @@ +[ + { + "inputs": [ + { + "internalType": "address", + "name": "_defaultFallbackHandler", + "type": "address" + } + ], + "stateMutability": "nonpayable", + "type": "constructor" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "contract Safe", + "name": "safe", + "type": "address" + }, + { + "indexed": false, + "internalType": "bytes4", + "name": "selector", + "type": "bytes4" + }, + { + "indexed": false, + "internalType": "contract IFallbackMethod", + "name": "handler", + "type": "address" + } + ], + "name": "AddedSafeMethod", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "contract Safe", + "name": "safe", + "type": "address" + }, + { + "indexed": false, + "internalType": "address", + "name": "oldHandler", + "type": "address" + }, + { + "indexed": false, + "internalType": "address", + "name": "newHandler", + "type": "address" + } + ], + "name": "ChangedDefaultFallbackHandler", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "contract Safe", + "name": "safe", + "type": "address" + }, + { + "indexed": false, + "internalType": "bytes4", + "name": "selector", + "type": "bytes4" + }, + { + "indexed": false, + "internalType": "contract IFallbackMethod", + "name": "oldHandler", + "type": "address" + }, + { + "indexed": false, + "internalType": "contract IFallbackMethod", + "name": "newHandler", + "type": "address" + } + ], + "name": "ChangedSafeMethod", + "type": "event" + }, + { + "anonymous": false, + "inputs": [ + { + "indexed": true, + "internalType": "contract Safe", + "name": "safe", + "type": "address" + }, + { + "indexed": false, + "internalType": "bytes4", + "name": "selector", + "type": "bytes4" + } + ], + "name": "RemovedSafeMethod", + "type": "event" + }, + { + "stateMutability": "nonpayable", + "type": "fallback" + }, + { + "inputs": [ + { + "internalType": "address", + "name": "newHandler", + "type": "address" + } + ], + "name": "setDefaultFallbackHandler", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + }, + { + "inputs": [ + { + "internalType": "bytes4", + "name": "selector", + "type": "bytes4" + }, + { + "internalType": "contract IFallbackMethod", + "name": "newHandler", + "type": "address" + } + ], + "name": "setSafeMethod", + "outputs": [], + "stateMutability": "nonpayable", + "type": "function" + } +] diff --git a/src/modules/advancedOrders/const.ts b/src/modules/advancedOrders/const.ts new file mode 100644 index 0000000000..b2424f07f3 --- /dev/null +++ b/src/modules/advancedOrders/const.ts @@ -0,0 +1,7 @@ +import { SupportedChainId } from '@cowprotocol/cow-sdk' + +export const COMPOSABLE_COW_ADDRESS: Record = { + 1: 'TODO', + 100: 'TODO', + 5: '0xa31b99bd44528c7bae9e1f675d810ae13b0e29aa', +} diff --git a/src/modules/advancedOrders/hooks/useComposableCowContract.ts b/src/modules/advancedOrders/hooks/useComposableCowContract.ts new file mode 100644 index 0000000000..0fe9ba15f6 --- /dev/null +++ b/src/modules/advancedOrders/hooks/useComposableCowContract.ts @@ -0,0 +1,10 @@ +import { ComposableCoW } from 'abis/types' +import { useWalletInfo } from 'modules/wallet' +import COMPOSABLE_COW_ABI from 'abis/ComposableCoW.json' +import { useContract } from 'legacy/hooks/useContract' +import { COMPOSABLE_COW_ADDRESS } from '../const' + +export function useComposableCowContract(): ComposableCoW | null { + const { chainId } = useWalletInfo() + return useContract(chainId ? COMPOSABLE_COW_ADDRESS[chainId] : undefined, COMPOSABLE_COW_ABI, true) +} diff --git a/src/modules/twap/README.md b/src/modules/twap/README.md new file mode 100644 index 0000000000..d5c3433fb5 --- /dev/null +++ b/src/modules/twap/README.md @@ -0,0 +1,7 @@ +# TWAP orders + +## Twap order creation + +The process follows common pattern: collect context -> execute logic + +![twap-creation](./docs/twap-creation.drawio.svg) diff --git a/src/modules/twap/const.ts b/src/modules/twap/const.ts new file mode 100644 index 0000000000..a2532eb3b0 --- /dev/null +++ b/src/modules/twap/const.ts @@ -0,0 +1,10 @@ +import { SupportedChainId } from '@cowprotocol/cow-sdk' + +export const TWAP_ORDER_STRUCT = + 'tuple(address sellToken,address buyToken,address receiver,uint256 partSellAmount,uint256 minPartLimit,uint256 t0,uint256 n,uint256 t,uint256 span)' + +export const TWAP_HANDLER_ADDRESS: Record = { + 1: 'TODO', + 100: 'TODO', + 5: '0xa12d770028d7072b80baeb6a1df962374fd13d9a', +} diff --git a/src/modules/twap/docs/twap-creation.drawio.svg b/src/modules/twap/docs/twap-creation.drawio.svg new file mode 100644 index 0000000000..5fdda87242 --- /dev/null +++ b/src/modules/twap/docs/twap-creation.drawio.svg @@ -0,0 +1,4 @@ + + + +
TwapOrderCreationContext
TwapOrderCreationContext
+ chainId: SupportedChainId
+ chainId: SupportedChainId
+ safeAppsSdk: SafeAppsSDK
+ safeAppsSdk: SafeAppsSDK
+ composableCowContract: ComposableCoW
+ composableCowContract: ComposableCoW
+ needsApproval: boolean
+ needsApproval: boolean
+ spender: string
+ spender: string
+ erc20Contract: Erc20
+ erc20Contract: Erc20
useTwapOrderCreationContext()
useTwapOrderCreationContext()
settleTwapOrder()
settleTwapOrder()
createTwapOrderTxs()
createTwapOrderTxs()
safeAppsSdk.txs.send({ txs })
safeAppsSdk.txs.send({ txs })
TWAPOrder
TWAPOrder
+ sellAmount: CurrencyAmount<Token>
+ sellAmount: CurrencyAmount<Token>
+ buyAmount: CurrencyAmount<Token>
+ buyAmount: CurrencyAmount<Token>
+ receiver: string
+ receiver: string
+ numOfParts: number
+ numOfParts: number
+ startTime: number
+ startTime: number
+ timeInterval: number
+ timeInterval: number
+ span: number
+ span: number
Text is not SVG - cannot display
\ No newline at end of file diff --git a/src/modules/twap/hooks/useTwapOrderCreationContext.ts b/src/modules/twap/hooks/useTwapOrderCreationContext.ts new file mode 100644 index 0000000000..4aea8e7f60 --- /dev/null +++ b/src/modules/twap/hooks/useTwapOrderCreationContext.ts @@ -0,0 +1,36 @@ +import { useComposableCowContract } from 'modules/advancedOrders/hooks/useComposableCowContract' +import { useNeedsApproval } from 'common/hooks/useNeedsApproval' +import { useTokenContract } from 'legacy/hooks/useContract' +import { ComposableCoW } from 'abis/types' +import { Erc20 } from 'legacy/abis/types' +import { CurrencyAmount, Token } from '@uniswap/sdk-core' +import { Nullish } from 'types' +import { useWalletInfo } from 'modules/wallet' +import { useSafeAppsSdk } from 'modules/wallet/web3-react/hooks/useSafeAppsSdk' +import { SupportedChainId } from '@cowprotocol/cow-sdk' +import SafeAppsSDK from '@safe-global/safe-apps-sdk' +import { useTradeSpenderAddress } from 'common/hooks/useTradeSpenderAddress' + +export interface TwapOrderCreationContext { + chainId: SupportedChainId + safeAppsSdk: SafeAppsSDK + composableCowContract: ComposableCoW + needsApproval: boolean + spender: string + erc20Contract: Erc20 +} + +export function useTwapOrderCreationContext( + inputAmount: Nullish> +): TwapOrderCreationContext | null { + const { chainId } = useWalletInfo() + const safeAppsSdk = useSafeAppsSdk() + const composableCowContract = useComposableCowContract() + const needsApproval = useNeedsApproval(inputAmount) + const erc20Contract = useTokenContract(inputAmount?.currency.address) + const spender = useTradeSpenderAddress() + + if (!composableCowContract || !erc20Contract || !chainId || !safeAppsSdk || !spender) return null + + return { chainId, safeAppsSdk, composableCowContract, erc20Contract, needsApproval, spender } +} diff --git a/src/modules/twap/services/__snapshots__/createTwapOrderTxs.test.ts.snap b/src/modules/twap/services/__snapshots__/createTwapOrderTxs.test.ts.snap new file mode 100644 index 0000000000..e28003542c --- /dev/null +++ b/src/modules/twap/services/__snapshots__/createTwapOrderTxs.test.ts.snap @@ -0,0 +1,67 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Create TWAP order When sell token is NOT approved, then should generate approval and creation transactions 1`] = ` +Array [ + "create", + Array [ + Object { + "handler": "0xa12d770028d7072b80baeb6a1df962374fd13d9a", + "salt": "0x00000000000000000000000000000000000000000000000000000015c90b9b2a", + "staticInput": "0x00000000000000000000000091056d4a53e1faa1a84306d4deaec71085394bc8000000000000000000000000b4fbf271143f4fbf7b91a5ded31805e42b2208d6000000000000000000000000b4fbf271143f4fbf7b91a5ded31805e42b2208d600000000000000000000000000000000000000000000000000000007c2d24d55000000000000000000000000000000000000000000000000000000000001046a00000000000000000000000000000000000000000000000000000000646b782c000000000000000000000000000000000000000000000000000000000000000300000000000000000000000000000000000000000000000000000000000002580000000000000000000000000000000000000000000000000000000000000000", + }, + true, + ], +] +`; + +exports[`Create TWAP order When sell token is NOT approved, then should generate approval and creation transactions 2`] = ` +Array [ + "approve", + Array [ + "0xB4FBF271143F4FBf7B91A5ded31805e42b222222", + "100000000000", + ], +] +`; + +exports[`Create TWAP order When sell token is NOT approved, then should generate approval and creation transactions 3`] = ` +Array [ + Object { + "data": "0xAPPROVE_TX_DATA", + "operation": 0, + "to": "0x91056D4A53E1faa1A84306D4deAEc71085394bC8", + "value": "0", + }, + Object { + "data": "0xCREATE_COW_TX_DATA", + "operation": 0, + "to": "0xa31b99bd44528c7bae9e1f675d810ae13b0e29aa", + "value": "0", + }, +] +`; + +exports[`Create TWAP order When sell token is approved, then should generate only creation transaction 1`] = ` +Array [ + "create", + Array [ + Object { + "handler": "0xa12d770028d7072b80baeb6a1df962374fd13d9a", + "salt": "0x00000000000000000000000000000000000000000000000000000015c90b9b2a", + "staticInput": "0x00000000000000000000000091056d4a53e1faa1a84306d4deaec71085394bc8000000000000000000000000b4fbf271143f4fbf7b91a5ded31805e42b2208d6000000000000000000000000b4fbf271143f4fbf7b91a5ded31805e42b2208d600000000000000000000000000000000000000000000000000000007c2d24d55000000000000000000000000000000000000000000000000000000000001046a00000000000000000000000000000000000000000000000000000000646b782c000000000000000000000000000000000000000000000000000000000000000300000000000000000000000000000000000000000000000000000000000002580000000000000000000000000000000000000000000000000000000000000000", + }, + true, + ], +] +`; + +exports[`Create TWAP order When sell token is approved, then should generate only creation transaction 2`] = ` +Array [ + Object { + "data": "0xCREATE_COW_TX_DATA", + "operation": 0, + "to": "0xa31b99bd44528c7bae9e1f675d810ae13b0e29aa", + "value": "0", + }, +] +`; diff --git a/src/modules/twap/services/createTwapOrderTxs.test.ts b/src/modules/twap/services/createTwapOrderTxs.test.ts new file mode 100644 index 0000000000..7a4f5ef334 --- /dev/null +++ b/src/modules/twap/services/createTwapOrderTxs.test.ts @@ -0,0 +1,65 @@ +import { createTwapOrderTxs } from './createTwapOrderTxs' +import { TWAPOrder } from '../types' +import { CurrencyAmount } from '@uniswap/sdk-core' +import { COW } from '../../../legacy/constants/tokens' +import { SupportedChainId } from '@cowprotocol/cow-sdk' +import { WETH_GOERLI } from '../../../legacy/utils/goerli/constants' +import { TwapOrderCreationContext } from '../hooks/useTwapOrderCreationContext' + +const order: TWAPOrder = { + sellAmount: CurrencyAmount.fromRawAmount(COW[SupportedChainId.GOERLI], 100_000_000_000), + buyAmount: CurrencyAmount.fromRawAmount(WETH_GOERLI, 200_000), + receiver: '0xB4FBF271143F4FBf7B91A5ded31805e42b2208d6', + numOfParts: 3, + startTime: 1684764716, + timeInterval: 600, + span: 0, +} + +const CREATE_COW_TX_DATA = '0xCREATE_COW_TX_DATA' +const APPROVE_TX_DATA = '0xAPPROVE_TX_DATA' + +describe('Create TWAP order', () => { + let context: TwapOrderCreationContext + let createCowFn: jest.Mock + let approveFn: jest.Mock + + beforeEach(() => { + jest.spyOn(Date, 'now').mockImplementation(() => 1497076708000) + + createCowFn = jest.fn().mockReturnValue(CREATE_COW_TX_DATA) + approveFn = jest.fn().mockReturnValue(APPROVE_TX_DATA) + + context = { + chainId: SupportedChainId.GOERLI, + safeAppsSdk: null as any, + composableCowContract: { interface: { encodeFunctionData: createCowFn } } as any, + needsApproval: false, + spender: '0xB4FBF271143F4FBf7B91A5ded31805e42b222222', + erc20Contract: { interface: { encodeFunctionData: approveFn } } as any, + } + }) + + it('When sell token is approved, then should generate only creation transaction', () => { + const result = createTwapOrderTxs(order, { ...context, needsApproval: false }) + + expect(createCowFn).toHaveBeenCalledTimes(1) + expect(createCowFn.mock.calls[0]).toMatchSnapshot() + + expect(result.length).toBe(1) + expect(result).toMatchSnapshot() + }) + + it('When sell token is NOT approved, then should generate approval and creation transactions', () => { + const result = createTwapOrderTxs(order, { ...context, needsApproval: true }) + + expect(createCowFn).toHaveBeenCalledTimes(1) + expect(createCowFn.mock.calls[0]).toMatchSnapshot() + + expect(approveFn).toHaveBeenCalledTimes(1) + expect(approveFn.mock.calls[0]).toMatchSnapshot() + + expect(result.length).toBe(2) + expect(result).toMatchSnapshot() + }) +}) diff --git a/src/modules/twap/services/createTwapOrderTxs.ts b/src/modules/twap/services/createTwapOrderTxs.ts new file mode 100644 index 0000000000..50b6eaae73 --- /dev/null +++ b/src/modules/twap/services/createTwapOrderTxs.ts @@ -0,0 +1,60 @@ +import { IConditionalOrder } from 'abis/types/ComposableCoW' +import { defaultAbiCoder } from '@ethersproject/abi' +import { MetaTransactionData } from '@safe-global/safe-core-sdk-types' +import { TWAPOrder, TWAPOrderStruct } from '../types' +import { TWAP_HANDLER_ADDRESS, TWAP_ORDER_STRUCT } from '../const' +import { COMPOSABLE_COW_ADDRESS } from '../../advancedOrders/const' +import { hexZeroPad } from '@ethersproject/bytes' +import { TwapOrderCreationContext } from '../hooks/useTwapOrderCreationContext' +import { SupportedChainId } from '@cowprotocol/cow-sdk' + +function getTwapOrderParamsStruct( + chainId: SupportedChainId, + order: TWAPOrder +): IConditionalOrder.ConditionalOrderParamsStruct { + const twapOrderData: TWAPOrderStruct = { + sellToken: order.sellAmount.currency.address, + buyToken: order.buyAmount.currency.address, + receiver: order.receiver, + partSellAmount: order.sellAmount.divide(order.numOfParts).quotient.toString(), + minPartLimit: order.buyAmount.divide(order.numOfParts).quotient.toString(), + t0: order.startTime, + n: order.numOfParts, + t: order.timeInterval, + span: order.span, + } + + return { + handler: TWAP_HANDLER_ADDRESS[chainId], + salt: hexZeroPad(Buffer.from(Date.now().toString(16), 'hex'), 32), + staticInput: defaultAbiCoder.encode([TWAP_ORDER_STRUCT], [twapOrderData]), + } +} + +export function createTwapOrderTxs(order: TWAPOrder, context: TwapOrderCreationContext): MetaTransactionData[] { + const { chainId, composableCowContract, needsApproval, erc20Contract, spender } = context + + const sellTokenAddress = order.sellAmount.currency.address + const sellAmountAtoms = order.sellAmount.quotient.toString() + + // TODO: support other conditional orders (stop loss, GAT, etc.) + const creationParams = getTwapOrderParamsStruct(chainId, order) + + const createOrderTx = { + to: COMPOSABLE_COW_ADDRESS[chainId], + data: composableCowContract.interface.encodeFunctionData('create', [creationParams, true]), + value: '0', + operation: 0, + } + + if (!needsApproval) return [createOrderTx] + + const approveTx = { + to: sellTokenAddress, + data: erc20Contract.interface.encodeFunctionData('approve', [spender, sellAmountAtoms]), + value: '0', + operation: 0, + } + + return [approveTx, createOrderTx] +} diff --git a/src/modules/twap/services/settleTwapOrder.test.ts b/src/modules/twap/services/settleTwapOrder.test.ts new file mode 100644 index 0000000000..1fad4649a6 --- /dev/null +++ b/src/modules/twap/services/settleTwapOrder.test.ts @@ -0,0 +1,71 @@ +import { settleTwapOrder } from './settleTwapOrder' +import { useTwapOrderCreationContext } from '../hooks/useTwapOrderCreationContext' +import { CurrencyAmount } from '@uniswap/sdk-core' +import { COW } from 'legacy/constants/tokens' +import { SupportedChainId } from '@cowprotocol/cow-sdk' +import { TWAPOrder } from '../types' +import { WETH_GOERLI } from 'legacy/utils/goerli/constants' +import { renderHook } from '@testing-library/react-hooks' +import { useUpdateAtom } from 'jotai/utils' +import { walletInfoAtom } from 'modules/wallet/api/state' +import { useSafeAppsSdk } from 'modules/wallet/web3-react/hooks/useSafeAppsSdk' +import { useComposableCowContract } from 'modules/advancedOrders/hooks/useComposableCowContract' +import { useNeedsApproval } from 'common/hooks/useNeedsApproval' +import { useTokenContract } from 'legacy/hooks/useContract' +import { useTradeSpenderAddress } from 'common/hooks/useTradeSpenderAddress' +import { useEffect } from 'react' + +jest.mock('modules/wallet/web3-react/hooks/useSafeAppsSdk') +jest.mock('modules/advancedOrders/hooks/useComposableCowContract') +jest.mock('common/hooks/useNeedsApproval') +jest.mock('legacy/hooks/useContract') +jest.mock('common/hooks/useTradeSpenderAddress') + +const chainId = SupportedChainId.GOERLI + +const order: TWAPOrder = { + sellAmount: CurrencyAmount.fromRawAmount(COW[chainId], 100_000_000), + buyAmount: CurrencyAmount.fromRawAmount(WETH_GOERLI, 5_000), + receiver: '0xca063a2ab07491ee991dcecb456d1265f842b568', + numOfParts: 5, + startTime: 1497076708, + timeInterval: 350, + span: 0, +} +const useSafeAppsSdkMock = useSafeAppsSdk as jest.MockedFunction +const useComposableCowContractMock = useComposableCowContract as jest.MockedFunction +const useNeedsApprovalMock = useNeedsApproval as jest.MockedFunction +const useTokenContractMock = useTokenContract as jest.MockedFunction +const useTradeSpenderAddressMock = useTradeSpenderAddress as jest.MockedFunction + +describe('settleTwapOrder - integration test', () => { + beforeEach(() => { + jest.spyOn(Date, 'now').mockImplementation(() => 1497076708000) + + useSafeAppsSdkMock.mockReturnValue({ txs: { send: () => Promise.resolve({ safeTxHash: '0x00b' }) } } as any) + useComposableCowContractMock.mockReturnValue({ + interface: { encodeFunctionData: () => '0xCREATE_COW_TX_DATA' }, + } as any) + useNeedsApprovalMock.mockReturnValue(true) + useTokenContractMock.mockReturnValue({ interface: { encodeFunctionData: () => '0xAPPROVE_TX_DATA' } } as any) + useTradeSpenderAddressMock.mockReturnValue('0xes8p7e9n3dbedr4e7caf8451050d1948be717679') + }) + + it('Should send a bundle of transactions to Safe with a TWAP order creation', async () => { + const { result } = renderHook(() => { + const updateWalletInfo = useUpdateAtom(walletInfoAtom) + + useEffect(() => { + updateWalletInfo({ chainId }) + }, [updateWalletInfo]) + + const context = useTwapOrderCreationContext(order.sellAmount) + + if (!context) return null + + return settleTwapOrder(order, context) + }) + + expect(await result.current).toEqual({ safeTxHash: '0x00b' }) + }) +}) diff --git a/src/modules/twap/services/settleTwapOrder.ts b/src/modules/twap/services/settleTwapOrder.ts new file mode 100644 index 0000000000..854bbc8e0b --- /dev/null +++ b/src/modules/twap/services/settleTwapOrder.ts @@ -0,0 +1,20 @@ +import { createTwapOrderTxs } from './createTwapOrderTxs' +import { TWAPOrder } from '../types' +import { TwapOrderCreationContext } from '../hooks/useTwapOrderCreationContext' +import { SendTransactionsResponse } from '@safe-global/safe-apps-sdk' + +export async function settleTwapOrder( + order: TWAPOrder, + context: TwapOrderCreationContext +): Promise { + const { safeAppsSdk } = context + + const txs = createTwapOrderTxs(order, context) + + const response = await safeAppsSdk.txs.send({ txs }) + + // TODO: process the sent transaction + console.log('TWAP order: ', response) + + return response +} diff --git a/src/modules/twap/types.ts b/src/modules/twap/types.ts new file mode 100644 index 0000000000..ee2bbc698a --- /dev/null +++ b/src/modules/twap/types.ts @@ -0,0 +1,24 @@ +import { CurrencyAmount, Token } from '@uniswap/sdk-core' + +// Read more: https://github.com/rndlabs/composable-cow#data-structure +export interface TWAPOrder { + sellAmount: CurrencyAmount + buyAmount: CurrencyAmount + receiver: string + numOfParts: number + startTime: number + timeInterval: number + span: number +} + +export interface TWAPOrderStruct { + sellToken: string + buyToken: string + receiver: string + partSellAmount: string + minPartLimit: string + t0: number + n: number + t: number + span: number +}