From c503c160f260a7027fdbcd7c1a3b11bb4257dece Mon Sep 17 00:00:00 2001 From: martonp Date: Tue, 26 Dec 2023 10:08:18 -0500 Subject: [PATCH] client/mm: Refactor bot balance tracking - This diff updates the balance tracking for market making bots. Previously, there was a fragile diff based accounting technique. Now, on each bot action, including trades, withdrawals, and deposits, a pending action is stored. This pending action is updated until it is complete, at which time it is removed, and the "base" balance of the bot is updated. The bot balances are calculated by adding the base balances of the bot to the effects of all pending actions. - The `wrappedCore` and `wrappedCEX` types are removed, and their functionalities are combined into a `unifiedExchangeAdaptor`. This is useful because pending deposits and pending withdrawals have balance effects on both CEX and DEX balances. Each `unifiedExchangeAdaptor` listens to a core notification stream, and the `MarketMaker` type no longer needs to. In a future refactor, the bots themselves will no longer listen to the core notification functionality, and all common functionalities can be moved to the `unifiedExchangeAdaptor`. - Previously, the fee assets of tokens were not taken into account, but now they are. - The Binance library now uses a different endpoint, `/api/v3/balances`, to get balances. This is supported by the Binance testnet api, so testbinance is no longer required for balances. --- client/cmd/testbinance/main.go | 758 +++- client/mm/config.go | 5 + client/mm/exchange_adaptor.go | 1347 +++++++ client/mm/exchange_adaptor_test.go | 2943 +++++++++++++++ client/mm/libxc/binance.go | 191 +- client/mm/libxc/binance_live_test.go | 34 +- client/mm/libxc/binance_test.go | 2 +- client/mm/libxc/interface.go | 18 +- client/mm/mm.go | 744 +--- client/mm/mm_arb_market_maker.go | 23 +- client/mm/mm_arb_market_maker_test.go | 90 +- client/mm/mm_basic.go | 7 +- client/mm/mm_basic_test.go | 4 +- client/mm/mm_simple_arb.go | 31 +- client/mm/mm_simple_arb_test.go | 380 +- client/mm/mm_test.go | 5040 +++---------------------- client/mm/wrapped_cex.go | 313 -- client/mm/wrapped_core.go | 473 --- 18 files changed, 6051 insertions(+), 6352 deletions(-) create mode 100644 client/mm/exchange_adaptor.go create mode 100644 client/mm/exchange_adaptor_test.go delete mode 100644 client/mm/wrapped_cex.go delete mode 100644 client/mm/wrapped_core.go diff --git a/client/cmd/testbinance/main.go b/client/cmd/testbinance/main.go index 920856e867..c60c773b03 100644 --- a/client/cmd/testbinance/main.go +++ b/client/cmd/testbinance/main.go @@ -13,20 +13,13 @@ import ( "net/http" "os" "strconv" - "strings" "sync" - "time" "decred.org/dcrdex/client/websocket" "decred.org/dcrdex/dex" "decred.org/dcrdex/dex/encode" ) -const ( - pongWait = 60 * time.Second - pingPeriod = (pongWait * 9) / 10 -) - var ( log = dex.StdOutLogger("TBNC", dex.LevelDebug) ) @@ -90,29 +83,7 @@ func main() { func runServer() error { f := &fakeBinance{ - wsServer: websocket.New(nil, log.SubLogger("WS")), - balances: map[string]*balance{ - "eth": { - free: 0.5, - locked: 0, - }, - "btc": { - free: 10, - locked: 0, - }, - "ltc": { - free: 1000.8689444, - locked: 0, - }, - "bch": { - free: 1000.2358249, - locked: 0, - }, - "dcr": { - free: 1000.2358249, - locked: 0, - }, - }, + wsServer: websocket.New(nil, log.SubLogger("WS")), withdrawalHistory: make([]*transfer, 0), depositHistory: make([]*transfer, 0), } @@ -183,9 +154,6 @@ type transfer struct { type fakeBinance struct { wsServer *websocket.Server - balanceMtx sync.RWMutex - balances map[string]*balance - withdrawalHistoryMtx sync.RWMutex withdrawalHistory []*transfer @@ -194,8 +162,695 @@ type fakeBinance struct { } func (f *fakeBinance) handleWalletCoinsReq(w http.ResponseWriter, r *http.Request) { - ci := f.coinInfo() - writeJSONWithStatus(w, ci, http.StatusOK) + // Returning configs for eth, btc, ltc, bch, zec, usdc, matic. + // Balances do not use a sapi endpoint, so they do not need to be handled + // here. + resp := `[ + { + "coin": "MATIC", + "depositAllEnable": true, + "free": "0", + "freeze": "0", + "ipoable": "0", + "ipoing": "0", + "isLegalMoney": false, + "locked": "0", + "name": "Polygon", + "storage": "0", + "trading": true, + "withdrawAllEnable": true, + "withdrawing": "0", + "networkList": [ + { + "addressRegex": "^(0x)[0-9A-Fa-f]{40}$", + "coin": "MATIC", + "depositEnable": true, + "isDefault": false, + "memoRegex": "", + "minConfirm": 15, + "name": "BNB Smart Chain (BEP20)", + "network": "BSC", + "resetAddressStatus": false, + "specialTips": "", + "unLockConfirm": 0, + "withdrawEnable": true, + "withdrawFee": "0.8", + "withdrawIntegerMultiple": "1e-8", + "withdrawMax": "9999999", + "withdrawMin": "20", + "sameAddress": false, + "estimatedArrivalTime": 0, + "busy": false + }, + { + "addressRegex": "^(0x)[0-9A-Fa-f]{40}$", + "coin": "MATIC", + "depositEnable": true, + "isDefault": true, + "memoRegex": "", + "minConfirm": 6, + "name": "Ethereum (ERC20)", + "network": "ETH", + "resetAddressStatus": false, + "specialTips": "", + "unLockConfirm": 64, + "withdrawEnable": true, + "withdrawFee": "15.8", + "withdrawIntegerMultiple": "1e-8", + "withdrawMax": "10000000000", + "withdrawMin": "31.6", + "sameAddress": false, + "estimatedArrivalTime": 0, + "busy": false + }, + { + "addressRegex": "^(0x)[0-9A-Fa-f]{40}$", + "coin": "MATIC", + "depositEnable": true, + "isDefault": false, + "memoRegex": "", + "minConfirm": 300, + "name": "Polygon", + "network": "MATIC", + "resetAddressStatus": false, + "specialTips": "", + "unLockConfirm": 800, + "withdrawEnable": true, + "withdrawFee": "0.1", + "withdrawIntegerMultiple": "1e-8", + "withdrawMax": "9999999", + "withdrawMin": "20", + "sameAddress": false, + "estimatedArrivalTime": 0, + "busy": false + } + ] + }, + { + "coin": "ETH", + "depositAllEnable": true, + "free": "0", + "freeze": "0", + "ipoable": "0", + "ipoing": "0", + "isLegalMoney": false, + "locked": "0", + "name": "Ethereum", + "storage": "0", + "trading": true, + "withdrawAllEnable": true, + "withdrawing": "0", + "networkList": [ + { + "addressRegex": "^(0x)[0-9A-Fa-f]{40}$", + "coin": "ETH", + "depositEnable": true, + "isDefault": false, + "memoRegex": "", + "minConfirm": 100, + "name": "Arbitrum One", + "network": "ARBITRUM", + "resetAddressStatus": false, + "specialTips": "", + "unLockConfirm": 0, + "withdrawEnable": true, + "withdrawFee": "0.00035", + "withdrawIntegerMultiple": "1e-8", + "withdrawMax": "9999999", + "withdrawMin": "0.0008", + "sameAddress": false, + "estimatedArrivalTime": 0, + "busy": false + }, + { + "addressRegex": "^(bnb1)[0-9a-z]{38}$", + "coin": "ETH", + "depositEnable": true, + "isDefault": false, + "memoRegex": "^[0-9A-Za-z\\-_]{1,120}$", + "minConfirm": 1, + "name": "BNB Beacon Chain (BEP2)", + "network": "BNB", + "resetAddressStatus": false, + "specialTips": "Both a MEMO and an Address are required to successfully deposit your BEP2 tokens to Binance US.", + "unLockConfirm": 0, + "withdrawEnable": false, + "withdrawFee": "0.000086", + "withdrawIntegerMultiple": "1e-8", + "withdrawMax": "10000000000", + "withdrawMin": "0.0005", + "sameAddress": false, + "estimatedArrivalTime": 0, + "busy": false + }, + { + "addressRegex": "^(0x)[0-9A-Fa-f]{40}$", + "coin": "ETH", + "depositEnable": true, + "isDefault": false, + "memoRegex": "", + "minConfirm": 15, + "name": "BNB Smart Chain (BEP20)", + "network": "BSC", + "resetAddressStatus": false, + "specialTips": "", + "unLockConfirm": 0, + "withdrawEnable": true, + "withdrawFee": "0.000057", + "withdrawIntegerMultiple": "1e-8", + "withdrawMax": "10000000000", + "withdrawMin": "0.00011", + "sameAddress": false, + "estimatedArrivalTime": 0, + "busy": false + }, + { + "addressRegex": "^(0x)[0-9A-Fa-f]{40}$", + "coin": "ETH", + "depositEnable": true, + "isDefault": true, + "memoRegex": "", + "minConfirm": 6, + "name": "Ethereum (ERC20)", + "network": "ETH", + "resetAddressStatus": false, + "specialTips": "", + "unLockConfirm": 64, + "withdrawEnable": true, + "withdrawFee": "0.00221", + "withdrawIntegerMultiple": "1e-8", + "withdrawMax": "10000000000", + "withdrawMin": "0.00442", + "sameAddress": false, + "estimatedArrivalTime": 0, + "busy": false + }, + { + "addressRegex": "^(0x)[0-9A-Fa-f]{40}$", + "coin": "ETH", + "depositEnable": true, + "isDefault": false, + "memoRegex": "", + "minConfirm": 100, + "name": "Optimism", + "network": "OPTIMISM", + "resetAddressStatus": false, + "specialTips": "", + "unLockConfirm": 120, + "withdrawEnable": true, + "withdrawFee": "0.00035", + "withdrawIntegerMultiple": "1e-8", + "withdrawMax": "10000000000", + "withdrawMin": "0.001", + "sameAddress": false, + "estimatedArrivalTime": 0, + "busy": false + } + ] + }, + { + "coin": "ZEC", + "depositAllEnable": true, + "free": "0", + "freeze": "0", + "ipoable": "0", + "ipoing": "0", + "isLegalMoney": false, + "locked": "0", + "name": "Zcash", + "storage": "0", + "trading": true, + "withdrawAllEnable": true, + "withdrawing": "0", + "networkList": [ + { + "addressRegex": "^(0x)[0-9A-Fa-f]{40}$", + "coin": "ZEC", + "depositEnable": true, + "isDefault": false, + "memoRegex": "", + "minConfirm": 15, + "name": "BNB Smart Chain (BEP20)", + "network": "BSC", + "resetAddressStatus": false, + "specialTips": "", + "unLockConfirm": 0, + "withdrawEnable": false, + "withdrawFee": "0.0041", + "withdrawIntegerMultiple": "1e-8", + "withdrawMax": "10000000000", + "withdrawMin": "0.0082", + "sameAddress": false, + "estimatedArrivalTime": 0, + "busy": false + }, + { + "addressRegex": "^(t)[A-Za-z0-9]{34}$", + "coin": "ZEC", + "depositEnable": true, + "isDefault": true, + "memoRegex": "", + "minConfirm": 15, + "name": "Zcash", + "network": "ZEC", + "resetAddressStatus": false, + "specialTips": "", + "unLockConfirm": 20, + "withdrawEnable": true, + "withdrawFee": "0.005", + "withdrawIntegerMultiple": "1e-8", + "withdrawMax": "10000000000", + "withdrawMin": "0.01", + "sameAddress": false, + "estimatedArrivalTime": 0, + "busy": false + } + ] + }, + { + "coin": "BTC", + "depositAllEnable": true, + "free": "0", + "freeze": "0", + "ipoable": "0", + "ipoing": "0", + "isLegalMoney": false, + "locked": "0", + "name": "Bitcoin", + "storage": "0", + "trading": true, + "withdrawAllEnable": true, + "withdrawing": "0", + "networkList": [ + { + "addressRegex": "^(bnb1)[0-9a-z]{38}$", + "coin": "BTC", + "depositEnable": true, + "isDefault": false, + "memoRegex": "^[0-9A-Za-z\\-_]{1,120}$", + "minConfirm": 1, + "name": "BNB Beacon Chain (BEP2)", + "network": "BNB", + "resetAddressStatus": false, + "specialTips": "Both a MEMO and an Address are required to successfully deposit your BEP2-BTCB tokens to Binance.", + "unLockConfirm": 0, + "withdrawEnable": false, + "withdrawFee": "0.0000061", + "withdrawIntegerMultiple": "1e-8", + "withdrawMax": "10000000000", + "withdrawMin": "0.000012", + "sameAddress": false, + "estimatedArrivalTime": 0, + "busy": false + }, + { + "addressRegex": "^(0x)[0-9A-Fa-f]{40}$", + "coin": "BTC", + "depositEnable": true, + "isDefault": false, + "memoRegex": "", + "minConfirm": 15, + "name": "BNB Smart Chain (BEP20)", + "network": "BSC", + "resetAddressStatus": false, + "specialTips": "", + "unLockConfirm": 0, + "withdrawEnable": true, + "withdrawFee": "0.000003", + "withdrawIntegerMultiple": "1e-8", + "withdrawMax": "10000000000", + "withdrawMin": "0.000006", + "sameAddress": false, + "estimatedArrivalTime": 0, + "busy": false + }, + { + "addressRegex": "^[13][a-km-zA-HJ-NP-Z1-9]{25,34}$|^[(bc1q)|(bc1p)][0-9A-Za-z]{37,62}$", + "coin": "BTC", + "depositEnable": true, + "isDefault": true, + "memoRegex": "", + "minConfirm": 1, + "name": "Bitcoin", + "network": "BTC", + "resetAddressStatus": false, + "specialTips": "", + "unLockConfirm": 2, + "withdrawEnable": true, + "withdrawFee": "0.00025", + "withdrawIntegerMultiple": "1e-8", + "withdrawMax": "10000000000", + "withdrawMin": "0.0005", + "sameAddress": false, + "estimatedArrivalTime": 0, + "busy": false + } + ] + }, + { + "coin": "LTC", + "depositAllEnable": true, + "free": "0", + "freeze": "0", + "ipoable": "0", + "ipoing": "0", + "isLegalMoney": false, + "locked": "0", + "name": "Litecoin", + "storage": "0", + "trading": true, + "withdrawAllEnable": true, + "withdrawing": "0", + "networkList": [ + { + "addressRegex": "^(bnb1)[0-9a-z]{38}$", + "coin": "LTC", + "depositEnable": true, + "isDefault": false, + "memoRegex": "^[0-9A-Za-z\\-_]{1,120}$", + "minConfirm": 1, + "name": "BNB Beacon Chain (BEP2)", + "network": "BNB", + "resetAddressStatus": false, + "specialTips": "Both a MEMO and an Address are required to successfully deposit your LTC BEP2 tokens to Binance.", + "unLockConfirm": 0, + "withdrawEnable": false, + "withdrawFee": "0.0035", + "withdrawIntegerMultiple": "1e-8", + "withdrawMax": "10000000000", + "withdrawMin": "0.007", + "sameAddress": false, + "estimatedArrivalTime": 0, + "busy": false + }, + { + "addressRegex": "^(0x)[0-9A-Fa-f]{40}$", + "coin": "LTC", + "depositEnable": true, + "isDefault": false, + "memoRegex": "", + "minConfirm": 15, + "name": "BNB Smart Chain (BEP20)", + "network": "BSC", + "resetAddressStatus": false, + "specialTips": "", + "unLockConfirm": 0, + "withdrawEnable": true, + "withdrawFee": "0.0017", + "withdrawIntegerMultiple": "1e-8", + "withdrawMax": "10000000000", + "withdrawMin": "0.0034", + "sameAddress": false, + "estimatedArrivalTime": 0, + "busy": false + }, + { + "addressRegex": "^(L|M)[A-Za-z0-9]{33}$|^(ltc1)[0-9A-Za-z]{39}$", + "coin": "LTC", + "depositEnable": true, + "isDefault": true, + "memoRegex": "", + "minConfirm": 3, + "name": "Litecoin", + "network": "LTC", + "resetAddressStatus": false, + "specialTips": "", + "unLockConfirm": 4, + "withdrawEnable": true, + "withdrawFee": "0.00125", + "withdrawIntegerMultiple": "1e-8", + "withdrawMax": "10000000000", + "withdrawMin": "0.002", + "sameAddress": false, + "estimatedArrivalTime": 0, + "busy": false + } + ] + }, + { + "coin": "BCH", + "depositAllEnable": true, + "free": "0", + "freeze": "0", + "ipoable": "0", + "ipoing": "0", + "isLegalMoney": false, + "locked": "0", + "name": "Bitcoin Cash", + "storage": "0", + "trading": true, + "withdrawAllEnable": true, + "withdrawing": "0", + "networkList": [ + { + "addressRegex": "^[1][a-km-zA-HJ-NP-Z1-9]{25,34}$|^[0-9a-z]{42,42}$", + "coin": "BCH", + "depositEnable": true, + "isDefault": true, + "memoRegex": "", + "minConfirm": 2, + "name": "Bitcoin Cash", + "network": "BCH", + "resetAddressStatus": false, + "specialTips": "", + "unLockConfirm": 6, + "withdrawEnable": true, + "withdrawFee": "0.0008", + "withdrawIntegerMultiple": "1e-8", + "withdrawMax": "10000000000", + "withdrawMin": "0.002", + "sameAddress": false, + "estimatedArrivalTime": 0, + "busy": false + }, + { + "addressRegex": "^(bnb1)[0-9a-z]{38}$", + "coin": "BCH", + "depositEnable": false, + "isDefault": false, + "memoRegex": "^[0-9A-Za-z\\-_]{1,120}$", + "minConfirm": 1, + "name": "BNB Beacon Chain (BEP2)", + "network": "BNB", + "resetAddressStatus": false, + "specialTips": "Both a MEMO and an Address are required to successfully deposit your BCH BEP2 tokens to Binance.", + "unLockConfirm": 0, + "withdrawEnable": false, + "withdrawFee": "0.0011", + "withdrawIntegerMultiple": "1e-8", + "withdrawMax": "10000000000", + "withdrawMin": "0.0022", + "sameAddress": false, + "estimatedArrivalTime": 0, + "busy": false + }, + { + "addressRegex": "^(0x)[0-9A-Fa-f]{40}$", + "coin": "BCH", + "depositEnable": true, + "isDefault": false, + "memoRegex": "", + "minConfirm": 15, + "name": "BNB Smart Chain (BEP20)", + "network": "BSC", + "resetAddressStatus": false, + "specialTips": "", + "unLockConfirm": 0, + "withdrawEnable": true, + "withdrawFee": "0.00054", + "withdrawIntegerMultiple": "1e-8", + "withdrawMax": "10000000000", + "withdrawMin": "0.0011", + "sameAddress": false, + "estimatedArrivalTime": 0, + "busy": false + } + ] + }, + { + "coin": "USDC", + "depositAllEnable": true, + "free": "0", + "freeze": "0", + "ipoable": "0", + "ipoing": "0", + "isLegalMoney": false, + "locked": "0", + "name": "USD Coin", + "storage": "0", + "trading": true, + "withdrawAllEnable": true, + "withdrawing": "0", + "networkList": [ + { + "addressRegex": "^(0x)[0-9A-Fa-f]{40}$", + "coin": "USDC", + "depositEnable": true, + "isDefault": false, + "memoRegex": "", + "minConfirm": 12, + "name": "AVAX C-Chain", + "network": "AVAXC", + "resetAddressStatus": false, + "specialTips": "", + "unLockConfirm": 0, + "withdrawEnable": true, + "withdrawFee": "1", + "withdrawIntegerMultiple": "0.000001", + "withdrawMax": "9999999", + "withdrawMin": "50", + "sameAddress": false, + "estimatedArrivalTime": 0, + "busy": false + }, + { + "addressRegex": "^(0x)[0-9A-Fa-f]{40}$", + "coin": "USDC", + "depositEnable": true, + "isDefault": false, + "memoRegex": "", + "minConfirm": 15, + "name": "BNB Smart Chain (BEP20)", + "network": "BSC", + "resetAddressStatus": false, + "specialTips": "", + "unLockConfirm": 0, + "withdrawEnable": true, + "withdrawFee": "0.29", + "withdrawIntegerMultiple": "1e-8", + "withdrawMax": "10000000000", + "withdrawMin": "10", + "sameAddress": false, + "estimatedArrivalTime": 0, + "busy": false + }, + { + "addressRegex": "^(0x)[0-9A-Fa-f]{40}$", + "coin": "USDC", + "depositEnable": true, + "isDefault": true, + "memoRegex": "", + "minConfirm": 6, + "name": "Ethereum (ERC20)", + "network": "ETH", + "resetAddressStatus": false, + "specialTips": "", + "unLockConfirm": 64, + "withdrawEnable": true, + "withdrawFee": "15.16", + "withdrawIntegerMultiple": "0.000001", + "withdrawMax": "10000000000", + "withdrawMin": "30.32", + "sameAddress": false, + "estimatedArrivalTime": 0, + "busy": false + }, + { + "addressRegex": "^(0x)[0-9A-Fa-f]{40}$", + "coin": "USDC", + "depositEnable": false, + "isDefault": false, + "memoRegex": "", + "minConfirm": 300, + "name": "Polygon", + "network": "MATIC", + "resetAddressStatus": false, + "specialTips": "", + "unLockConfirm": 800, + "withdrawEnable": false, + "withdrawFee": "1", + "withdrawIntegerMultiple": "0.000001", + "withdrawMax": "9999999", + "withdrawMin": "10", + "sameAddress": false, + "estimatedArrivalTime": 0, + "busy": false + }, + { + "addressRegex": "^[1-9A-HJ-NP-Za-km-z]{32,44}$", + "coin": "USDC", + "depositEnable": true, + "isDefault": false, + "memoRegex": "", + "minConfirm": 1, + "name": "Solana", + "network": "SOL", + "resetAddressStatus": false, + "specialTips": "", + "unLockConfirm": 0, + "withdrawEnable": true, + "withdrawFee": "1", + "withdrawIntegerMultiple": "0.000001", + "withdrawMax": "250000100", + "withdrawMin": "10", + "sameAddress": false, + "estimatedArrivalTime": 0, + "busy": false + }, + { + "addressRegex": "^T[1-9A-HJ-NP-Za-km-z]{33}$", + "coin": "USDC", + "depositEnable": true, + "isDefault": false, + "memoRegex": "", + "minConfirm": 1, + "name": "Tron (TRC20)", + "network": "TRX", + "resetAddressStatus": false, + "specialTips": "", + "unLockConfirm": 0, + "withdrawEnable": true, + "withdrawFee": "1.5", + "withdrawIntegerMultiple": "0.000001", + "withdrawMax": "10000000000", + "withdrawMin": "10", + "sameAddress": false, + "estimatedArrivalTime": 0, + "busy": false + } + ] + }, + { + "coin": "WBTC", + "depositAllEnable": true, + "free": "0", + "freeze": "0", + "ipoable": "0", + "ipoing": "0", + "isLegalMoney": false, + "locked": "0", + "name": "Wrapped Bitcoin", + "storage": "0", + "trading": true, + "withdrawAllEnable": true, + "withdrawing": "0", + "networkList": [ + { + "addressRegex": "^(0x)[0-9A-Fa-f]{40}$", + "coin": "WBTC", + "depositEnable": true, + "isDefault": true, + "memoRegex": "", + "minConfirm": 6, + "name": "Ethereum (ERC20)", + "network": "ETH", + "resetAddressStatus": false, + "specialTips": "", + "unLockConfirm": 64, + "withdrawEnable": true, + "withdrawFee": "0.0003", + "withdrawIntegerMultiple": "1e-8", + "withdrawMax": "10000000000", + "withdrawMin": "0.0006", + "sameAddress": false, + "estimatedArrivalTime": 0, + "busy": false + } + ] + } + ]` + + writeBytesWithStatus(w, []byte(resp), http.StatusOK) } func (f *fakeBinance) handleConfirmDeposit(w http.ResponseWriter, r *http.Request) { @@ -398,33 +1053,6 @@ type fakeBinanceCoinInfo struct { NetworkList []*fakeBinanceNetworkInfo `json:"networkList"` } -func (f *fakeBinance) coinInfo() (coins []*fakeBinanceCoinInfo) { - f.balanceMtx.Lock() - for symbol, bal := range f.balances { - bigSymbol := strings.ToUpper(symbol) - coins = append(coins, &fakeBinanceCoinInfo{ - Coin: bigSymbol, - Free: strconv.FormatFloat(bal.free, 'f', 8, 64), - Locked: strconv.FormatFloat(bal.locked, 'f', 8, 64), - Withdrawing: "0", - NetworkList: []*fakeBinanceNetworkInfo{ - { - Coin: bigSymbol, - Network: bigSymbol, - MinConfirm: 1, - WithdrawEnable: true, - WithdrawFee: strconv.FormatFloat(0.00000800, 'f', 8, 64), - WithdrawIntegerMultiple: strconv.FormatFloat(0.00000001, 'f', 8, 64), - WithdrawMax: strconv.FormatFloat(1000, 'f', 8, 64), - WithdrawMin: strconv.FormatFloat(0.01, 'f', 8, 64), - }, - }, - }) - } - f.balanceMtx.Unlock() - return -} - // writeJSON marshals the provided interface and writes the bytes to the // ResponseWriter with the specified response code. func writeJSONWithStatus(w http.ResponseWriter, thing interface{}, code int) { @@ -435,8 +1063,12 @@ func writeJSONWithStatus(w http.ResponseWriter, thing interface{}, code int) { log.Errorf("JSON encode error: %v", err) return } + writeBytesWithStatus(w, b, code) +} + +func writeBytesWithStatus(w http.ResponseWriter, b []byte, code int) { w.WriteHeader(code) - _, err = w.Write(append(b, byte('\n'))) + _, err := w.Write(append(b, byte('\n'))) if err != nil { log.Errorf("Write error: %v", err) } diff --git a/client/mm/config.go b/client/mm/config.go index 92c19e7ed4..549648fe31 100644 --- a/client/mm/config.go +++ b/client/mm/config.go @@ -49,6 +49,11 @@ type BotConfig struct { QuoteBalanceType BalanceType `json:"quoteBalanceType"` QuoteBalance uint64 `json:"quoteBalance"` + BaseFeeAssetBalanceType BalanceType `json:"baseFeeAssetBalanceType"` + BaseFeeAssetBalance uint64 `json:"baseFeeAssetBalance"` + QuoteFeeAssetBalanceType BalanceType `json:"quoteFeeAssetBalanceType"` + QuoteFeeAssetBalance uint64 `json:"quoteFeeAssetBalance"` + // Only applicable for arb bots. CEXCfg *BotCEXCfg `json:"cexCfg"` diff --git a/client/mm/exchange_adaptor.go b/client/mm/exchange_adaptor.go new file mode 100644 index 0000000000..ef7d3abe26 --- /dev/null +++ b/client/mm/exchange_adaptor.go @@ -0,0 +1,1347 @@ +// This code is available on the terms of the project LICENSE.md file, +// also available online at https://blueoakcouncil.org/license/1.0.0. + +package mm + +import ( + "context" + "encoding/hex" + "errors" + "fmt" + "strings" + "sync" + "sync/atomic" + "time" + + "decred.org/dcrdex/client/asset" + "decred.org/dcrdex/client/core" + "decred.org/dcrdex/client/mm/libxc" + "decred.org/dcrdex/client/orderbook" + "decred.org/dcrdex/dex" + "decred.org/dcrdex/dex/calc" + "decred.org/dcrdex/dex/order" +) + +// botBalance keeps track of the amount of funds available for a +// bot's use, locked to fund orders, and pending. +type botBalance struct { + Available uint64 `json:"available"` + Locked uint64 `json:"locked"` + Pending uint64 `json:"pending"` +} + +// botCoreAdaptor is an interface used by bots to access functionality +// implemented by client.Core. There are slight differences with the methods +// of client.Core. One example is AssetBalance returns a botBalance instead of +// a *core.WalletBalance. +type botCoreAdaptor interface { + NotificationFeed() *core.NoteFeed + SyncBook(host string, base, quote uint32) (*orderbook.OrderBook, core.BookFeed, error) + SingleLotFees(form *core.SingleLotFeesForm) (uint64, uint64, uint64, error) + Cancel(oidB dex.Bytes) error + MaxBuy(host string, base, quote uint32, rate uint64) (*core.MaxOrderEstimate, error) + MaxSell(host string, base, quote uint32) (*core.MaxOrderEstimate, error) + AssetBalance(assetID uint32) (*botBalance, error) + MultiTrade(pw []byte, form *core.MultiTradeForm) ([]*core.Order, error) + MaxFundingFees(fromAsset uint32, host string, numTrades uint32, fromSettings map[string]string) (uint64, error) + FiatConversionRates() map[uint32]float64 + ExchangeMarket(host string, base, quote uint32) (*core.Market, error) + Broadcast(core.Notification) +} + +// botCexAdaptor is an interface used by bots to access functionality +// related to a CEX. Some of the functions are implemented by libxc.CEX, but +// have some differences, since this interface is meant to be used by only +// one caller. For example, Trade does not take a subscriptionID, and +// SubscribeTradeUpdates does not return one. Deposit is not available +// on libxc.CEX as it involves sending funds from the DEX wallet, but it is +// exposed here. +type botCexAdaptor interface { + Balance(assetID uint32) (*botBalance, error) + CancelTrade(ctx context.Context, baseID, quoteID uint32, tradeID string) error + SubscribeMarket(ctx context.Context, baseID, quoteID uint32) error + SubscribeTradeUpdates() (updates <-chan *libxc.Trade, unsubscribe func()) + Trade(ctx context.Context, baseID, quoteID uint32, sell bool, rate, qty uint64) (*libxc.Trade, error) + VWAP(baseID, quoteID uint32, sell bool, qty uint64) (vwap, extrema uint64, filled bool, err error) + Deposit(ctx context.Context, assetID uint32, amount uint64, onConfirm func()) error + Withdraw(ctx context.Context, assetID uint32, amount uint64, onConfirm func()) error +} + +// pendingWithdrawal represents a withdrawal from a CEX that has been +// initiated, but the DEX has not yet received. +type pendingWithdrawal struct { + assetID uint32 + // amtWithdrawn is the amount the CEX balance is decreased by. + // It will not be the same as the amount received in the dex wallet. + amtWithdrawn uint64 +} + +// pendingDeposit represents a deposit to a CEX that has not yet been +// confirmed. +type pendingDeposit struct { + mtx sync.RWMutex + assetID uint32 + amtSent uint64 + fee uint64 + amtCredited uint64 + // feeConfirmed is relevant assets with dynamic fees where the fee is + // not known until the tx is mined. + feeConfirmed bool + cexConfirmed bool + // cexConfirmed == true && success == false means the CEX did not credit the + // deposit for some reason. + success bool +} + +// pendingDEXOrder keeps track of the balance effects of a pending DEX order. +// The actual order is not stored here, only its effects on the balance. +type pendingDEXOrder struct { + balancesMtx sync.RWMutex + availableDiff map[uint32]int64 + locked map[uint32]uint64 + pending map[uint32]uint64 + + // swaps, redeems, and refunds are caches of transactions. This avoids + // having to query the wallet for transactions that are already confirmed. + txsMtx sync.RWMutex + swaps map[string]*asset.WalletTransaction + redeems map[string]*asset.WalletTransaction + refunds map[string]*asset.WalletTransaction +} + +// unifiedExchangeAdaptor implements both botCoreAdaptor and botCexAdaptor. +type unifiedExchangeAdaptor struct { + clientCore + libxc.CEX + + botID string + log dex.Logger + + subscriptionIDMtx sync.RWMutex + subscriptionID *int + + withdrawalNoncePrefix string + withdrawalNonce atomic.Uint64 + + balancesMtx sync.RWMutex + // baseDEXBalance/baseCEXBalance are the balances the bots have before + // taking into account any pending actions. These are updated whenever + // a pending action is completed. + baseDexBalances map[uint32]uint64 + baseCexBalances map[uint32]uint64 + pendingDEXOrders map[order.OrderID]*pendingDEXOrder + pendingCEXOrders map[string]*libxc.Trade + pendingWithdrawals map[string]*pendingWithdrawal + pendingDeposits map[string]*pendingDeposit +} + +var _ botCoreAdaptor = (*unifiedExchangeAdaptor)(nil) +var _ botCexAdaptor = (*unifiedExchangeAdaptor)(nil) + +func (u *unifiedExchangeAdaptor) logBalanceAdjustments(dexDiffs, cexDiffs map[uint32]int64, reason string) { + var msg strings.Builder + msg.WriteString("\n" + reason) + msg.WriteString("\n Balance adjustments:") + if len(dexDiffs) > 0 { + msg.WriteString("\n DEX: ") + i := 0 + for assetID, diff := range dexDiffs { + msg.WriteString(fmt.Sprintf("%s: %d", dex.BipIDSymbol(assetID), diff)) + if i < len(dexDiffs)-1 { + msg.WriteString(", ") + } + i++ + } + } + + if len(cexDiffs) > 0 { + msg.WriteString("\n CEX: ") + i := 0 + for assetID, diff := range cexDiffs { + msg.WriteString(fmt.Sprintf("%s: %d", dex.BipIDSymbol(assetID), diff)) + if i < len(cexDiffs)-1 { + msg.WriteString(", ") + } + i++ + } + } + + msg.WriteString("\n\n Updated base balances:\n DEX: ") + i := 0 + for assetID, bal := range u.baseDexBalances { + msg.WriteString(fmt.Sprintf("%s: %d", dex.BipIDSymbol(assetID), bal)) + if i < len(u.baseDexBalances)-1 { + msg.WriteString(", ") + } + i++ + } + + if len(u.baseCexBalances) > 0 { + msg.WriteString("\n CEX: ") + i = 0 + for assetID, bal := range u.baseCexBalances { + msg.WriteString(fmt.Sprintf("%s: %d", dex.BipIDSymbol(assetID), bal)) + if i < len(u.baseCexBalances)-1 { + msg.WriteString(", ") + } + i++ + } + } + + u.log.Infof(msg.String()) +} + +func (u *unifiedExchangeAdaptor) maxBuyQty(host string, baseID, quoteID uint32, rate uint64, options map[string]string) (uint64, error) { + baseBalance, err := u.AssetBalance(baseID) + if err != nil { + return 0, err + } + quoteBalance, err := u.AssetBalance(quoteID) + if err != nil { + return 0, err + } + availBaseBal, availQuoteBal := baseBalance.Available, quoteBalance.Available + + mkt, err := u.clientCore.ExchangeMarket(host, baseID, quoteID) + if err != nil { + return 0, err + } + + fundingFees, err := u.clientCore.MaxFundingFees(quoteID, host, 1, options) + if err != nil { + return 0, err + } + + swapFees, redeemFees, refundFees, err := u.clientCore.SingleLotFees(&core.SingleLotFeesForm{ + Host: host, + Base: baseID, + Quote: quoteID, + UseMaxFeeRate: true, + }) + if err != nil { + return 0, err + } + + if availQuoteBal > fundingFees { + availQuoteBal -= fundingFees + } else { + availQuoteBal = 0 + } + + // Account based coins require the refund fees to be reserved as well. + if !u.isAccountLocker(quoteID) { + refundFees = 0 + } + + lotSizeQuote := calc.BaseToQuote(rate, mkt.LotSize) + maxLots := availQuoteBal / (lotSizeQuote + swapFees + refundFees) + + if redeemFees > 0 && u.isAccountLocker(baseID) { + maxBaseLots := availBaseBal / redeemFees + if maxLots > maxBaseLots { + maxLots = maxBaseLots + } + } + + return maxLots * mkt.LotSize, nil +} + +func (u *unifiedExchangeAdaptor) maxSellQty(host string, baseID, quoteID, numTrades uint32, options map[string]string) (uint64, error) { + baseBalance, err := u.AssetBalance(baseID) + if err != nil { + return 0, err + } + quoteBalance, err := u.AssetBalance(quoteID) + if err != nil { + return 0, err + } + availBaseBal, availQuoteBal := baseBalance.Available, quoteBalance.Available + + mkt, err := u.ExchangeMarket(host, baseID, quoteID) + if err != nil { + return 0, err + } + + fundingFees, err := u.MaxFundingFees(baseID, host, numTrades, options) + if err != nil { + return 0, err + } + + swapFees, redeemFees, refundFees, err := u.SingleLotFees(&core.SingleLotFeesForm{ + Host: host, + Base: baseID, + Quote: quoteID, + Sell: true, + UseMaxFeeRate: true, + }) + if err != nil { + return 0, err + } + + if availBaseBal > fundingFees { + availBaseBal -= fundingFees + } else { + availBaseBal = 0 + } + + // Account based coins require the refund fees to be reserved as well. + if !u.isAccountLocker(baseID) { + refundFees = 0 + } + + maxLots := availBaseBal / (mkt.LotSize + swapFees + refundFees) + if u.isAccountLocker(quoteID) && redeemFees > 0 { + maxQuoteLots := availQuoteBal / redeemFees + if maxLots > maxQuoteLots { + maxLots = maxQuoteLots + } + } + + return maxLots * mkt.LotSize, nil +} + +func (c *unifiedExchangeAdaptor) sufficientBalanceForMultiSell(host string, base, quote uint32, placements []*core.QtyRate, options map[string]string) (bool, error) { + var totalQty uint64 + for _, placement := range placements { + totalQty += placement.Qty + } + maxQty, err := c.maxSellQty(host, base, quote, uint32(len(placements)), options) + if err != nil { + return false, err + } + return maxQty >= totalQty, nil +} + +func (u *unifiedExchangeAdaptor) sufficientBalanceForMultiBuy(host string, baseID, quoteID uint32, placements []*core.QtyRate, options map[string]string) (bool, error) { + baseBalance, err := u.AssetBalance(baseID) + if err != nil { + return false, err + } + quoteBalance, err := u.AssetBalance(quoteID) + if err != nil { + return false, err + } + availBaseBal, availQuoteBal := baseBalance.Available, quoteBalance.Available + + mkt, err := u.ExchangeMarket(host, baseID, quoteID) + if err != nil { + return false, err + } + + swapFees, redeemFees, refundFees, err := u.SingleLotFees(&core.SingleLotFeesForm{ + Host: host, + Base: baseID, + Quote: quoteID, + UseMaxFeeRate: true, + }) + if err != nil { + return false, err + } + + if !u.isAccountLocker(quoteID) { + refundFees = 0 + } + + fundingFees, err := u.MaxFundingFees(quoteID, host, uint32(len(placements)), options) + if err != nil { + return false, err + } + if availQuoteBal < fundingFees { + return false, nil + } + + var totalLots uint64 + remainingBalance := availQuoteBal - fundingFees + for _, placement := range placements { + quoteQty := calc.BaseToQuote(placement.Rate, placement.Qty) + numLots := placement.Qty / mkt.LotSize + totalLots += numLots + req := quoteQty + (numLots * (swapFees + refundFees)) + if remainingBalance < req { + return false, nil + } + remainingBalance -= req + } + + if u.isAccountLocker(baseID) && availBaseBal < redeemFees*totalLots { + return false, nil + } + + return true, nil +} + +func (c *unifiedExchangeAdaptor) sufficientBalanceForMultiTrade(host string, base, quote uint32, sell bool, placements []*core.QtyRate, options map[string]string) (bool, error) { + if sell { + return c.sufficientBalanceForMultiSell(host, base, quote, placements, options) + } + return c.sufficientBalanceForMultiBuy(host, base, quote, placements, options) +} + +func (u *unifiedExchangeAdaptor) addPendingDexOrders(orders []*core.Order) { + u.balancesMtx.Lock() + defer u.balancesMtx.Unlock() + + for _, o := range orders { + var orderID order.OrderID + copy(orderID[:], o.ID) + + fromAsset, fromFeeAsset, _, toFeeAsset := orderAssets(o) + + availableDiff := map[uint32]int64{} + availableDiff[fromAsset] -= int64(o.LockedAmt) + availableDiff[fromFeeAsset] -= int64(o.ParentAssetLockedAmt + o.RefundLockedAmt) + availableDiff[toFeeAsset] -= int64(o.RedeemLockedAmt) + if o.FeesPaid != nil { + availableDiff[fromFeeAsset] -= int64(o.FeesPaid.Funding) + } + + locked := map[uint32]uint64{} + locked[fromAsset] += o.LockedAmt + locked[fromFeeAsset] += o.ParentAssetLockedAmt + o.RefundLockedAmt + locked[toFeeAsset] += o.RedeemLockedAmt + + u.pendingDEXOrders[orderID] = &pendingDEXOrder{ + swaps: make(map[string]*asset.WalletTransaction), + redeems: make(map[string]*asset.WalletTransaction), + refunds: make(map[string]*asset.WalletTransaction), + availableDiff: availableDiff, + locked: locked, + pending: map[uint32]uint64{}, + } + } +} + +// MultiTrade is used to place multiple standing limit orders on the same +// side of the same market simultaneously. +func (u *unifiedExchangeAdaptor) MultiTrade(pw []byte, form *core.MultiTradeForm) ([]*core.Order, error) { + enough, err := u.sufficientBalanceForMultiTrade(form.Host, form.Base, form.Quote, form.Sell, form.Placements, form.Options) + if err != nil { + return nil, err + } + if !enough { + return nil, fmt.Errorf("insufficient balance") + } + + fromAsset := form.Quote + if form.Sell { + fromAsset = form.Base + } + fromBalance, err := u.AssetBalance(fromAsset) + if err != nil { + return nil, err + } + form.MaxLock = fromBalance.Available + + orders, err := u.clientCore.MultiTrade(pw, form) + if err != nil { + return nil, err + } + + u.addPendingDexOrders(orders) + + return orders, nil +} + +// MayBuy returns the maximum quantity of the base asset that the bot can +// buy for rate using its balance of the quote asset. +func (u *unifiedExchangeAdaptor) MaxBuy(host string, base, quote uint32, rate uint64) (*core.MaxOrderEstimate, error) { + maxQty, err := u.maxBuyQty(host, base, quote, rate, nil) + if err != nil { + return nil, err + } + if maxQty == 0 { + return nil, fmt.Errorf("insufficient balance") + } + + orderEstimate, err := u.clientCore.PreOrder(&core.TradeForm{ + Host: host, + IsLimit: true, + Base: base, + Quote: quote, + Qty: maxQty, + Rate: rate, + // TODO: handle options. need new option for split if remaining balance < certain amount. + }) + if err != nil { + return nil, err + } + + return &core.MaxOrderEstimate{ + Swap: orderEstimate.Swap.Estimate, + Redeem: orderEstimate.Redeem.Estimate, + }, nil +} + +// MaxSell returned the maximum quantity of the base asset that the bot can +// sell. +func (u *unifiedExchangeAdaptor) MaxSell(host string, base, quote uint32) (*core.MaxOrderEstimate, error) { + qty, err := u.maxSellQty(host, base, quote, 1, nil) + if err != nil { + return nil, err + } + if qty == 0 { + return nil, fmt.Errorf("insufficient balance") + } + + orderEstimate, err := u.clientCore.PreOrder(&core.TradeForm{ + Host: host, + IsLimit: true, + Sell: true, + Base: base, + Quote: quote, + Qty: qty, + }) + if err != nil { + return nil, err + } + + return &core.MaxOrderEstimate{ + Swap: orderEstimate.Swap.Estimate, + Redeem: orderEstimate.Redeem.Estimate, + }, nil +} + +// AssetBalance returns the bot's balance for a specific asset. +func (u *unifiedExchangeAdaptor) AssetBalance(assetID uint32) (*botBalance, error) { + u.balancesMtx.RLock() + defer u.balancesMtx.RUnlock() + + bal, found := u.baseDexBalances[assetID] + if !found { + return &botBalance{}, nil + } + + var totalAvailableDiff int64 + var totalLocked, totalPending uint64 + + for _, pendingOrder := range u.pendingDEXOrders { + pendingOrder.balancesMtx.RLock() + totalAvailableDiff += pendingOrder.availableDiff[assetID] + totalLocked += pendingOrder.locked[assetID] + totalPending += pendingOrder.pending[assetID] + pendingOrder.balancesMtx.RUnlock() + } + + for _, pendingDeposit := range u.pendingDeposits { + pendingDeposit.mtx.RLock() + if pendingDeposit.assetID == assetID { + totalAvailableDiff -= int64(pendingDeposit.amtSent) + } + + token := asset.TokenInfo(pendingDeposit.assetID) + if token != nil && token.ParentID == assetID { + totalAvailableDiff -= int64(pendingDeposit.fee) + } else if token == nil && pendingDeposit.assetID == assetID { + totalAvailableDiff -= int64(pendingDeposit.fee) + } + pendingDeposit.mtx.RUnlock() + } + + for _, pendingWithdrawal := range u.pendingWithdrawals { + if pendingWithdrawal.assetID == assetID { + totalPending += pendingWithdrawal.amtWithdrawn + } + } + + availableBalance := bal + if totalAvailableDiff >= 0 { + availableBalance += uint64(totalAvailableDiff) + } else { + if availableBalance < uint64(-totalAvailableDiff) { + u.log.Errorf("bot %s totalAvailableDiff %d exceeds available balance %d", u.botID, totalAvailableDiff, availableBalance) + availableBalance = 0 + } else { + availableBalance -= uint64(-totalAvailableDiff) + } + } + + return &botBalance{ + Available: availableBalance, + Locked: totalLocked, + Pending: totalPending, + }, nil +} + +// incompleteCexTradeBalanceEffects returns the balance effects of an +// incomplete CEX traade. As soon as a CEX trade is complete, it is removed +// from the pending map, so completed trades do not need to be calculated. +func incompleteCexTradeBalanceEffects(trade *libxc.Trade) (availableDiff map[uint32]int64, locked map[uint32]uint64) { + availableDiff = make(map[uint32]int64) + locked = make(map[uint32]uint64) + + if trade.Sell { + availableDiff[trade.BaseID] = -int64(trade.Qty) + locked[trade.BaseID] = trade.Qty - trade.BaseFilled + availableDiff[trade.QuoteID] = int64(trade.QuoteFilled) + } else { + availableDiff[trade.QuoteID] = -int64(calc.BaseToQuote(trade.Rate, trade.Qty)) + locked[trade.QuoteID] = calc.BaseToQuote(trade.Rate, trade.Qty) - trade.QuoteFilled + availableDiff[trade.BaseID] = int64(trade.BaseFilled) + } + + return +} + +// Balance returns the balance of the bot on the CEX. +func (u *unifiedExchangeAdaptor) Balance(assetID uint32) (*botBalance, error) { + u.balancesMtx.RLock() + defer u.balancesMtx.RUnlock() + + var available, totalFundingOrder, totalPending uint64 + var totalAvailableDiff int64 + for _, tx := range u.pendingCEXOrders { + if tx.BaseID == assetID || tx.QuoteID == assetID { + if tx.Complete { + u.log.Errorf("pending cex order %s is complete", tx.ID) + continue + } + availableDiff, fundingOrder := incompleteCexTradeBalanceEffects(tx) + totalAvailableDiff += availableDiff[assetID] + totalFundingOrder += fundingOrder[assetID] + } + } + + for _, withdrawal := range u.pendingWithdrawals { + if withdrawal.assetID == assetID { + totalAvailableDiff -= int64(withdrawal.amtWithdrawn) + } + } + + // Credited deposits generally should already be part of the base balance, + // but just in case the amount was credited before the wallet confirmed the + // fee. + for _, deposit := range u.pendingDeposits { + deposit.mtx.RLock() + if deposit.assetID == assetID { + totalPending += deposit.amtCredited + } + deposit.mtx.RUnlock() + } + + if totalAvailableDiff < 0 && u.baseCexBalances[assetID] < uint64(-totalAvailableDiff) { + u.log.Errorf("bot %s asset %d available balance %d is less than total available diff %d", u.botID, assetID, u.baseCexBalances[assetID], totalAvailableDiff) + available = 0 + } else { + available = u.baseCexBalances[assetID] + uint64(totalAvailableDiff) + } + + return &botBalance{ + Available: available, + Locked: totalFundingOrder, + Pending: totalPending, + }, nil +} + +// updatePendingDeposit applies the balance effects of the deposit to the bot's +// dex and cex base balances after both the fees of the deposit transaction and +// the amount credited by the CEX are confirmed. +func (u *unifiedExchangeAdaptor) updatePendingDeposit(txID string, deposit *pendingDeposit) { + u.balancesMtx.Lock() + defer u.balancesMtx.Unlock() + + _, found := u.pendingDeposits[txID] + if !found { + u.log.Errorf("%s not found among pending deposits", txID) + return + } + + deposit.mtx.RLock() + defer deposit.mtx.RUnlock() + + if !deposit.cexConfirmed || !deposit.feeConfirmed { + u.pendingDeposits[txID] = deposit + return + } + + if u.baseDexBalances[deposit.assetID] < deposit.amtSent { + u.log.Errorf("%s balance on dex %d < deposit amt %d", + dex.BipIDSymbol(deposit.assetID), u.baseDexBalances[deposit.assetID], deposit.amtSent) + u.baseDexBalances[deposit.assetID] = 0 + } else { + u.baseDexBalances[deposit.assetID] -= deposit.amtSent + } + + var feeAsset uint32 + token := asset.TokenInfo(deposit.assetID) + if token != nil { + feeAsset = token.ParentID + } else { + feeAsset = deposit.assetID + } + if u.baseDexBalances[feeAsset] < deposit.fee { + u.log.Errorf("%s balance on dex %d < deposit fee %d", + dex.BipIDSymbol(feeAsset), u.baseDexBalances[feeAsset], deposit.fee) + u.baseDexBalances[feeAsset] = 0 + } else { + u.baseDexBalances[feeAsset] -= deposit.fee + } + + if deposit.success { + u.baseCexBalances[deposit.assetID] += deposit.amtCredited + } + + delete(u.pendingDeposits, txID) + + dexDiffs := map[uint32]int64{} + cexDiffs := map[uint32]int64{} + dexDiffs[deposit.assetID] -= int64(deposit.amtSent) + dexDiffs[feeAsset] -= int64(deposit.fee) + msg := fmt.Sprintf("Deposit %s complete.", txID) + if deposit.success { + cexDiffs[deposit.assetID] += int64(deposit.amtCredited) + } else { + msg = fmt.Sprintf("Deposit %s complete, but not successful.", txID) + } + u.logBalanceAdjustments(dexDiffs, cexDiffs, msg) +} + +// Deposit deposits funds to the CEX. The deposited funds will be removed from +// the bot's wallet balance and allocated to the bot's CEX balance. After both +// the fees of the deposit transaction are confirmed by the wallet and the +// CEX confirms the amount they received, the onConfirm callback is called. +func (u *unifiedExchangeAdaptor) Deposit(ctx context.Context, assetID uint32, amount uint64, onConfirm func()) error { + balance, err := u.AssetBalance(assetID) + if err != nil { + return err + } + // TODO: estimate fee and make sure we have enough to cover it. + if balance.Available < amount { + return fmt.Errorf("bot has insufficient balance to deposit %d. required: %v, have: %v", assetID, amount, balance.Available) + } + + addr, err := u.CEX.GetDepositAddress(ctx, assetID) + if err != nil { + return err + } + coin, err := u.clientCore.Send([]byte{}, assetID, amount, addr, u.isWithdrawer(assetID)) + if err != nil { + return err + } + + getAmtAndFee := func() (uint64, uint64, bool) { + tx, err := u.clientCore.WalletTransaction(assetID, coin.ID()) + if err != nil { + u.log.Errorf("Error getting wallet transaction: %v", err) + return amount, 0, false + } + if tx.BalanceDelta > 0 { + // This indicates a bug in the wallet. + u.log.Errorf("Deposit tx %s returned a balance delta > 0", coin.TxID()) + return amount, tx.Fees, false + } + + return uint64(-tx.BalanceDelta), tx.Fees, tx.PartOfActiveBalance + } + + amt, fee, _ := getAmtAndFee() + isDynamicSwapper := u.isDynamicSwapper(assetID) + deposit := &pendingDeposit{ + assetID: assetID, + amtSent: amt, + fee: fee, + feeConfirmed: !isDynamicSwapper, + } + + u.balancesMtx.Lock() + u.pendingDeposits[coin.TxID()] = deposit + u.balancesMtx.Unlock() + + if u.isDynamicSwapper(assetID) { + confirmedTx := func(tx *asset.WalletTransaction) { + deposit.mtx.Lock() + deposit.amtSent = -(uint64(tx.BalanceDelta)) // shouldn't have changed + deposit.fee = tx.Fees + deposit.feeConfirmed = true + deposit.mtx.Unlock() + + u.updatePendingDeposit(coin.TxID(), deposit) + + deposit.mtx.RLock() + defer deposit.mtx.RUnlock() + if deposit.cexConfirmed { + go onConfirm() + } + } + + err = u.clientCore.ConfirmedWalletTransaction(assetID, coin.ID(), confirmedTx) + if err != nil { + u.log.Errorf("Error confirming deposit transaction: %v", err) + } + } + + cexConfirmedDeposit := func(success bool, creditedAmt uint64) { + deposit.mtx.Lock() + deposit.cexConfirmed = true + deposit.success = success + deposit.amtCredited = creditedAmt + + if success && deposit.amtCredited != deposit.amtSent { + u.log.Warnf("CEX credited less to deposit %s than was sent. sent: %d, credited: %d", coin.TxID(), deposit.amtSent, deposit.amtCredited) + } + if !success { + u.log.Warnf("CEX did not credit deposit %s. Check with the CEX about the reason.", coin.TxID()) + } + deposit.mtx.Unlock() + + u.updatePendingDeposit(coin.TxID(), deposit) + + deposit.mtx.RLock() + defer deposit.mtx.RUnlock() + if deposit.feeConfirmed { + go onConfirm() + } + } + + u.CEX.ConfirmDeposit(ctx, coin.TxID(), cexConfirmedDeposit) + + return nil +} + +// pendingWithdrawalComplete is called after a withdrawal has been confirmed. +// The amount received is applied to the base balance, and the withdrawal is +// removed from the pending map. +func (u *unifiedExchangeAdaptor) pendingWithdrawalComplete(id string, amtReceived uint64) { + u.balancesMtx.Lock() + defer u.balancesMtx.Unlock() + + withdrawal, found := u.pendingWithdrawals[id] + if !found { + u.log.Errorf("Completed withdrawal not found among pending withdrawals") + return + } + + if u.baseCexBalances[withdrawal.assetID] < withdrawal.amtWithdrawn { + u.log.Errorf("%s balance on cex %d < withdrawn amt %d", + dex.BipIDSymbol(withdrawal.assetID), u.baseCexBalances[withdrawal.assetID], withdrawal.amtWithdrawn) + u.baseCexBalances[withdrawal.assetID] = 0 + } else { + u.baseCexBalances[withdrawal.assetID] -= withdrawal.amtWithdrawn + } + u.baseDexBalances[withdrawal.assetID] += amtReceived + delete(u.pendingWithdrawals, id) + + dexDiffs := map[uint32]int64{withdrawal.assetID: int64(amtReceived)} + cexDiffs := map[uint32]int64{withdrawal.assetID: -int64(withdrawal.amtWithdrawn)} + u.logBalanceAdjustments(dexDiffs, cexDiffs, fmt.Sprintf("Withdrawal %s complete.", id)) +} + +// Withdraw withdraws funds from the CEX. After withdrawing, the CEX is queried +// for the transaction ID. After the transaction ID is available, the wallet is +// queried for the amount received. +func (u *unifiedExchangeAdaptor) Withdraw(ctx context.Context, assetID uint32, amount uint64, onConfirm func()) error { + symbol := dex.BipIDSymbol(assetID) + + balance, err := u.Balance(assetID) + if err != nil { + return err + } + if balance.Available < amount { + return fmt.Errorf("bot has insufficient balance to withdraw %s. required: %v, have: %v", symbol, amount, balance.Available) + } + + addr, err := u.clientCore.NewDepositAddress(assetID) + if err != nil { + return err + } + + withdrawalID := fmt.Sprintf("%s%d", u.withdrawalNoncePrefix, u.withdrawalNonce.Add(1)) + + confirmWithdrawal := func(amt uint64, txID string) { + if strings.HasPrefix(txID, "0x") { + txID, _ = strings.CutPrefix(txID, "0x") + } + + var err error + id, err := hex.DecodeString(txID) + if err != nil { + u.log.Errorf("Could not decode tx ID %s, unable to process withdrawal.", txID) + return + } + + timer := time.NewTimer(0) + defer timer.Stop() + for { + select { + case <-timer.C: + tx, err := u.clientCore.WalletTransaction(assetID, id) + if errors.Is(err, asset.CoinNotFoundError) { + u.log.Warnf("%s wallet does not know about withdrawal tx: %s", dex.BipIDSymbol(assetID), txID) + continue + } + if err != nil { + u.log.Errorf("Error getting wallet transaction: %v", err) + } else if tx.PartOfActiveBalance { + if tx.BalanceDelta < 0 { + // This indicates a bug in the wallet. + u.log.Errorf("Confirmed withdrawal tx %s returned a balance delta < 0", txID) + u.pendingWithdrawalComplete(withdrawalID, 0) + } else { + u.pendingWithdrawalComplete(withdrawalID, uint64(tx.BalanceDelta)) + } + onConfirm() + return + } + + timer = time.NewTimer(1 * time.Minute) + case <-ctx.Done(): + return + } + } + } + + err = u.CEX.Withdraw(ctx, assetID, amount, addr, confirmWithdrawal) + if err != nil { + return err + } + + u.balancesMtx.Lock() + u.pendingWithdrawals[withdrawalID] = &pendingWithdrawal{ + assetID: assetID, + amtWithdrawn: amount, + } + u.balancesMtx.Unlock() + + return nil +} + +// handleCEXTradeUpdate handles a trade update from the CEX. If the trade is in +// the pending map, it will be updated. If the trade is complete, the base balances +// will be updated. +func (u *unifiedExchangeAdaptor) handleCEXTradeUpdate(trade *libxc.Trade) { + u.balancesMtx.Lock() + defer u.balancesMtx.Unlock() + + if _, found := u.pendingCEXOrders[trade.ID]; !found { + return + } + + if !trade.Complete { + u.pendingCEXOrders[trade.ID] = trade + return + } + + delete(u.pendingCEXOrders, trade.ID) + + if trade.BaseFilled == 0 && trade.QuoteFilled == 0 { + u.log.Infof("CEX trade %s completed with zero filled amount", trade.ID) + return + } + + diffs := make(map[uint32]int64) + + if trade.Sell { + if trade.BaseFilled <= u.baseCexBalances[trade.BaseID] { + u.baseCexBalances[trade.BaseID] -= trade.BaseFilled + } else { + u.log.Errorf("CEX trade %s filled %d more than available balance %d of %s.", + trade.ID, trade.BaseFilled, u.baseCexBalances[trade.BaseID], dex.BipIDSymbol(trade.BaseID)) + u.baseCexBalances[trade.BaseID] = 0 + } + u.baseCexBalances[trade.QuoteID] += trade.QuoteFilled + + diffs[trade.BaseID] = -int64(trade.BaseFilled) + diffs[trade.QuoteID] = int64(trade.QuoteFilled) + } else { + if trade.QuoteFilled <= u.baseCexBalances[trade.QuoteID] { + u.baseCexBalances[trade.QuoteID] -= trade.QuoteFilled + } else { + u.log.Errorf("CEX trade %s filled %d more than available balance %d of %s.", + trade.ID, trade.QuoteFilled, u.baseCexBalances[trade.QuoteID], dex.BipIDSymbol(trade.QuoteID)) + u.baseCexBalances[trade.QuoteID] = 0 + } + u.baseCexBalances[trade.BaseID] += trade.BaseFilled + + diffs[trade.QuoteID] = -int64(trade.QuoteFilled) + diffs[trade.BaseID] = int64(trade.BaseFilled) + } + + u.logBalanceAdjustments(nil, diffs, fmt.Sprintf("CEX trade %s completed.", trade.ID)) +} + +// SubscribeTradeUpdates subscribes to trade updates for the bot's trades on +// the CEX. This should be called before making any trades, and only once. +func (w *unifiedExchangeAdaptor) SubscribeTradeUpdates() (<-chan *libxc.Trade, func()) { + w.subscriptionIDMtx.Lock() + defer w.subscriptionIDMtx.Unlock() + if w.subscriptionID != nil { + w.log.Errorf("SubscribeTradeUpdates called more than once by bot %s", w.botID) + return nil, nil + } + + updates, unsubscribe, subscriptionID := w.CEX.SubscribeTradeUpdates() + w.subscriptionID = &subscriptionID + + ctx, cancel := context.WithCancel(context.Background()) + forwardUnsubscribe := func() { + cancel() + unsubscribe() + } + forwardUpdates := make(chan *libxc.Trade, 256) + go func() { + for { + select { + case <-ctx.Done(): + return + case note := <-updates: + w.handleCEXTradeUpdate(note) + forwardUpdates <- note + } + } + }() + + return forwardUpdates, forwardUnsubscribe +} + +// Trade executes a trade on the CEX. The trade will be executed using the +// bot's CEX balance. +func (u *unifiedExchangeAdaptor) Trade(ctx context.Context, baseID, quoteID uint32, sell bool, rate, qty uint64) (*libxc.Trade, error) { + var fromAssetID uint32 + var fromAssetQty uint64 + if sell { + fromAssetID = baseID + fromAssetQty = qty + } else { + fromAssetID = quoteID + fromAssetQty = calc.BaseToQuote(rate, qty) + } + + fromAssetBal, err := u.Balance(fromAssetID) + if err != nil { + return nil, err + } + if fromAssetBal.Available < fromAssetQty { + return nil, fmt.Errorf("asset bal < required for trade (%d < %d)", fromAssetBal.Available, fromAssetQty) + } + + u.subscriptionIDMtx.RLock() + subscriptionID := u.subscriptionID + u.subscriptionIDMtx.RUnlock() + if u.subscriptionID == nil { + return nil, fmt.Errorf("Trade called before SubscribeTradeUpdates") + } + + trade, err := u.CEX.Trade(ctx, baseID, quoteID, sell, rate, qty, *subscriptionID) + if err != nil { + return nil, err + } + + u.balancesMtx.Lock() + defer u.balancesMtx.Unlock() + + if trade.Complete { + if trade.Sell { + u.baseCexBalances[trade.BaseID] -= trade.BaseFilled + u.baseCexBalances[trade.QuoteID] += trade.QuoteFilled + } else { + u.baseCexBalances[trade.BaseID] += trade.BaseFilled + u.baseCexBalances[trade.QuoteID] -= trade.QuoteFilled + } + } else { + u.pendingCEXOrders[trade.ID] = trade + } + + return trade, nil +} + +// isAccountLocker returns if the asset's wallet is an asset.AccountLocker. +func (u *unifiedExchangeAdaptor) isAccountLocker(assetID uint32) bool { + walletState := u.clientCore.WalletState(assetID) + if walletState == nil { + u.log.Errorf("isAccountLocker: wallet state not found for asset %d", assetID) + return false + } + + return walletState.Traits.IsAccountLocker() +} + +// isDynamicSwapper returns if the asset's wallet is an asset.DynamicSwapper. +func (u *unifiedExchangeAdaptor) isDynamicSwapper(assetID uint32) bool { + walletState := u.clientCore.WalletState(assetID) + if walletState == nil { + u.log.Errorf("isDynamicSwapper: wallet state not found for asset %d", assetID) + return false + } + + return walletState.Traits.IsDynamicSwapper() +} + +// isWithdrawer returns if the asset's wallet is an asset.Withdrawer. +func (u *unifiedExchangeAdaptor) isWithdrawer(assetID uint32) bool { + walletState := u.clientCore.WalletState(assetID) + if walletState == nil { + u.log.Errorf("isWithdrawer: wallet state not found for asset %d", assetID) + return false + } + + return walletState.Traits.IsWithdrawer() +} + +func orderAssets(o *core.Order) (fromAsset, fromFeeAsset, toAsset, toFeeAsset uint32) { + if o.Sell { + fromAsset = o.BaseID + toAsset = o.QuoteID + } else { + fromAsset = o.QuoteID + toAsset = o.BaseID + } + if token := asset.TokenInfo(fromAsset); token != nil { + fromFeeAsset = token.ParentID + } else { + fromFeeAsset = fromAsset + } + if token := asset.TokenInfo(toAsset); token != nil { + toFeeAsset = token.ParentID + } else { + toFeeAsset = toAsset + } + return +} + +func dexOrderComplete(o *core.Order) bool { + if o.Status.IsActive() { + return false + } + + for _, match := range o.Matches { + if match.Active { + return false + } + } + + return o.AllFeesConfirmed +} + +// orderTransactions returns all of the swap, redeem, and refund transactions +// involving a dex order. +func orderTransactions(o *core.Order) (swaps map[string]bool, redeems map[string]bool, refunds map[string]bool) { + swaps = make(map[string]bool) + redeems = make(map[string]bool) + refunds = make(map[string]bool) + + for _, match := range o.Matches { + if match.Swap != nil { + swaps[match.Swap.ID.String()] = true + } + if match.Redeem != nil { + redeems[match.Redeem.ID.String()] = true + } + if match.Refund != nil { + refunds[match.Refund.ID.String()] = true + } + } + + return +} + +// updatePendingDEXOrder updates a pending DEX order based its current state. +// If the order is complete, its effects are applied to the base balance, +// and it is removed from the pending list. +func (u *unifiedExchangeAdaptor) updatePendingDEXOrder(o *core.Order) { + var orderID order.OrderID + copy(orderID[:], o.ID) + + u.balancesMtx.RLock() + pendingOrder, found := u.pendingDEXOrders[orderID] + if !found { + u.balancesMtx.RUnlock() + return + } + u.balancesMtx.RUnlock() + + fromAsset, fromFeeAsset, toAsset, toFeeAsset := orderAssets(o) + + pendingOrder.txsMtx.Lock() + + // Add new txs to tx cache + swaps, redeems, refunds := orderTransactions(o) + for id := range swaps { + if _, found := pendingOrder.swaps[id]; !found { + pendingOrder.swaps[id] = &asset.WalletTransaction{} + } + } + for id := range redeems { + if _, found := pendingOrder.redeems[id]; !found { + pendingOrder.redeems[id] = &asset.WalletTransaction{} + } + } + for id := range refunds { + if _, found := pendingOrder.refunds[id]; !found { + pendingOrder.refunds[id] = &asset.WalletTransaction{} + } + } + + // Query the wallet regarding all unconfirmed transactions + for id, oldTx := range pendingOrder.swaps { + if oldTx.PartOfActiveBalance { + continue + } + idB, _ := hex.DecodeString(id) + tx, err := u.clientCore.WalletTransaction(fromAsset, idB) + if err != nil { + u.log.Errorf("Error getting swap tx %s: %v", id, err) + continue + } + pendingOrder.swaps[id] = tx + } + for id, oldTx := range pendingOrder.redeems { + if oldTx.PartOfActiveBalance { + continue + } + idB, _ := hex.DecodeString(id) + tx, err := u.clientCore.WalletTransaction(toAsset, idB) + if err != nil { + u.log.Errorf("Error getting redeem tx %s: %v", id, err) + continue + } + pendingOrder.redeems[id] = tx + } + for id, oldTx := range pendingOrder.refunds { + if oldTx.PartOfActiveBalance { + continue + } + idB, _ := hex.DecodeString(id) + tx, err := u.clientCore.WalletTransaction(fromAsset, idB) + if err != nil { + u.log.Errorf("Error getting refund tx %s: %v", id, err) + continue + } + pendingOrder.refunds[id] = tx + } + + // Calculate balance effects based on current state of the order. + availableDiff := make(map[uint32]int64) + locked := make(map[uint32]uint64) + pending := make(map[uint32]uint64) + + availableDiff[fromAsset] -= int64(o.LockedAmt) + availableDiff[fromFeeAsset] -= int64(o.ParentAssetLockedAmt + o.RefundLockedAmt) + availableDiff[toFeeAsset] -= int64(o.RedeemLockedAmt) + if o.FeesPaid != nil { + availableDiff[fromFeeAsset] -= int64(o.FeesPaid.Funding) + } + + locked[fromAsset] += o.LockedAmt + locked[fromFeeAsset] += o.ParentAssetLockedAmt + o.RefundLockedAmt + locked[toFeeAsset] += o.RedeemLockedAmt + + for _, tx := range pendingOrder.swaps { + availableDiff[fromAsset] += tx.BalanceDelta + availableDiff[fromFeeAsset] -= int64(tx.Fees) + } + for _, tx := range pendingOrder.redeems { + isDynamicSwapper := u.isDynamicSwapper(toAsset) + + // For dynamic fee assets, the fees are paid from the active balance, + // and are not taken out of the redeem amount. + if isDynamicSwapper || tx.PartOfActiveBalance { + availableDiff[toFeeAsset] -= int64(tx.Fees) + } else { + pending[toFeeAsset] -= tx.Fees + } + + if tx.PartOfActiveBalance { + availableDiff[toAsset] += tx.BalanceDelta + } else { + pending[toAsset] += uint64(tx.BalanceDelta) + } + } + for _, tx := range pendingOrder.refunds { + isDynamicSwapper := u.isDynamicSwapper(fromAsset) + + // For dynamic fee assets, the fees are paid from the active balance, + // and are not taken out of the redeem amount. + if isDynamicSwapper || tx.PartOfActiveBalance { + availableDiff[fromFeeAsset] -= int64(tx.Fees) + } else { + pending[fromFeeAsset] -= tx.Fees + } + + if tx.PartOfActiveBalance { + availableDiff[fromAsset] += tx.BalanceDelta + } else { + pending[fromAsset] += uint64(tx.BalanceDelta) + } + } + pendingOrder.txsMtx.Unlock() + + // Update the balances + pendingOrder.balancesMtx.Lock() + pendingOrder.availableDiff = availableDiff + pendingOrder.locked = locked + pendingOrder.pending = pending + pendingOrder.balancesMtx.Unlock() + + // If complete, remove the order from the pending list, and update the + // bot's balance. + if dexOrderComplete(o) { + u.balancesMtx.Lock() + defer u.balancesMtx.Unlock() + + delete(u.pendingDEXOrders, orderID) + + adjustedBals := false + for assetID, diff := range availableDiff { + adjustedBals = adjustedBals || diff != 0 + if diff > 0 { + u.baseDexBalances[assetID] += uint64(diff) + } else { + if u.baseDexBalances[assetID] < uint64(-diff) { + u.log.Errorf("bot %s diff %d exceeds available balance %d. Setting balance to 0.", u.botID, diff, u.baseDexBalances[assetID]) + u.baseDexBalances[assetID] = 0 + } else { + u.baseDexBalances[assetID] -= uint64(-diff) + } + } + } + + if adjustedBals { + u.logBalanceAdjustments(availableDiff, nil, fmt.Sprintf("DEX order %s complete.", orderID)) + } + } +} + +func (u *unifiedExchangeAdaptor) handleDEXNotification(n core.Notification) { + switch note := n.(type) { + case *core.OrderNote: + u.updatePendingDEXOrder(note.Order) + case *core.MatchNote: + o, err := u.clientCore.Order(note.OrderID) + if err != nil { + u.log.Errorf("handleDEXNotification: failed to get order %s: %v", note.OrderID, err) + return + } + u.updatePendingDEXOrder(o) + } +} + +func (u *unifiedExchangeAdaptor) run(ctx context.Context) { + go func() { + feed := u.clientCore.NotificationFeed() + defer feed.ReturnFeed() + + for { + select { + case <-ctx.Done(): + return + case n := <-feed.C: + u.handleDEXNotification(n) + } + } + }() +} + +// unifiedExchangeAdaptorForBot returns a unifiedExchangeAdaptor for the specified bot. +func unifiedExchangeAdaptorForBot(botID string, baseDexBalances, baseCexBalances map[uint32]uint64, core clientCore, cex libxc.CEX, log dex.Logger) *unifiedExchangeAdaptor { + return &unifiedExchangeAdaptor{ + clientCore: core, + CEX: cex, + botID: botID, + log: log, + baseDexBalances: baseDexBalances, + baseCexBalances: baseCexBalances, + pendingDEXOrders: make(map[order.OrderID]*pendingDEXOrder), + pendingCEXOrders: make(map[string]*libxc.Trade), + pendingDeposits: make(map[string]*pendingDeposit), + pendingWithdrawals: make(map[string]*pendingWithdrawal), + } +} diff --git a/client/mm/exchange_adaptor_test.go b/client/mm/exchange_adaptor_test.go new file mode 100644 index 0000000000..fde4f5e3d1 --- /dev/null +++ b/client/mm/exchange_adaptor_test.go @@ -0,0 +1,2943 @@ +package mm + +import ( + "context" + "encoding/hex" + "fmt" + "reflect" + "testing" + + "decred.org/dcrdex/client/asset" + "decred.org/dcrdex/client/core" + "decred.org/dcrdex/client/mm/libxc" + "decred.org/dcrdex/dex/calc" + "decred.org/dcrdex/dex/encode" + "decred.org/dcrdex/dex/order" +) + +func TestExchangeAdaptorMaxSell(t *testing.T) { + tCore := newTCore() + tCore.isAccountLocker[60] = true + dcrBtcID := fmt.Sprintf("%s-%d-%d", "host1", 42, 0) + dcrEthID := fmt.Sprintf("%s-%d-%d", "host1", 42, 60) + + // Whatever is returned from PreOrder is returned from this function. + // What we need to test is what is passed to PreOrder. + orderEstimate := &core.OrderEstimate{ + Swap: &asset.PreSwap{ + Estimate: &asset.SwapEstimate{ + Lots: 5, + Value: 5e8, + MaxFees: 1600, + RealisticWorstCase: 12010, + RealisticBestCase: 6008, + }, + }, + Redeem: &asset.PreRedeem{ + Estimate: &asset.RedeemEstimate{ + RealisticBestCase: 2800, + RealisticWorstCase: 6500, + }, + }, + } + tCore.orderEstimate = orderEstimate + + expectedResult := &core.MaxOrderEstimate{ + Swap: &asset.SwapEstimate{ + Lots: 5, + Value: 5e8, + MaxFees: 1600, + RealisticWorstCase: 12010, + RealisticBestCase: 6008, + }, + Redeem: &asset.RedeemEstimate{ + RealisticBestCase: 2800, + RealisticWorstCase: 6500, + }, + } + + tests := []struct { + name string + assetBalances map[uint32]uint64 + market *core.Market + swapFees uint64 + redeemFees uint64 + refundFees uint64 + + expectPreOrderParam *core.TradeForm + wantErr bool + }{ + { + name: "ok", + assetBalances: map[uint32]uint64{ + 0: 5e6, + 42: 5e6, + }, + market: &core.Market{ + LotSize: 1e6, + BaseID: 42, + QuoteID: 0, + }, + expectPreOrderParam: &core.TradeForm{ + Host: "host1", + IsLimit: true, + Base: 42, + Quote: 0, + Sell: true, + Qty: 4 * 1e6, + }, + swapFees: 1000, + redeemFees: 1000, + }, + { + name: "1 lot", + assetBalances: map[uint32]uint64{ + 42: 1e6 + 1000, + 0: 1000, + }, + market: &core.Market{ + LotSize: 1e6, + BaseID: 42, + QuoteID: 0, + }, + expectPreOrderParam: &core.TradeForm{ + Host: "host1", + IsLimit: true, + Base: 42, + Quote: 0, + Sell: true, + Qty: 1e6, + }, + swapFees: 1000, + redeemFees: 1000, + }, + { + name: "not enough for 1 swap", + assetBalances: map[uint32]uint64{ + 0: 1e6 + 999, + 42: 1000, + }, + market: &core.Market{ + LotSize: 1e6, + BaseID: 42, + QuoteID: 0, + }, + swapFees: 1000, + redeemFees: 1000, + wantErr: true, + }, + { + name: "not enough for 1 lot of redeem fees", + assetBalances: map[uint32]uint64{ + 42: 1e6 + 1000, + 60: 999, + }, + market: &core.Market{ + LotSize: 1e6, + BaseID: 42, + QuoteID: 60, + }, + swapFees: 1000, + redeemFees: 1000, + wantErr: true, + }, + { + name: "redeem fees don't matter if not account locker", + assetBalances: map[uint32]uint64{ + 42: 1e6 + 1000, + 0: 999, + }, + market: &core.Market{ + LotSize: 1e6, + BaseID: 42, + QuoteID: 0, + }, + swapFees: 1000, + redeemFees: 1000, + expectPreOrderParam: &core.TradeForm{ + Host: "host1", + IsLimit: true, + Base: 42, + Quote: 0, + Sell: true, + Qty: 1e6, + }, + }, + { + name: "2 lots with refund fees, not account locker", + assetBalances: map[uint32]uint64{ + 42: 2e6 + 2000, + 0: 1000, + }, + market: &core.Market{ + LotSize: 1e6, + BaseID: 42, + QuoteID: 0, + }, + expectPreOrderParam: &core.TradeForm{ + Host: "host1", + IsLimit: true, + Base: 42, + Quote: 0, + Sell: true, + Qty: 2e6, + }, + swapFees: 1000, + redeemFees: 1000, + refundFees: 1000, + }, + { + name: "1 lot with refund fees, account locker", + assetBalances: map[uint32]uint64{ + 60: 1000, + 42: 2e6 + 2000, + }, + market: &core.Market{ + LotSize: 1e6, + BaseID: 42, + QuoteID: 60, + }, + expectPreOrderParam: &core.TradeForm{ + Host: "host1", + IsLimit: true, + Base: 42, + Quote: 60, + Sell: true, + Qty: 1e6, + }, + swapFees: 1000, + redeemFees: 1000, + refundFees: 1000, + }, + } + + for _, test := range tests { + tCore.setAssetBalances(test.assetBalances) + tCore.market = test.market + tCore.sellSwapFees = test.swapFees + tCore.sellRedeemFees = test.redeemFees + tCore.sellRefundFees = test.refundFees + tCore.isAccountLocker[60] = true + + botID := dcrBtcID + if test.market.QuoteID == 60 { + botID = dcrEthID + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + adaptor := unifiedExchangeAdaptorForBot(botID, test.assetBalances, nil, tCore, nil, tLogger) + adaptor.run(ctx) + res, err := adaptor.MaxSell("host1", test.market.BaseID, test.market.QuoteID) + if test.wantErr { + if err == nil { + t.Fatalf("%s: expected error but did not get", test.name) + } + continue + } + if err != nil { + t.Fatalf("%s: unexpected error: %v", test.name, err) + } + + if !reflect.DeepEqual(tCore.preOrderParam, test.expectPreOrderParam) { + t.Fatalf("%s: expected pre order param %+v != actual %+v", test.name, test.expectPreOrderParam, tCore.preOrderParam) + } + + if !reflect.DeepEqual(res, expectedResult) { + t.Fatalf("%s: expected max sell result %+v != actual %+v", test.name, expectedResult, res) + } + } +} + +func TestExchangeAdaptorMaxBuy(t *testing.T) { + tCore := newTCore() + + tCore.isAccountLocker[60] = true + dcrBtcID := fmt.Sprintf("%s-%d-%d", "host1", 42, 0) + ethBtcID := fmt.Sprintf("%s-%d-%d", "host1", 60, 0) + + // Whatever is returned from PreOrder is returned from this function. + // What we need to test is what is passed to PreOrder. + orderEstimate := &core.OrderEstimate{ + Swap: &asset.PreSwap{ + Estimate: &asset.SwapEstimate{ + Lots: 5, + Value: 5e8, + MaxFees: 1600, + RealisticWorstCase: 12010, + RealisticBestCase: 6008, + }, + }, + Redeem: &asset.PreRedeem{ + Estimate: &asset.RedeemEstimate{ + RealisticBestCase: 2800, + RealisticWorstCase: 6500, + }, + }, + } + tCore.orderEstimate = orderEstimate + + expectedResult := &core.MaxOrderEstimate{ + Swap: &asset.SwapEstimate{ + Lots: 5, + Value: 5e8, + MaxFees: 1600, + RealisticWorstCase: 12010, + RealisticBestCase: 6008, + }, + Redeem: &asset.RedeemEstimate{ + RealisticBestCase: 2800, + RealisticWorstCase: 6500, + }, + } + + tests := []struct { + name string + dexBalances map[uint32]uint64 + market *core.Market + rate uint64 + swapFees uint64 + redeemFees uint64 + refundFees uint64 + + expectPreOrderParam *core.TradeForm + wantErr bool + }{ + { + name: "ok", + rate: 5e7, + dexBalances: map[uint32]uint64{ + 0: 5e6, + 42: 5e6, + }, + market: &core.Market{ + LotSize: 1e6, + BaseID: 42, + QuoteID: 0, + }, + expectPreOrderParam: &core.TradeForm{ + Host: "host1", + IsLimit: true, + Base: 42, + Quote: 0, + Sell: false, + Rate: 5e7, + Qty: 9 * 1e6, + }, + swapFees: 1000, + redeemFees: 1000, + }, + { + name: "1 lot", + rate: 5e7, + dexBalances: map[uint32]uint64{ + 42: 1000, + 0: calc.BaseToQuote(5e7, 1e6) + 1000, + }, + market: &core.Market{ + LotSize: 1e6, + BaseID: 42, + QuoteID: 0, + }, + expectPreOrderParam: &core.TradeForm{ + Host: "host1", + IsLimit: true, + Base: 42, + Quote: 0, + Sell: false, + Qty: 1e6, + Rate: 5e7, + }, + swapFees: 1000, + redeemFees: 1000, + }, + { + name: "not enough for 1 swap", + rate: 5e7, + dexBalances: map[uint32]uint64{ + 0: 1000, + 42: calc.BaseToQuote(5e7, 1e6) + 999, + }, + market: &core.Market{ + LotSize: 1e6, + BaseID: 42, + QuoteID: 0, + }, + swapFees: 1000, + redeemFees: 1000, + wantErr: true, + }, + { + name: "not enough for 1 lot of redeem fees", + rate: 5e7, + dexBalances: map[uint32]uint64{ + 0: calc.BaseToQuote(5e7, 1e6) + 1000, + 60: 999, + }, + market: &core.Market{ + LotSize: 1e6, + BaseID: 60, + QuoteID: 0, + }, + swapFees: 1000, + redeemFees: 1000, + wantErr: true, + }, + { + name: "only account locker affected by redeem fees", + rate: 5e7, + dexBalances: map[uint32]uint64{ + 0: calc.BaseToQuote(5e7, 1e6) + 1000, + 42: 999, + }, + market: &core.Market{ + LotSize: 1e6, + BaseID: 42, + QuoteID: 0, + }, + swapFees: 1000, + redeemFees: 1000, + expectPreOrderParam: &core.TradeForm{ + Host: "host1", + IsLimit: true, + Base: 42, + Quote: 0, + Sell: false, + Qty: 1e6, + Rate: 5e7, + }, + }, + { + name: "2 lots with refund fees, not account locker", + rate: 5e7, + dexBalances: map[uint32]uint64{ + 0: calc.BaseToQuote(5e7, 2e6) + 2000, + 42: 1000, + }, + market: &core.Market{ + LotSize: 1e6, + BaseID: 42, + QuoteID: 0, + }, + expectPreOrderParam: &core.TradeForm{ + Host: "host1", + IsLimit: true, + Base: 42, + Quote: 0, + Sell: false, + Qty: 2e6, + Rate: 5e7, + }, + swapFees: 1000, + redeemFees: 1000, + refundFees: 1000, + }, + { + name: "1 lot with refund fees, account locker", + rate: 5e7, + dexBalances: map[uint32]uint64{ + 0: calc.BaseToQuote(5e7, 2e6) + 2000, + 60: 1000, + }, + market: &core.Market{ + LotSize: 1e6, + BaseID: 60, + QuoteID: 0, + }, + expectPreOrderParam: &core.TradeForm{ + Host: "host1", + IsLimit: true, + Base: 60, + Quote: 0, + Sell: false, + Qty: 1e6, + Rate: 5e7, + }, + swapFees: 1000, + redeemFees: 1000, + refundFees: 1000, + }, + } + + for _, test := range tests { + tCore.market = test.market + tCore.buySwapFees = test.swapFees + tCore.buyRedeemFees = test.redeemFees + + botID := dcrBtcID + if test.market.BaseID != 42 { + botID = ethBtcID + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + adaptor := unifiedExchangeAdaptorForBot(botID, test.dexBalances, nil, tCore, nil, tLogger) + adaptor.run(ctx) + + res, err := adaptor.MaxBuy("host1", test.market.BaseID, test.market.QuoteID, test.rate) + if test.wantErr { + if err == nil { + t.Fatalf("%s: expected error but did not get", test.name) + } + continue + } + if err != nil { + t.Fatalf("%s: unexpected error: %v", test.name, err) + } + + if !reflect.DeepEqual(tCore.preOrderParam, test.expectPreOrderParam) { + t.Fatalf("%s: expected pre order param %+v != actual %+v", test.name, test.expectPreOrderParam, tCore.preOrderParam) + } + + if !reflect.DeepEqual(res, expectedResult) { + t.Fatalf("%s: expected max buy result %+v != actual %+v", test.name, expectedResult, res) + } + } +} + +func TestExchangeAdaptorDEXTrade(t *testing.T) { + host := "dex.com" + + orderIDs := make([]order.OrderID, 5) + for i := range orderIDs { + var id order.OrderID + copy(id[:], encode.RandomBytes(order.OrderIDSize)) + orderIDs[i] = id + } + + matchIDs := make([]order.MatchID, 5) + for i := range matchIDs { + var id order.MatchID + copy(id[:], encode.RandomBytes(order.MatchIDSize)) + matchIDs[i] = id + } + + walletTxIDs := make([][32]byte, 6) + for i := range walletTxIDs { + var id [32]byte + copy(id[:], encode.RandomBytes(32)) + walletTxIDs[i] = id + } + + type updateAndBalances struct { + walletTxUpdates []*asset.WalletTransaction + note core.Notification + balances map[uint32]*botBalance + } + + type test struct { + name string + isDynamicSwapper map[uint32]bool + balances map[uint32]uint64 + multiTrade *core.MultiTradeForm + multiTradeResponse []*core.Order + wantErr bool + postTradeBalances map[uint32]*botBalance + updatesAndBalances []*updateAndBalances + } + + tests := []*test{ + { + name: "non dynamic swapper, sell", + balances: map[uint32]uint64{ + 42: 1e8, + 0: 1e8, + }, + multiTrade: &core.MultiTradeForm{ + Host: host, + Sell: true, + Base: 42, + Quote: 0, + Placements: []*core.QtyRate{ + { + Qty: 5e6, + Rate: 5e7, + }, + { + Qty: 5e6, + Rate: 6e7, + }, + }, + }, + multiTradeResponse: []*core.Order{ + { + Host: host, + BaseID: 42, + QuoteID: 0, + Sell: true, + LockedAmt: 5e6 + 2000, + Status: order.OrderStatusBooked, + ID: orderIDs[0][:], + }, + { + Host: host, + BaseID: 42, + QuoteID: 0, + Sell: true, + LockedAmt: 5e6 + 2000, + Status: order.OrderStatusBooked, + ID: orderIDs[1][:], + }, + }, + postTradeBalances: map[uint32]*botBalance{ + 42: { + Available: 1e8 - (5e6+2000)*2, + Locked: (5e6 + 2000) * 2, + }, + 0: { + Available: 1e8, + }, + }, + updatesAndBalances: []*updateAndBalances{ + // First order has a match and sends a swap tx + { + walletTxUpdates: []*asset.WalletTransaction{ + { + Type: asset.Swap, + ID: walletTxIDs[0][:], + BalanceDelta: -2e6, + Fees: 1000, + }, + }, + note: &core.OrderNote{ + Order: &core.Order{ + Host: host, + BaseID: 42, + QuoteID: 0, + Sell: true, + LockedAmt: 3e6 + 1000, + Status: order.OrderStatusBooked, + ID: orderIDs[0][:], + Matches: []*core.Match{ + { + Swap: &core.Coin{ + ID: walletTxIDs[0][:], + }, + }, + }, + }, + }, + balances: map[uint32]*botBalance{ + 42: { + Available: 1e8 - (5e6+2000)*2, + Locked: 5e6 + 2000 + 3e6 + 1000, + }, + 0: { + Available: 1e8, + }, + }, + }, + // Second order has a match and sends swap tx + { + walletTxUpdates: []*asset.WalletTransaction{ + { + Type: asset.Swap, + ID: walletTxIDs[1][:], + BalanceDelta: -3e6, + Fees: 1000, + }, + }, + note: &core.OrderNote{ + Order: &core.Order{ + Host: host, + BaseID: 42, + QuoteID: 0, + Sell: true, + LockedAmt: 2e6 + 1000, + Status: order.OrderStatusBooked, + ID: orderIDs[1][:], + Matches: []*core.Match{ + { + Swap: &core.Coin{ + ID: walletTxIDs[1][:], + }, + }, + }, + }, + }, + balances: map[uint32]*botBalance{ + 42: { + Available: 1e8 - (5e6+2000)*2, + Locked: 2e6 + 1000 + 3e6 + 1000, + }, + 0: { + Available: 1e8, + }, + }, + }, + // First order swap is confirmed, and redemption is sent + { + walletTxUpdates: []*asset.WalletTransaction{ + { + Type: asset.Swap, + ID: walletTxIDs[0][:], + BalanceDelta: -2e6, + Fees: 1000, + PartOfActiveBalance: true, + }, + { + Type: asset.Redeem, + ID: walletTxIDs[2][:], + BalanceDelta: int64(calc.BaseToQuote(5e7, 2e6)), + Fees: 1000, + }, + }, + note: &core.OrderNote{ + Order: &core.Order{ + Host: host, + BaseID: 42, + QuoteID: 0, + Sell: true, + LockedAmt: 3e6 + 1000, + Status: order.OrderStatusBooked, + ID: orderIDs[0][:], + Matches: []*core.Match{ + { + Swap: &core.Coin{ + ID: walletTxIDs[0][:], + }, + Redeem: &core.Coin{ + ID: walletTxIDs[2][:], + }, + }, + }, + }, + }, + balances: map[uint32]*botBalance{ + 42: { + Available: 1e8 - (5e6+2000)*2, + Locked: 2e6 + 1000 + 3e6 + 1000, + }, + 0: { + Available: 1e8, + Pending: calc.BaseToQuote(5e7, 2e6) - 1000, + }, + }, + }, + // First order redemption confirmed + { + walletTxUpdates: []*asset.WalletTransaction{ + { + Type: asset.Redeem, + ID: walletTxIDs[2][:], + BalanceDelta: int64(calc.BaseToQuote(5e7, 2e6)), + Fees: 1000, + PartOfActiveBalance: true, + }, + }, + note: &core.OrderNote{ + Order: &core.Order{ + Host: host, + BaseID: 42, + QuoteID: 0, + Sell: true, + LockedAmt: 3e6 + 1000, + Status: order.OrderStatusBooked, + ID: orderIDs[0][:], + Matches: []*core.Match{ + { + Swap: &core.Coin{ + ID: walletTxIDs[0][:], + }, + Redeem: &core.Coin{ + ID: walletTxIDs[2][:], + }, + }, + }, + }, + }, + balances: map[uint32]*botBalance{ + 42: { + Available: 1e8 - (5e6+2000)*2, + Locked: 2e6 + 1000 + 3e6 + 1000, + }, + 0: { + Available: 1e8 + calc.BaseToQuote(5e7, 2e6) - 1000, + }, + }, + }, + // First order cancelled + { + note: &core.OrderNote{ + Order: &core.Order{ + Host: host, + BaseID: 42, + QuoteID: 0, + Sell: true, + Status: order.OrderStatusCanceled, + ID: orderIDs[0][:], + AllFeesConfirmed: true, + Matches: []*core.Match{ + { + Swap: &core.Coin{ + ID: walletTxIDs[0][:], + }, + Redeem: &core.Coin{ + ID: walletTxIDs[2][:], + }, + Status: order.MatchConfirmed, + }, + }, + }, + }, + balances: map[uint32]*botBalance{ + 42: { + Available: 1e8 - (7e6 + 2000 + 1000), + Locked: 2e6 + 1000, + }, + 0: { + Available: 1e8 + calc.BaseToQuote(5e7, 2e6) - 1000, + }, + }, + }, + // Second order second match, swap sent, and first match refunded + { + walletTxUpdates: []*asset.WalletTransaction{ + { + Type: asset.Swap, + ID: walletTxIDs[1][:], + BalanceDelta: -3e6, + Fees: 1000, + PartOfActiveBalance: true, + }, + { + Type: asset.Refund, + ID: walletTxIDs[3][:], + BalanceDelta: 3e6, + Fees: 1200, + }, + { + Type: asset.Swap, + ID: walletTxIDs[4][:], + BalanceDelta: -2e6, + Fees: 800, + }, + }, + note: &core.OrderNote{ + Order: &core.Order{ + Host: host, + BaseID: 42, + QuoteID: 0, + Sell: true, + Status: order.OrderStatusExecuted, + ID: orderIDs[1][:], + Matches: []*core.Match{ + { + Swap: &core.Coin{ + ID: walletTxIDs[1][:], + }, + Refund: &core.Coin{ + ID: walletTxIDs[3][:], + }, + }, + { + Swap: &core.Coin{ + ID: walletTxIDs[4][:], + }, + }, + }, + }, + }, + balances: map[uint32]*botBalance{ + 42: { + Available: 1e8 - (7e6 + 1800 + 1000), + Pending: 3e6 - 1200, + }, + 0: { + Available: 1e8 + calc.BaseToQuote(5e7, 2e6) - 1000, + }, + }, + }, + // Second order second match redeemed and confirmed, first match refund confirmed + { + walletTxUpdates: []*asset.WalletTransaction{ + { + Type: asset.Refund, + ID: walletTxIDs[3][:], + BalanceDelta: 3e6, + Fees: 1200, + PartOfActiveBalance: true, + }, + { + Type: asset.Swap, + ID: walletTxIDs[4][:], + BalanceDelta: -2e6, + Fees: 800, + PartOfActiveBalance: true, + }, + { + Type: asset.Redeem, + ID: walletTxIDs[5][:], + BalanceDelta: int64(calc.BaseToQuote(6e7, 2e6)), + Fees: 700, + PartOfActiveBalance: true, + }, + }, + note: &core.OrderNote{ + Order: &core.Order{ + Host: host, + BaseID: 42, + QuoteID: 0, + Sell: true, + Status: order.OrderStatusExecuted, + ID: orderIDs[1][:], + Matches: []*core.Match{ + { + Swap: &core.Coin{ + ID: walletTxIDs[1][:], + }, + Refund: &core.Coin{ + ID: walletTxIDs[3][:], + }, + }, + { + Swap: &core.Coin{ + ID: walletTxIDs[4][:], + }, + Redeem: &core.Coin{ + ID: walletTxIDs[5][:], + }, + }, + }, + }, + }, + balances: map[uint32]*botBalance{ + 42: { + Available: 1e8 - (4e6 + 1800 + 1000 + 1200), + }, + 0: { + Available: 1e8 + calc.BaseToQuote(5e7, 2e6) + calc.BaseToQuote(6e7, 2e6) - 1700, + }, + }, + }, + }, + }, + { + name: "non dynamic swapper, buy", + balances: map[uint32]uint64{ + 42: 1e8, + 0: 1e8, + }, + multiTrade: &core.MultiTradeForm{ + Host: host, + Sell: false, + Base: 42, + Quote: 0, + Placements: []*core.QtyRate{ + { + Qty: 5e6, + Rate: 5e7, + }, + { + Qty: 5e6, + Rate: 6e7, + }, + }, + }, + multiTradeResponse: []*core.Order{ + { + Host: host, + BaseID: 42, + QuoteID: 0, + Sell: false, + LockedAmt: calc.BaseToQuote(5e7, 5e6) + 2000, + Status: order.OrderStatusBooked, + ID: orderIDs[0][:], + }, + { + Host: host, + BaseID: 42, + QuoteID: 0, + Sell: false, + LockedAmt: calc.BaseToQuote(6e7, 5e6) + 2000, + Status: order.OrderStatusBooked, + ID: orderIDs[1][:], + }, + }, + postTradeBalances: map[uint32]*botBalance{ + 42: { + Available: 1e8, + }, + 0: { + Available: 1e8 - (calc.BaseToQuote(5e7, 5e6) + calc.BaseToQuote(6e7, 5e6) + 4000), + Locked: calc.BaseToQuote(5e7, 5e6) + calc.BaseToQuote(6e7, 5e6) + 4000, + }, + }, + updatesAndBalances: []*updateAndBalances{ + // First order has a match and sends a swap tx + { + walletTxUpdates: []*asset.WalletTransaction{ + { + Type: asset.Swap, + ID: walletTxIDs[0][:], + BalanceDelta: -int64(calc.BaseToQuote(5e7, 2e6)), + Fees: 1000, + }, + }, + note: &core.OrderNote{ + Order: &core.Order{ + Host: host, + BaseID: 42, + QuoteID: 0, + Sell: false, + LockedAmt: calc.BaseToQuote(5e7, 3e6) + 1000, + Status: order.OrderStatusBooked, + ID: orderIDs[0][:], + Matches: []*core.Match{ + { + Swap: &core.Coin{ + ID: walletTxIDs[0][:], + }, + }, + }, + }, + }, + balances: map[uint32]*botBalance{ + 42: { + Available: 1e8, + }, + 0: { + Available: 1e8 - (calc.BaseToQuote(5e7, 5e6) + calc.BaseToQuote(6e7, 5e6) + 4000), + Locked: calc.BaseToQuote(5e7, 3e6) + calc.BaseToQuote(6e7, 5e6) + 3000, + }, + }, + }, + // Second order has a match and sends swap tx + { + walletTxUpdates: []*asset.WalletTransaction{ + { + Type: asset.Swap, + ID: walletTxIDs[1][:], + BalanceDelta: -int64(calc.BaseToQuote(6e7, 3e6)), + Fees: 1000, + }, + }, + note: &core.OrderNote{ + Order: &core.Order{ + Host: host, + BaseID: 42, + QuoteID: 0, + Sell: false, + LockedAmt: calc.BaseToQuote(6e7, 2e6) + 1000, + Status: order.OrderStatusBooked, + ID: orderIDs[1][:], + Matches: []*core.Match{ + { + Swap: &core.Coin{ + ID: walletTxIDs[1][:], + }, + }, + }, + }, + }, + balances: map[uint32]*botBalance{ + 42: { + Available: 1e8, + }, + 0: { + Available: 1e8 - (calc.BaseToQuote(5e7, 5e6) + calc.BaseToQuote(6e7, 5e6) + 4000), + Locked: calc.BaseToQuote(5e7, 3e6) + calc.BaseToQuote(6e7, 2e6) + 2000, + }, + }, + }, + // First order swap is confirmed, and redemption is sent + { + walletTxUpdates: []*asset.WalletTransaction{ + { + Type: asset.Swap, + ID: walletTxIDs[0][:], + BalanceDelta: -int64(calc.BaseToQuote(5e7, 2e6)), + Fees: 1000, + PartOfActiveBalance: true, + }, + { + Type: asset.Redeem, + ID: walletTxIDs[2][:], + BalanceDelta: 2e6, + Fees: 1000, + }, + }, + note: &core.OrderNote{ + Order: &core.Order{ + Host: host, + BaseID: 42, + QuoteID: 0, + Sell: false, + LockedAmt: calc.BaseToQuote(5e7, 3e6) + 1000, + Status: order.OrderStatusBooked, + ID: orderIDs[0][:], + Matches: []*core.Match{ + { + Swap: &core.Coin{ + ID: walletTxIDs[0][:], + }, + Redeem: &core.Coin{ + ID: walletTxIDs[2][:], + }, + }, + }, + }, + }, + balances: map[uint32]*botBalance{ + 42: { + Available: 1e8, + Pending: 2e6 - 1000, + }, + 0: { + Available: 1e8 - (calc.BaseToQuote(5e7, 5e6) + calc.BaseToQuote(6e7, 5e6) + 4000), + Locked: calc.BaseToQuote(5e7, 3e6) + calc.BaseToQuote(6e7, 2e6) + 2000, + }, + }, + }, + // First order redemption confirmed + { + walletTxUpdates: []*asset.WalletTransaction{ + { + Type: asset.Redeem, + ID: walletTxIDs[2][:], + BalanceDelta: 2e6, + Fees: 1000, + PartOfActiveBalance: true, + }, + }, + note: &core.OrderNote{ + Order: &core.Order{ + Host: host, + BaseID: 42, + QuoteID: 0, + Sell: false, + LockedAmt: calc.BaseToQuote(5e7, 3e6) + 1000, + Status: order.OrderStatusBooked, + ID: orderIDs[0][:], + Matches: []*core.Match{ + { + Swap: &core.Coin{ + ID: walletTxIDs[0][:], + }, + Redeem: &core.Coin{ + ID: walletTxIDs[2][:], + }, + }, + }, + }, + }, + balances: map[uint32]*botBalance{ + 42: { + Available: 1e8 + 2e6 - 1000, + }, + 0: { + Available: 1e8 - (calc.BaseToQuote(5e7, 5e6) + calc.BaseToQuote(6e7, 5e6) + 4000), + Locked: calc.BaseToQuote(5e7, 3e6) + calc.BaseToQuote(6e7, 2e6) + 2000, + }, + }, + }, + // First order cancelled + { + note: &core.OrderNote{ + Order: &core.Order{ + Host: host, + BaseID: 42, + QuoteID: 0, + Sell: false, + Status: order.OrderStatusCanceled, + ID: orderIDs[0][:], + Matches: []*core.Match{ + { + Swap: &core.Coin{ + ID: walletTxIDs[0][:], + }, + Redeem: &core.Coin{ + ID: walletTxIDs[2][:], + }, + Status: order.MatchConfirmed, + }, + }, + AllFeesConfirmed: true, + }, + }, + balances: map[uint32]*botBalance{ + 42: { + Available: 1e8 + 2e6 - 1000, + }, + 0: { + Available: 1e8 - (calc.BaseToQuote(5e7, 2e6) + calc.BaseToQuote(6e7, 5e6) + 3000), + Locked: calc.BaseToQuote(6e7, 2e6) + 1000, + }, + }, + }, + // Second order second match, swap sent, and first match refunded + { + walletTxUpdates: []*asset.WalletTransaction{ + { + Type: asset.Swap, + ID: walletTxIDs[1][:], + BalanceDelta: -int64(calc.BaseToQuote(6e7, 3e6)), + Fees: 1000, + PartOfActiveBalance: true, + }, + { + Type: asset.Refund, + ID: walletTxIDs[3][:], + BalanceDelta: int64(calc.BaseToQuote(6e7, 3e6)), + Fees: 1200, + }, + { + Type: asset.Swap, + ID: walletTxIDs[4][:], + BalanceDelta: -int64(calc.BaseToQuote(6e7, 2e6)), + Fees: 800, + }, + }, + note: &core.OrderNote{ + Order: &core.Order{ + Host: host, + BaseID: 42, + QuoteID: 0, + Sell: false, + Status: order.OrderStatusExecuted, + ID: orderIDs[1][:], + Matches: []*core.Match{ + { + Swap: &core.Coin{ + ID: walletTxIDs[1][:], + }, + Refund: &core.Coin{ + ID: walletTxIDs[3][:], + }, + }, + { + Swap: &core.Coin{ + ID: walletTxIDs[4][:], + }, + }, + }, + }, + }, + balances: map[uint32]*botBalance{ + 42: { + Available: 1e8 + 2e6 - 1000, + }, + 0: { + Available: 1e8 - (calc.BaseToQuote(5e7, 2e6) + calc.BaseToQuote(6e7, 5e6) + 2800), + Pending: calc.BaseToQuote(6e7, 3e6) - 1200, + }, + }, + }, + // Second order second match redeemed and confirmed, first match refund confirmed + { + walletTxUpdates: []*asset.WalletTransaction{ + { + Type: asset.Refund, + ID: walletTxIDs[3][:], + BalanceDelta: int64(calc.BaseToQuote(6e7, 3e6)), + Fees: 1200, + PartOfActiveBalance: true, + }, + { + Type: asset.Swap, + ID: walletTxIDs[4][:], + BalanceDelta: -int64(calc.BaseToQuote(6e7, 2e6)), + Fees: 800, + PartOfActiveBalance: true, + }, + { + Type: asset.Redeem, + ID: walletTxIDs[5][:], + BalanceDelta: 2e6, + Fees: 700, + PartOfActiveBalance: true, + }, + }, + note: &core.OrderNote{ + Order: &core.Order{ + Host: host, + BaseID: 42, + QuoteID: 0, + Sell: false, + Status: order.OrderStatusExecuted, + ID: orderIDs[1][:], + Matches: []*core.Match{ + { + Swap: &core.Coin{ + ID: walletTxIDs[1][:], + }, + Refund: &core.Coin{ + ID: walletTxIDs[3][:], + }, + }, + { + Swap: &core.Coin{ + ID: walletTxIDs[4][:], + }, + Redeem: &core.Coin{ + ID: walletTxIDs[5][:], + }, + }, + }, + }, + }, + balances: map[uint32]*botBalance{ + 42: { + Available: 1e8 + 4e6 - 1700, + }, + 0: { + Available: 1e8 - (calc.BaseToQuote(5e7, 2e6) + calc.BaseToQuote(6e7, 2e6) + 2800 + 1200), + }, + }, + }, + }, + }, + { + name: "dynamic swapper, token, sell", + isDynamicSwapper: map[uint32]bool{ + 966001: true, + 966: true, + 60: true, + }, + balances: map[uint32]uint64{ + 966001: 1e8, + 966: 1e8, + 60: 1e8, + }, + multiTrade: &core.MultiTradeForm{ + Host: host, + Sell: true, + Base: 60, + Quote: 966001, + Placements: []*core.QtyRate{ + { + Qty: 5e6, + Rate: 5e7, + }, + { + Qty: 5e6, + Rate: 6e7, + }, + }, + }, + multiTradeResponse: []*core.Order{ + { + Host: host, + BaseID: 60, + QuoteID: 966001, + Sell: true, + LockedAmt: 5e6 + 2000, + RefundLockedAmt: 3000, + RedeemLockedAmt: 4000, + Status: order.OrderStatusBooked, + ID: orderIDs[0][:], + }, + { + Host: host, + BaseID: 60, + QuoteID: 966001, + Sell: true, + LockedAmt: 5e6 + 2000, + RefundLockedAmt: 3000, + RedeemLockedAmt: 4000, + Status: order.OrderStatusBooked, + ID: orderIDs[1][:], + }, + }, + postTradeBalances: map[uint32]*botBalance{ + 966001: { + Available: 1e8, + }, + 966: { + Available: 1e8 - 8000, + Locked: 8000, + }, + 60: { + Available: 1e8 - (5e6+2000+3000)*2, + Locked: (5e6 + 2000 + 3000) * 2, + }, + }, + updatesAndBalances: []*updateAndBalances{ + // First order has a match and sends a swap tx + { + walletTxUpdates: []*asset.WalletTransaction{ + { + Type: asset.Swap, + ID: walletTxIDs[0][:], + BalanceDelta: -2e6, + Fees: 1000, + }, + }, + note: &core.OrderNote{ + Order: &core.Order{ + Host: host, + BaseID: 60, + QuoteID: 966001, + Sell: true, + LockedAmt: 3e6 + 1000, + RefundLockedAmt: 3000, + RedeemLockedAmt: 4000, + Status: order.OrderStatusBooked, + ID: orderIDs[0][:], + Matches: []*core.Match{ + { + Swap: &core.Coin{ + ID: walletTxIDs[0][:], + }, + }, + }, + }, + }, + balances: map[uint32]*botBalance{ + 966001: { + Available: 1e8, + }, + 966: { + Available: 1e8 - 8000, + Locked: 8000, + }, + 60: { + Available: 1e8 - (5e6+2000+3000)*2, + Locked: 3e6 + 1000 + 5e6 + 2000 + 3000 + 3000, + }, + }, + }, + // Second order has a match and sends swap tx + { + walletTxUpdates: []*asset.WalletTransaction{ + { + Type: asset.Swap, + ID: walletTxIDs[1][:], + BalanceDelta: -3e6, + Fees: 1000, + }, + }, + note: &core.OrderNote{ + Order: &core.Order{ + Host: host, + BaseID: 60, + QuoteID: 966001, + Sell: true, + LockedAmt: 2e6 + 1000, + RefundLockedAmt: 3000, + RedeemLockedAmt: 4000, + Status: order.OrderStatusBooked, + ID: orderIDs[1][:], + Matches: []*core.Match{ + { + Swap: &core.Coin{ + ID: walletTxIDs[1][:], + }, + }, + }, + }, + }, + balances: map[uint32]*botBalance{ + 966001: { + Available: 1e8, + }, + 966: { + Available: 1e8 - 8000, + Locked: 8000, + }, + 60: { + Available: 1e8 - (5e6+2000+3000)*2, + Locked: 3e6 + 1000 + 2e6 + 1000 + 3000 + 3000, + }, + }, + }, + // First order swap is confirmed, and redemption is sent + { + walletTxUpdates: []*asset.WalletTransaction{ + { + Type: asset.Swap, + ID: walletTxIDs[0][:], + BalanceDelta: -2e6, + Fees: 900, + PartOfActiveBalance: true, + }, + { + Type: asset.Redeem, + ID: walletTxIDs[2][:], + BalanceDelta: int64(calc.BaseToQuote(5e7, 2e6)), + Fees: 1000, + }, + }, + note: &core.OrderNote{ + Order: &core.Order{ + Host: host, + BaseID: 60, + QuoteID: 966001, + Sell: true, + LockedAmt: 3e6 + 1000, + RefundLockedAmt: 3000, + RedeemLockedAmt: 3000, + Status: order.OrderStatusBooked, + ID: orderIDs[0][:], + Matches: []*core.Match{ + { + Swap: &core.Coin{ + ID: walletTxIDs[0][:], + }, + Redeem: &core.Coin{ + ID: walletTxIDs[2][:], + }, + }, + }, + }, + }, + balances: map[uint32]*botBalance{ + 966001: { + Available: 1e8, + Pending: calc.BaseToQuote(5e7, 2e6), + }, + 966: { + Available: 1e8 - 8000, + Locked: 7000, + }, + 60: { + Available: 1e8 - (5e6+2000+3000)*2 + 100, + Locked: 3e6 + 1000 + 2e6 + 1000 + 3000 + 3000, + }, + }, + }, + // First order redemption confirmed + { + walletTxUpdates: []*asset.WalletTransaction{ + { + Type: asset.Redeem, + ID: walletTxIDs[2][:], + BalanceDelta: int64(calc.BaseToQuote(5e7, 2e6)), + Fees: 800, + PartOfActiveBalance: true, + }, + }, + note: &core.OrderNote{ + Order: &core.Order{ + Host: host, + BaseID: 60, + QuoteID: 966001, + Sell: true, + LockedAmt: 3e6 + 1000, + RefundLockedAmt: 3000, + RedeemLockedAmt: 3000, + Status: order.OrderStatusBooked, + ID: orderIDs[0][:], + Matches: []*core.Match{ + { + Swap: &core.Coin{ + ID: walletTxIDs[0][:], + }, + Redeem: &core.Coin{ + ID: walletTxIDs[2][:], + }, + }, + }, + }, + }, + balances: map[uint32]*botBalance{ + 966001: { + Available: 1e8 + calc.BaseToQuote(5e7, 2e6), + }, + 966: { + Available: 1e8 - 7800, + Locked: 7000, + }, + 60: { + Available: 1e8 - (5e6+2000+3000)*2 + 100, + Locked: 3e6 + 1000 + 2e6 + 1000 + 3000 + 3000, + }, + }, + }, + // First order cancelled + { + note: &core.OrderNote{ + Order: &core.Order{ + Host: host, + BaseID: 60, + QuoteID: 966001, + Sell: true, + Status: order.OrderStatusCanceled, + ID: orderIDs[0][:], + AllFeesConfirmed: true, + Matches: []*core.Match{ + { + Swap: &core.Coin{ + ID: walletTxIDs[0][:], + }, + Redeem: &core.Coin{ + ID: walletTxIDs[2][:], + }, + Status: order.MatchConfirmed, + }, + }, + }, + }, + balances: map[uint32]*botBalance{ + 966001: { + Available: 1e8 + calc.BaseToQuote(5e7, 2e6), + }, + 966: { + Available: 1e8 - 4800, + Locked: 4000, + }, + 60: { + Available: 1e8 - (7e6 + 900 + 2000 + 3000), + Locked: 2e6 + 1000 + 3000, + }, + }, + }, + // Second order second match, swap sent, and first match refunded + { + walletTxUpdates: []*asset.WalletTransaction{ + { + Type: asset.Swap, + ID: walletTxIDs[1][:], + BalanceDelta: -3e6, + Fees: 1000, + PartOfActiveBalance: true, + }, + { + Type: asset.Refund, + ID: walletTxIDs[3][:], + BalanceDelta: 3e6, + Fees: 1200, + }, + { + Type: asset.Swap, + ID: walletTxIDs[4][:], + BalanceDelta: -2e6, + Fees: 800, + }, + }, + note: &core.OrderNote{ + Order: &core.Order{ + Host: host, + BaseID: 60, + QuoteID: 966001, + Sell: true, + Status: order.OrderStatusExecuted, + ID: orderIDs[1][:], + RefundLockedAmt: 1800, + RedeemLockedAmt: 4000, + Matches: []*core.Match{ + { + Swap: &core.Coin{ + ID: walletTxIDs[1][:], + }, + Refund: &core.Coin{ + ID: walletTxIDs[3][:], + }, + }, + { + Swap: &core.Coin{ + ID: walletTxIDs[4][:], + }, + }, + }, + }, + }, + balances: map[uint32]*botBalance{ + 966001: { + Available: 1e8 + calc.BaseToQuote(5e7, 2e6), + }, + 966: { + Available: 1e8 - 4800, + Locked: 4000, + }, + 60: { + Available: 1e8 - (7e6 + 900 + 2000 + 3000) + 200, + Pending: 3e6, + Locked: 1800, + }, + }, + }, + // Second order second match redeemed and confirmed, first match refund confirmed + { + walletTxUpdates: []*asset.WalletTransaction{ + { + Type: asset.Refund, + ID: walletTxIDs[3][:], + BalanceDelta: 3e6, + Fees: 1100, + PartOfActiveBalance: true, + }, + { + Type: asset.Swap, + ID: walletTxIDs[4][:], + BalanceDelta: -2e6, + Fees: 800, + PartOfActiveBalance: true, + }, + { + Type: asset.Redeem, + ID: walletTxIDs[5][:], + BalanceDelta: int64(calc.BaseToQuote(6e7, 2e6)), + Fees: 700, + PartOfActiveBalance: true, + }, + }, + note: &core.OrderNote{ + Order: &core.Order{ + Host: host, + BaseID: 60, + QuoteID: 966001, + Sell: true, + Status: order.OrderStatusExecuted, + ID: orderIDs[1][:], + Matches: []*core.Match{ + { + Swap: &core.Coin{ + ID: walletTxIDs[1][:], + }, + Refund: &core.Coin{ + ID: walletTxIDs[3][:], + }, + }, + { + Swap: &core.Coin{ + ID: walletTxIDs[4][:], + }, + Redeem: &core.Coin{ + ID: walletTxIDs[5][:], + }, + }, + }, + }, + }, + balances: map[uint32]*botBalance{ + 966001: { + Available: 1e8 + calc.BaseToQuote(5e7, 2e6) + calc.BaseToQuote(6e7, 2e6), + }, + 966: { + Available: 1e8 - 1500, + }, + 60: { + Available: 1e8 - (4e6 + 3800), + }, + }, + }, + }, + }, + { + name: "dynamic swapper, token, buy", + isDynamicSwapper: map[uint32]bool{ + 966001: true, + 966: true, + 60: true, + }, + balances: map[uint32]uint64{ + 966001: 1e8, + 966: 1e8, + 60: 1e8, + }, + multiTrade: &core.MultiTradeForm{ + Host: host, + Sell: false, + Base: 60, + Quote: 966001, + Placements: []*core.QtyRate{ + { + Qty: 5e6, + Rate: 5e7, + }, + { + Qty: 5e6, + Rate: 6e7, + }, + }, + }, + multiTradeResponse: []*core.Order{ + { + Host: host, + BaseID: 60, + QuoteID: 966001, + Sell: false, + LockedAmt: calc.BaseToQuote(5e7, 5e6), + ParentAssetLockedAmt: 2000, + RedeemLockedAmt: 3000, + RefundLockedAmt: 4000, + Status: order.OrderStatusBooked, + ID: orderIDs[0][:], + }, + { + Host: host, + BaseID: 60, + QuoteID: 966001, + Sell: false, + LockedAmt: calc.BaseToQuote(6e7, 5e6), + ParentAssetLockedAmt: 2000, + RedeemLockedAmt: 3000, + RefundLockedAmt: 4000, + Status: order.OrderStatusBooked, + ID: orderIDs[1][:], + }, + }, + postTradeBalances: map[uint32]*botBalance{ + 966001: { + Available: 1e8 - (calc.BaseToQuote(5e7, 5e6) + calc.BaseToQuote(6e7, 5e6)), + Locked: calc.BaseToQuote(5e7, 5e6) + calc.BaseToQuote(6e7, 5e6), + }, + 966: { + Available: 1e8 - 12000, + Locked: 12000, + }, + 60: { + Available: 1e8 - 6000, + Locked: 6000, + }, + }, + updatesAndBalances: []*updateAndBalances{ + // First order has a match and sends a swap tx + { + walletTxUpdates: []*asset.WalletTransaction{ + { + Type: asset.Swap, + ID: walletTxIDs[0][:], + BalanceDelta: -int64(calc.BaseToQuote(5e7, 2e6)), + Fees: 1000, + }, + }, + note: &core.OrderNote{ + Order: &core.Order{ + Host: host, + BaseID: 60, + QuoteID: 966001, + Sell: false, + LockedAmt: calc.BaseToQuote(5e7, 3e6), + ParentAssetLockedAmt: 1000, + RedeemLockedAmt: 3000, + RefundLockedAmt: 4000, + Status: order.OrderStatusBooked, + ID: orderIDs[0][:], + Matches: []*core.Match{ + { + Swap: &core.Coin{ + ID: walletTxIDs[0][:], + }, + }, + }, + }, + }, + balances: map[uint32]*botBalance{ + 966001: { + Available: 1e8 - (calc.BaseToQuote(5e7, 5e6) + calc.BaseToQuote(6e7, 5e6)), + Locked: calc.BaseToQuote(5e7, 3e6) + calc.BaseToQuote(6e7, 5e6), + }, + 966: { + Available: 1e8 - 12000, + Locked: 11000, + }, + 60: { + Available: 1e8 - 6000, + Locked: 6000, + }, + }, + }, + // Second order has a match and sends swap tx + { + walletTxUpdates: []*asset.WalletTransaction{ + { + Type: asset.Swap, + ID: walletTxIDs[1][:], + BalanceDelta: -int64(calc.BaseToQuote(6e7, 3e6)), + Fees: 1000, + }, + }, + note: &core.OrderNote{ + Order: &core.Order{ + Host: host, + BaseID: 60, + QuoteID: 966001, + Sell: false, + LockedAmt: calc.BaseToQuote(6e7, 2e6), + ParentAssetLockedAmt: 1000, + RedeemLockedAmt: 3000, + RefundLockedAmt: 4000, + Status: order.OrderStatusBooked, + ID: orderIDs[1][:], + Matches: []*core.Match{ + { + Swap: &core.Coin{ + ID: walletTxIDs[1][:], + }, + }, + }, + }, + }, + balances: map[uint32]*botBalance{ + 966001: { + Available: 1e8 - (calc.BaseToQuote(5e7, 5e6) + calc.BaseToQuote(6e7, 5e6)), + Locked: calc.BaseToQuote(5e7, 3e6) + calc.BaseToQuote(6e7, 2e6), + }, + 966: { + Available: 1e8 - 12000, + Locked: 10000, + }, + 60: { + Available: 1e8 - 6000, + Locked: 6000, + }, + }, + }, + // First order swap is confirmed, and redemption is sent + { + walletTxUpdates: []*asset.WalletTransaction{ + { + Type: asset.Swap, + ID: walletTxIDs[0][:], + BalanceDelta: -int64(calc.BaseToQuote(5e7, 2e6)), + Fees: 900, + PartOfActiveBalance: true, + }, + { + Type: asset.Redeem, + ID: walletTxIDs[2][:], + BalanceDelta: 2e6, + Fees: 1000, + }, + }, + note: &core.OrderNote{ + Order: &core.Order{ + Host: host, + BaseID: 60, + QuoteID: 966001, + Sell: false, + LockedAmt: calc.BaseToQuote(5e7, 3e6), + ParentAssetLockedAmt: 1000, + RedeemLockedAmt: 2000, + RefundLockedAmt: 4000, + Status: order.OrderStatusBooked, + ID: orderIDs[0][:], + Matches: []*core.Match{ + { + Swap: &core.Coin{ + ID: walletTxIDs[0][:], + }, + Redeem: &core.Coin{ + ID: walletTxIDs[2][:], + }, + }, + }, + }, + }, + balances: map[uint32]*botBalance{ + 966001: { + Available: 1e8 - (calc.BaseToQuote(5e7, 5e6) + calc.BaseToQuote(6e7, 5e6)), + Locked: calc.BaseToQuote(5e7, 3e6) + calc.BaseToQuote(6e7, 2e6), + }, + 966: { + Available: 1e8 - 12000 + 100, + Locked: 10000, + }, + 60: { + Available: 1e8 - 6000, + Pending: 2e6, + Locked: 5000, + }, + }, + }, + // First order redemption confirmed + { + walletTxUpdates: []*asset.WalletTransaction{ + { + Type: asset.Redeem, + ID: walletTxIDs[2][:], + BalanceDelta: 2e6, + Fees: 800, + PartOfActiveBalance: true, + }, + }, + note: &core.OrderNote{ + Order: &core.Order{ + Host: host, + BaseID: 60, + QuoteID: 966001, + Sell: false, + LockedAmt: calc.BaseToQuote(5e7, 3e6), + ParentAssetLockedAmt: 1000, + RedeemLockedAmt: 2000, + RefundLockedAmt: 4000, + Status: order.OrderStatusBooked, + ID: orderIDs[0][:], + Matches: []*core.Match{ + { + Swap: &core.Coin{ + ID: walletTxIDs[0][:], + }, + Redeem: &core.Coin{ + ID: walletTxIDs[2][:], + }, + }, + }, + }, + }, + balances: map[uint32]*botBalance{ + 966001: { + Available: 1e8 - (calc.BaseToQuote(5e7, 5e6) + calc.BaseToQuote(6e7, 5e6)), + Locked: calc.BaseToQuote(5e7, 3e6) + calc.BaseToQuote(6e7, 2e6), + }, + 966: { + Available: 1e8 - 12000 + 100, + Locked: 10000, + }, + 60: { + Available: 1e8 - 5800 + 2e6, + Locked: 5000, + }, + }, + }, + // First order cancelled + { + note: &core.OrderNote{ + Order: &core.Order{ + Host: host, + BaseID: 60, + QuoteID: 966001, + Sell: false, + Status: order.OrderStatusCanceled, + ID: orderIDs[0][:], + Matches: []*core.Match{ + { + Swap: &core.Coin{ + ID: walletTxIDs[0][:], + }, + Redeem: &core.Coin{ + ID: walletTxIDs[2][:], + }, + Status: order.MatchConfirmed, + }, + }, + AllFeesConfirmed: true, + }, + }, + balances: map[uint32]*botBalance{ + 966001: { + Available: 1e8 - (calc.BaseToQuote(5e7, 2e6) + calc.BaseToQuote(6e7, 5e6)), + Locked: calc.BaseToQuote(6e7, 2e6), + }, + 966: { + Available: 1e8 - 7000 + 100, + Locked: 5000, + }, + 60: { + Available: 1e8 + 2e6 - 3800, + Locked: 3000, + }, + }, + }, + // Second order second match, swap sent, and first match refunded + { + walletTxUpdates: []*asset.WalletTransaction{ + { + Type: asset.Swap, + ID: walletTxIDs[1][:], + BalanceDelta: -int64(calc.BaseToQuote(6e7, 3e6)), + Fees: 1000, + PartOfActiveBalance: true, + }, + { + Type: asset.Refund, + ID: walletTxIDs[3][:], + BalanceDelta: int64(calc.BaseToQuote(6e7, 3e6)), + Fees: 1200, + }, + { + Type: asset.Swap, + ID: walletTxIDs[4][:], + BalanceDelta: -int64(calc.BaseToQuote(6e7, 2e6)), + Fees: 800, + }, + }, + note: &core.OrderNote{ + Order: &core.Order{ + Host: host, + BaseID: 60, + QuoteID: 966001, + Sell: false, + Status: order.OrderStatusExecuted, + RedeemLockedAmt: 3000, + RefundLockedAmt: 2000, + ID: orderIDs[1][:], + Matches: []*core.Match{ + { + Swap: &core.Coin{ + ID: walletTxIDs[1][:], + }, + Refund: &core.Coin{ + ID: walletTxIDs[3][:], + }, + }, + { + Swap: &core.Coin{ + ID: walletTxIDs[4][:], + }, + }, + }, + }, + }, + balances: map[uint32]*botBalance{ + 966001: { + Available: 1e8 - (calc.BaseToQuote(5e7, 2e6) + calc.BaseToQuote(6e7, 5e6)), + Pending: calc.BaseToQuote(6e7, 3e6), + }, + 966: { + Available: 1e8 - 6000 + 100, + Locked: 2000, + }, + 60: { + Available: 1e8 + 2e6 - 3800, + Locked: 3000, + }, + }, + }, + // Second order second match redeemed and confirmed, first match refund confirmed + { + walletTxUpdates: []*asset.WalletTransaction{ + { + Type: asset.Refund, + ID: walletTxIDs[3][:], + BalanceDelta: int64(calc.BaseToQuote(6e7, 3e6)), + Fees: 1200, + PartOfActiveBalance: true, + }, + { + Type: asset.Swap, + ID: walletTxIDs[4][:], + BalanceDelta: -int64(calc.BaseToQuote(6e7, 2e6)), + Fees: 800, + PartOfActiveBalance: true, + }, + { + Type: asset.Redeem, + ID: walletTxIDs[5][:], + BalanceDelta: 2e6, + Fees: 700, + PartOfActiveBalance: true, + }, + }, + note: &core.OrderNote{ + Order: &core.Order{ + Host: host, + BaseID: 60, + QuoteID: 966001, + Sell: false, + Status: order.OrderStatusExecuted, + ID: orderIDs[1][:], + Matches: []*core.Match{ + { + Swap: &core.Coin{ + ID: walletTxIDs[1][:], + }, + Refund: &core.Coin{ + ID: walletTxIDs[3][:], + }, + }, + { + Swap: &core.Coin{ + ID: walletTxIDs[4][:], + }, + Redeem: &core.Coin{ + ID: walletTxIDs[5][:], + }, + }, + }, + }, + }, + balances: map[uint32]*botBalance{ + 966001: { + Available: 1e8 - (calc.BaseToQuote(5e7, 2e6) + calc.BaseToQuote(6e7, 2e6)), + }, + 966: { + Available: 1e8 - 3900, + }, + 60: { + Available: 1e8 + 4e6 - 1500, + }, + }, + }, + }, + }, + } + + mkt := &core.Market{ + LotSize: 1e6, + } + + runTest := func(test *test) { + tCore := newTCore() + tCore.multiTradeResult = test.multiTradeResponse + tCore.market = mkt + tCore.isDynamicSwapper = test.isDynamicSwapper + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + botID := dexMarketID(test.multiTrade.Host, test.multiTrade.Base, test.multiTrade.Quote) + adaptor := unifiedExchangeAdaptorForBot(botID, test.balances, nil, tCore, nil, tLogger) + adaptor.run(ctx) + _, err := adaptor.MultiTrade([]byte{}, test.multiTrade) + if test.wantErr { + if err == nil { + t.Fatalf("%s: expected error but did not get", test.name) + } + return + } + if err != nil { + t.Fatalf("%s: unexpected error: %v", test.name, err) + } + + checkBalances := func(expected map[uint32]*botBalance, updateNum int) { + t.Helper() + for assetID, expectedBal := range expected { + bal, err := adaptor.AssetBalance(assetID) + if err != nil { + t.Fatalf("%s: unexpected error: %v", test.name, err) + } + if *bal != *expectedBal { + var updateStr string + if updateNum <= 0 { + updateStr = "post trade" + } else { + updateStr = fmt.Sprintf("after update #%d", updateNum) + } + t.Fatalf("%s: unexpected asset %d balance %s. want %+v, got %+v", + test.name, assetID, updateStr, expectedBal, bal) + } + } + } + + checkBalances(test.postTradeBalances, 0) + + for i, update := range test.updatesAndBalances { + for _, txUpdate := range update.walletTxUpdates { + tCore.walletTxs[hex.EncodeToString(txUpdate.ID)] = txUpdate + } + tCore.noteFeed <- update.note + tCore.noteFeed <- &core.BondPostNote{} // dummy note + checkBalances(update.balances, i+1) + } + } + + for _, test := range tests { + runTest(test) + } +} + +func TestExchangeAdaptorDeposit(t *testing.T) { + type test struct { + name string + isWithdrawer bool + isDynamicSwapper bool + depositAmt uint64 + sendCoin *tCoin + unconfirmedTx *asset.WalletTransaction + confirmedTx *asset.WalletTransaction + receivedAmt uint64 + initialDEXBalance uint64 + initialCEXBalance uint64 + assetID uint32 + + preConfirmDEXBalance *botBalance + preConfirmCEXBalance *botBalance + postConfirmDEXBalance *botBalance + postConfirmCEXBalance *botBalance + } + + id := encode.RandomBytes(32) + + tests := []test{ + { + name: "withdrawer, not dynamic swapper", + assetID: 42, + isWithdrawer: true, + depositAmt: 1e6, + sendCoin: &tCoin{ + txID: id, + value: 1e6 - 2000, + }, + unconfirmedTx: &asset.WalletTransaction{ + ID: id, + BalanceDelta: -(1e6 - 2000), + Fees: 2000, + PartOfActiveBalance: false, + }, + confirmedTx: &asset.WalletTransaction{ + ID: id, + BalanceDelta: -(1e6 - 2000), + Fees: 2000, + PartOfActiveBalance: true, + }, + receivedAmt: 1e6 - 2000, + initialDEXBalance: 3e6, + initialCEXBalance: 1e6, + preConfirmDEXBalance: &botBalance{ + Available: 2e6, + }, + preConfirmCEXBalance: &botBalance{ + Available: 1e6, + Pending: 1e6 - 2000, + }, + postConfirmDEXBalance: &botBalance{ + Available: 2e6, + }, + postConfirmCEXBalance: &botBalance{ + Available: 2e6 - 2000, + }, + }, + { + name: "not withdrawer, not dynamic swapper", + assetID: 42, + depositAmt: 1e6, + sendCoin: &tCoin{ + txID: id, + value: 1e6, + }, + unconfirmedTx: &asset.WalletTransaction{ + ID: id, + BalanceDelta: -1e6, + Fees: 2000, + PartOfActiveBalance: false, + }, + confirmedTx: &asset.WalletTransaction{ + ID: id, + BalanceDelta: -1e6, + Fees: 2000, + PartOfActiveBalance: true, + }, + receivedAmt: 1e6, + initialDEXBalance: 3e6, + initialCEXBalance: 1e6, + preConfirmDEXBalance: &botBalance{ + Available: 2e6 - 2000, + }, + preConfirmCEXBalance: &botBalance{ + Available: 1e6, + Pending: 1e6, + }, + postConfirmDEXBalance: &botBalance{ + Available: 2e6 - 2000, + }, + postConfirmCEXBalance: &botBalance{ + Available: 2e6, + }, + }, + { + name: "not withdrawer, dynamic swapper", + assetID: 42, + isDynamicSwapper: true, + depositAmt: 1e6, + sendCoin: &tCoin{ + txID: id, + value: 1e6, + }, + unconfirmedTx: &asset.WalletTransaction{ + ID: id, + BalanceDelta: -1e6, + Fees: 4000, + PartOfActiveBalance: false, + }, + confirmedTx: &asset.WalletTransaction{ + ID: id, + BalanceDelta: -1e6, + Fees: 2000, + PartOfActiveBalance: true, + }, + receivedAmt: 1e6, + initialDEXBalance: 3e6, + initialCEXBalance: 1e6, + preConfirmDEXBalance: &botBalance{ + Available: 2e6 - 4000, + }, + preConfirmCEXBalance: &botBalance{ + Available: 1e6, + Pending: 1e6, + }, + postConfirmDEXBalance: &botBalance{ + Available: 2e6 - 2000, + }, + postConfirmCEXBalance: &botBalance{ + Available: 2e6, + }, + }, + { + name: "not withdrawer, dynamic swapper, token", + assetID: 966001, + isDynamicSwapper: true, + depositAmt: 1e6, + sendCoin: &tCoin{ + txID: id, + value: 1e6, + }, + unconfirmedTx: &asset.WalletTransaction{ + ID: id, + BalanceDelta: -1e6, + Fees: 4000, + PartOfActiveBalance: false, + }, + confirmedTx: &asset.WalletTransaction{ + ID: id, + BalanceDelta: -1e6, + Fees: 2000, + PartOfActiveBalance: true, + }, + receivedAmt: 1e6, + initialDEXBalance: 3e6, + initialCEXBalance: 1e6, + preConfirmDEXBalance: &botBalance{ + Available: 2e6, + }, + preConfirmCEXBalance: &botBalance{ + Available: 1e6, + Pending: 1e6, + }, + postConfirmDEXBalance: &botBalance{ + Available: 2e6, + }, + postConfirmCEXBalance: &botBalance{ + Available: 2e6, + }, + }, + } + + runTest := func(test *test) { + tCore := newTCore() + tCore.isWithdrawer[test.assetID] = test.isWithdrawer + tCore.isDynamicSwapper[test.assetID] = test.isDynamicSwapper + tCore.setAssetBalances(map[uint32]uint64{test.assetID: test.initialDEXBalance, 0: 2e6, 966: 2e6}) + tCore.walletTxs[hex.EncodeToString(test.unconfirmedTx.ID)] = test.unconfirmedTx + tCore.sendCoin = test.sendCoin + + tCEX := newTCEX() + tCEX.balances[test.assetID] = &libxc.ExchangeBalance{ + Available: test.initialCEXBalance, + } + tCEX.balances[0] = &libxc.ExchangeBalance{ + Available: 2e6, + } + tCEX.balances[966] = &libxc.ExchangeBalance{ + Available: 1e8, + } + + dexBalances := map[uint32]uint64{ + test.assetID: test.initialDEXBalance, + 0: 2e6, + 966: 2e6, + } + cexBalances := map[uint32]uint64{ + 0: 2e6, + 966: 1e8, + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + botID := dexMarketID("host1", test.assetID, 0) + adaptor := unifiedExchangeAdaptorForBot(botID, dexBalances, cexBalances, tCore, tCEX, tLogger) + adaptor.run(ctx) + + err := adaptor.Deposit(ctx, test.assetID, test.depositAmt, func() {}) + if err != nil { + t.Fatalf("%s: unexpected error: %v", test.name, err) + } + + preConfirmBal, err := adaptor.AssetBalance(test.assetID) + if err != nil { + t.Fatalf("%s: unexpected error: %v", test.name, err) + } + if *preConfirmBal != *test.preConfirmDEXBalance { + t.Fatalf("%s: unexpected pre confirm dex balance. want %d, got %d", test.name, test.preConfirmDEXBalance, preConfirmBal.Available) + } + + if test.assetID == 966001 { + preConfirmParentBal, err := adaptor.AssetBalance(966) + if err != nil { + t.Fatalf("%s: unexpected error: %v", test.name, err) + } + if preConfirmParentBal.Available != 2e6-test.unconfirmedTx.Fees { + t.Fatalf("%s: unexpected pre confirm dex balance. want %d, got %d", test.name, test.preConfirmDEXBalance, preConfirmBal.Available) + } + } + + tCore.walletTxs[hex.EncodeToString(test.unconfirmedTx.ID)] = test.confirmedTx + tCEX.confirmDeposit <- test.receivedAmt + <-tCEX.confirmDepositComplete + + if test.isDynamicSwapper { + tCore.confirmWalletTx <- true + <-tCore.confirmWalletTxComplete + } + + postConfirmBal, err := adaptor.AssetBalance(test.assetID) + if err != nil { + t.Fatalf("%s: unexpected error: %v", test.name, err) + } + if *postConfirmBal != *test.postConfirmDEXBalance { + t.Fatalf("%s: unexpected post confirm dex balance. want %d, got %d", test.name, test.postConfirmDEXBalance, postConfirmBal) + } + + if test.assetID == 966001 { + postConfirmParentBal, err := adaptor.AssetBalance(966) + if err != nil { + t.Fatalf("%s: unexpected error: %v", test.name, err) + } + if postConfirmParentBal.Available != 2e6-test.confirmedTx.Fees { + t.Fatalf("%s: unexpected pre confirm dex balance. want %d, got %d", test.name, test.preConfirmDEXBalance, preConfirmBal.Available) + } + } + } + + for _, test := range tests { + runTest(&test) + } +} + +func TestExchangeAdaptorWithdraw(t *testing.T) { + assetID := uint32(42) + id := encode.RandomBytes(32) + + type test struct { + name string + withdrawAmt uint64 + tx *asset.WalletTransaction + initialDEXBalance uint64 + initialCEXBalance uint64 + + preConfirmDEXBalance *botBalance + preConfirmCEXBalance *botBalance + postConfirmDEXBalance *botBalance + postConfirmCEXBalance *botBalance + } + + tests := []test{ + { + name: "ok", + withdrawAmt: 1e6, + tx: &asset.WalletTransaction{ + ID: id, + BalanceDelta: 1e6 - 2000, + Fees: 2000, + PartOfActiveBalance: true, + }, + initialCEXBalance: 3e6, + initialDEXBalance: 1e6, + preConfirmDEXBalance: &botBalance{ + Available: 1e6, + Pending: 1e6, + }, + preConfirmCEXBalance: &botBalance{ + Available: 2e6, + }, + postConfirmDEXBalance: &botBalance{ + Available: 2e6 - 2000, + }, + postConfirmCEXBalance: &botBalance{ + Available: 2e6, + }, + }, + } + + runTest := func(test *test) { + tCore := newTCore() + tCore.walletTxs[hex.EncodeToString(test.tx.ID)] = test.tx + + tCEX := newTCEX() + + dexBalances := map[uint32]uint64{ + assetID: test.initialDEXBalance, + 0: 2e6, + } + cexBalances := map[uint32]uint64{ + assetID: test.initialCEXBalance, + 966: 1e8, + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + botID := dexMarketID("host1", assetID, 0) + adaptor := unifiedExchangeAdaptorForBot(botID, dexBalances, cexBalances, tCore, tCEX, tLogger) + adaptor.run(ctx) + + err := adaptor.Withdraw(ctx, assetID, test.withdrawAmt, func() {}) + if err != nil { + t.Fatalf("%s: unexpected error: %v", test.name, err) + } + + preConfirmBal, err := adaptor.AssetBalance(assetID) + if err != nil { + t.Fatalf("%s: unexpected error: %v", test.name, err) + } + if *preConfirmBal != *test.preConfirmDEXBalance { + t.Fatalf("%s: unexpected pre confirm dex balance. want %+v, got %+v", test.name, test.preConfirmDEXBalance, preConfirmBal) + } + + tCEX.confirmWithdrawal <- &withdrawArgs{ + assetID: assetID, + amt: test.withdrawAmt, + txID: hex.EncodeToString(test.tx.ID), + } + + <-tCEX.confirmWithdrawalComplete + + postConfirmBal, err := adaptor.AssetBalance(assetID) + if err != nil { + t.Fatalf("%s: unexpected error: %v", test.name, err) + } + if *postConfirmBal != *test.postConfirmDEXBalance { + t.Fatalf("%s: unexpected post confirm dex balance. want %+v, got %+v", test.name, test.postConfirmDEXBalance, postConfirmBal) + } + } + + for _, test := range tests { + runTest(&test) + } +} + +func TestExchangeAdaptorTrade(t *testing.T) { + baseID := uint32(42) + quoteID := uint32(0) + tradeID := "123" + + type updateAndBalance struct { + update *libxc.Trade + balances map[uint32]*botBalance + } + + type test struct { + name string + sell bool + rate uint64 + qty uint64 + balances map[uint32]uint64 + + wantErr bool + postTradeBalances map[uint32]*botBalance + updatesAndBalances []*updateAndBalance + } + + tests := []*test{ + { + name: "fully filled sell", + sell: true, + rate: 5e7, + qty: 5e6, + balances: map[uint32]uint64{ + 42: 1e7, + 0: 1e7, + }, + postTradeBalances: map[uint32]*botBalance{ + 42: { + Available: 5e6, + Locked: 5e6, + }, + 0: { + Available: 1e7, + }, + }, + updatesAndBalances: []*updateAndBalance{ + { + update: &libxc.Trade{ + ID: tradeID, + Sell: true, + BaseID: baseID, + QuoteID: quoteID, + Rate: 5e7, + Qty: 5e6, + BaseFilled: 3e6, + QuoteFilled: 1.6e6, + }, + balances: map[uint32]*botBalance{ + 42: { + Available: 5e6, + Locked: 5e6 - 3e6, + }, + 0: { + Available: 1e7 + 1.6e6, + }, + }, + }, + { + update: &libxc.Trade{ + ID: tradeID, + Sell: true, + BaseID: baseID, + QuoteID: quoteID, + Rate: 5e7, + Qty: 5e6, + BaseFilled: 5e6, + QuoteFilled: 2.8e6, + Complete: true, + }, + balances: map[uint32]*botBalance{ + 42: { + Available: 5e6, + }, + 0: { + Available: 1e7 + 2.8e6, + }, + }, + }, + { + update: &libxc.Trade{ + ID: tradeID, + Sell: true, + BaseID: baseID, + QuoteID: quoteID, + Rate: 5e7, + Qty: 5e6, + BaseFilled: 5e6, + QuoteFilled: 2.8e6, + Complete: true, + }, + balances: map[uint32]*botBalance{ + 42: { + Available: 5e6, + }, + 0: { + Available: 1e7 + 2.8e6, + }, + }, + }, + }, + }, + { + name: "partially filled sell", + sell: true, + rate: 5e7, + qty: 5e6, + balances: map[uint32]uint64{ + 42: 1e7, + 0: 1e7, + }, + postTradeBalances: map[uint32]*botBalance{ + 42: { + Available: 5e6, + Locked: 5e6, + }, + 0: { + Available: 1e7, + }, + }, + updatesAndBalances: []*updateAndBalance{ + { + update: &libxc.Trade{ + ID: tradeID, + Sell: true, + BaseID: baseID, + QuoteID: quoteID, + Rate: 5e7, + Qty: 5e6, + BaseFilled: 3e6, + QuoteFilled: 1.6e6, + Complete: true, + }, + balances: map[uint32]*botBalance{ + 42: { + Available: 7e6, + }, + 0: { + Available: 1e7 + 1.6e6, + }, + }, + }, + }, + }, + { + name: "fully filled buy", + sell: false, + rate: 5e7, + qty: 5e6, + balances: map[uint32]uint64{ + 42: 1e7, + 0: 1e7, + }, + postTradeBalances: map[uint32]*botBalance{ + 42: { + Available: 1e7, + }, + 0: { + Available: 1e7 - calc.BaseToQuote(5e7, 5e6), + Locked: calc.BaseToQuote(5e7, 5e6), + }, + }, + updatesAndBalances: []*updateAndBalance{ + { + update: &libxc.Trade{ + ID: tradeID, + Sell: false, + BaseID: baseID, + QuoteID: quoteID, + Rate: 5e7, + Qty: 5e6, + BaseFilled: 3e6, + QuoteFilled: 1.6e6, + }, + balances: map[uint32]*botBalance{ + 42: { + Available: 1e7 + 3e6, + }, + 0: { + Available: 1e7 - calc.BaseToQuote(5e7, 5e6), + Locked: calc.BaseToQuote(5e7, 5e6) - 1.6e6, + }, + }, + }, + { + update: &libxc.Trade{ + ID: tradeID, + Sell: false, + BaseID: baseID, + QuoteID: quoteID, + Rate: 5e7, + Qty: 5e6, + BaseFilled: 5.1e6, + QuoteFilled: calc.BaseToQuote(5e7, 5e6), + Complete: true, + }, + balances: map[uint32]*botBalance{ + 42: { + Available: 1e7 + 5.1e6, + }, + 0: { + Available: 1e7 - calc.BaseToQuote(5e7, 5e6), + }, + }, + }, + { + update: &libxc.Trade{ + ID: tradeID, + Sell: false, + BaseID: baseID, + QuoteID: quoteID, + Rate: 5e7, + Qty: 5e6, + BaseFilled: 5.1e6, + QuoteFilled: calc.BaseToQuote(5e7, 5e6), + Complete: true, + }, + balances: map[uint32]*botBalance{ + 42: { + Available: 1e7 + 5.1e6, + }, + 0: { + Available: 1e7 - calc.BaseToQuote(5e7, 5e6), + }, + }, + }, + }, + }, + { + name: "partially filled buy", + sell: false, + rate: 5e7, + qty: 5e6, + balances: map[uint32]uint64{ + 42: 1e7, + 0: 1e7, + }, + postTradeBalances: map[uint32]*botBalance{ + 42: { + Available: 1e7, + }, + 0: { + Available: 1e7 - calc.BaseToQuote(5e7, 5e6), + Locked: calc.BaseToQuote(5e7, 5e6), + }, + }, + updatesAndBalances: []*updateAndBalance{ + { + update: &libxc.Trade{ + ID: tradeID, + Sell: false, + BaseID: baseID, + QuoteID: quoteID, + Rate: 5e7, + Qty: 5e6, + BaseFilled: 3e6, + QuoteFilled: 1.6e6, + Complete: true, + }, + balances: map[uint32]*botBalance{ + 42: { + Available: 1e7 + 3e6, + }, + 0: { + Available: 1e7 - 1.6e6, + }, + }, + }, + }, + }, + } + + botCfg := &BotConfig{ + Host: "host1", + BaseAsset: baseID, + QuoteAsset: quoteID, + BaseBalanceType: Percentage, + BaseBalance: 100, + QuoteBalanceType: Percentage, + QuoteBalance: 100, + CEXCfg: &BotCEXCfg{ + Name: "Binance", + BaseBalanceType: Percentage, + BaseBalance: 100, + QuoteBalanceType: Percentage, + QuoteBalance: 100, + }, + } + + runTest := func(test *test) { + tCore := newTCore() + tCEX := newTCEX() + tCEX.tradeID = tradeID + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + botID := dexMarketID(botCfg.Host, botCfg.BaseAsset, botCfg.QuoteAsset) + adaptor := unifiedExchangeAdaptorForBot(botID, test.balances, test.balances, tCore, tCEX, tLogger) + adaptor.run(ctx) + + adaptor.SubscribeTradeUpdates() + + _, err := adaptor.Trade(ctx, baseID, quoteID, test.sell, test.rate, test.qty) + if test.wantErr { + if err == nil { + t.Fatalf("%s: expected error but did not get", test.name) + } + return + } + if err != nil { + t.Fatalf("%s: unexpected error: %v", test.name, err) + } + + checkBalances := func(expected map[uint32]*botBalance, i int) { + t.Helper() + for assetID, expectedBal := range expected { + bal, err := adaptor.Balance(assetID) + if err != nil { + t.Fatalf("%s: unexpected error: %v", test.name, err) + } + if *bal != *expectedBal { + step := "post trade" + if i > 0 { + step = fmt.Sprintf("after update #%d", i) + } + t.Fatalf("%s: unexpected cex balance %s for asset %d. want %+v, got %+v", + test.name, step, assetID, expectedBal, bal) + } + } + } + + checkBalances(test.postTradeBalances, 0) + + for i, updateAndBalance := range test.updatesAndBalances { + tCEX.tradeUpdates <- updateAndBalance.update + tCEX.tradeUpdates <- &libxc.Trade{} // dummy update + checkBalances(updateAndBalance.balances, i+1) + } + } + + for _, test := range tests { + runTest(test) + } +} diff --git a/client/mm/libxc/binance.go b/client/mm/libxc/binance.go index c64d36d7ad..f676825e30 100644 --- a/client/mm/libxc/binance.go +++ b/client/mm/libxc/binance.go @@ -349,6 +349,7 @@ type tradeInfo struct { updaterID int baseID uint32 quoteID uint32 + sell bool } type binance struct { @@ -379,7 +380,7 @@ type binance struct { tradeUpdaterMtx sync.RWMutex tradeInfo map[string]*tradeInfo - tradeUpdaters map[int]chan *TradeUpdate + tradeUpdaters map[int]chan *Trade tradeUpdateCounter int cexUpdatersMtx sync.RWMutex @@ -414,7 +415,7 @@ func newBinance(apiKey, secretKey string, log dex.Logger, net dex.Network, binan books: make(map[string]*binanceOrderBook), net: net, tradeInfo: make(map[string]*tradeInfo), - tradeUpdaters: make(map[int]chan *TradeUpdate), + tradeUpdaters: make(map[int]chan *Trade), cexUpdaters: make(map[chan interface{}]struct{}, 0), tradeIDNoncePrefix: encode.RandomBytes(10), } @@ -425,17 +426,43 @@ func newBinance(apiKey, secretKey string, log dex.Logger, net dex.Network, binan return bnc } -// setBalances stores the balances of the user. -func (bnc *binance) setBalances(coinsData []*binanceCoinInfo) { +// setBalances queries binance for the user's balances and stores them in the +// balances map. +func (bnc *binance) setBalances(ctx context.Context) error { + var resp struct { + Balances []struct { + Asset string `json:"asset"` + Free float64 `json:"free,string"` + Locked float64 `json:"locked,string"` + } `json:"balances"` + } + err := bnc.getAPI(ctx, "/api/v3/account", nil, true, true, &resp) + if err != nil { + return err + } + bnc.balanceMtx.Lock() defer bnc.balanceMtx.Unlock() - for _, nfo := range coinsData { - bnc.balances[nfo.Coin] = &bncBalance{ - available: nfo.Free, - locked: nfo.Locked, + for _, bal := range resp.Balances { + updatedBalance := &bncBalance{ + available: bal.Free, + locked: bal.Locked, + } + + currBalance, found := bnc.balances[bal.Asset] + if found && *currBalance != *updatedBalance { + // This function is only called when the CEX is started up, and + // once every 10 minutes. The balance should be updated by the user + // data stream, so if it is updated here, it could mean there is an + // issue. + bnc.log.Warnf("%v balance was out of sync. Updating. %+v -> %+v", bal.Asset, currBalance, updatedBalance) } + + bnc.balances[bal.Asset] = updatedBalance } + + return nil } // setTokenIDs stores the token IDs for which deposits and withdrawals are @@ -486,9 +513,7 @@ func (bnc *binance) getCoinInfo(ctx context.Context) error { return fmt.Errorf("error getting binance coin info: %w", err) } - bnc.setBalances(coins) bnc.setTokenIDs(coins) - return nil } @@ -531,10 +556,34 @@ func (bnc *binance) Connect(ctx context.Context) (*sync.WaitGroup, error) { return nil, fmt.Errorf("error getting markets: %v", err) } + if err := bnc.setBalances(ctx); err != nil { + return nil, err + } + if err := bnc.getUserDataStream(ctx); err != nil { return nil, err } + // Refresh balances periodically. This is just for safety as they should + // be updated based on the user data stream. + wg.Add(1) + go func() { + defer wg.Done() + ticker := time.NewTicker(time.Minute) + defer ticker.Stop() + for { + select { + case <-ticker.C: + err := bnc.setBalances(ctx) + if err != nil { + bnc.log.Errorf("Error fetching balances: %v", err) + } + case <-ctx.Done(): + return + } + } + }() + // Refresh the markets periodically. wg.Add(1) go func() { @@ -628,7 +677,7 @@ func (bnc *binance) generateTradeID() string { // Trade executes a trade on the CEX. subscriptionID takes an ID returned from // SubscribeTradeUpdates. -func (bnc *binance) Trade(ctx context.Context, baseID, quoteID uint32, sell bool, rate, qty uint64, subscriptionID int) (string, error) { +func (bnc *binance) Trade(ctx context.Context, baseID, quoteID uint32, sell bool, rate, qty uint64, subscriptionID int) (*Trade, error) { side := "BUY" if sell { side = "SELL" @@ -636,12 +685,12 @@ func (bnc *binance) Trade(ctx context.Context, baseID, quoteID uint32, sell bool baseCfg, err := bncAssetCfg(baseID) if err != nil { - return "", fmt.Errorf("error getting asset cfg for %d: %w", baseID, err) + return nil, fmt.Errorf("error getting asset cfg for %d: %w", baseID, err) } quoteCfg, err := bncAssetCfg(quoteID) if err != nil { - return "", fmt.Errorf("error getting asset cfg for %d: %w", quoteID, err) + return nil, fmt.Errorf("error getting asset cfg for %d: %w", quoteID, err) } slug := baseCfg.coin + quoteCfg.coin @@ -649,7 +698,7 @@ func (bnc *binance) Trade(ctx context.Context, baseID, quoteID uint32, sell bool marketsMap := bnc.markets.Load().(map[string]*binanceMarket) market, found := marketsMap[slug] if !found { - return "", fmt.Errorf("market not found: %v", slug) + return nil, fmt.Errorf("market not found: %v", slug) } price := calc.ConventionalRateAlt(rate, baseCfg.conversionFactor, quoteCfg.conversionFactor) @@ -665,28 +714,54 @@ func (bnc *binance) Trade(ctx context.Context, baseID, quoteID uint32, sell bool v.Add("quantity", strconv.FormatFloat(amt, 'f', market.BaseAssetPrecision, 64)) v.Add("price", strconv.FormatFloat(price, 'f', market.QuoteAssetPrecision, 64)) - bnc.tradeUpdaterMtx.RLock() + bnc.tradeUpdaterMtx.Lock() _, found = bnc.tradeUpdaters[subscriptionID] if !found { - bnc.tradeUpdaterMtx.RUnlock() - return "", fmt.Errorf("no trade updater with ID %v", subscriptionID) - } - bnc.tradeUpdaterMtx.RUnlock() - - err = bnc.postAPI(ctx, "/api/v3/order", v, nil, true, true, &struct{}{}) - if err != nil { - return "", err + bnc.tradeUpdaterMtx.Unlock() + return nil, fmt.Errorf("no trade updater with ID %v", subscriptionID) } - - bnc.tradeUpdaterMtx.Lock() - defer bnc.tradeUpdaterMtx.Unlock() bnc.tradeInfo[tradeID] = &tradeInfo{ updaterID: subscriptionID, baseID: baseID, quoteID: quoteID, + sell: sell, } + bnc.tradeUpdaterMtx.Unlock() - return tradeID, err + var success bool + defer func() { + if !success { + bnc.tradeUpdaterMtx.Lock() + delete(bnc.tradeInfo, tradeID) + bnc.tradeUpdaterMtx.Unlock() + } + }() + + var orderResponse struct { + Symbol string `json:"symbol"` + Price float64 `json:"price,string"` + OrigQty float64 `json:"origQty,string"` + OrigQuoteQty float64 `json:"origQuoteOrderQty,string"` + ExecutedQty float64 `json:"executedQty,string"` + CumulativeQuoteQty float64 `json:"cummulativeQuoteQty,string"` + } + err = bnc.postAPI(ctx, "/api/v3/order", v, nil, true, true, &orderResponse) + if err != nil { + return nil, err + } + + success = true + + return &Trade{ + ID: tradeID, + Sell: sell, + Rate: rate, + Qty: qty, + BaseID: baseID, + QuoteID: quoteID, + BaseFilled: uint64(orderResponse.ExecutedQty * float64(baseCfg.conversionFactor)), + QuoteFilled: uint64(orderResponse.CumulativeQuoteQty * float64(quoteCfg.conversionFactor)), + }, err } func (bnc *binance) assetPrecision(coin string) (int, error) { @@ -890,12 +965,12 @@ func (bnc *binance) ConfirmDeposit(ctx context.Context, txID string, onConfirm f // returned from this function is passed as the updaterID argument to // Trade, then updates to the trade will be sent on the updated channel // returned from this function. -func (bnc *binance) SubscribeTradeUpdates() (<-chan *TradeUpdate, func(), int) { +func (bnc *binance) SubscribeTradeUpdates() (<-chan *Trade, func(), int) { bnc.tradeUpdaterMtx.Lock() defer bnc.tradeUpdaterMtx.Unlock() updaterID := bnc.tradeUpdateCounter bnc.tradeUpdateCounter++ - updater := make(chan *TradeUpdate, 256) + updater := make(chan *Trade, 256) bnc.tradeUpdaters[updaterID] = updater unsubscribe := func() { @@ -1052,24 +1127,25 @@ func (bnc *binance) sendCexUpdateNotes() { } func (bnc *binance) handleOutboundAccountPosition(update *binanceStreamUpdate) { - bnc.log.Tracef("Received outboundAccountPosition: %+v", update) + bnc.log.Debugf("Received outboundAccountPosition: %+v", update) for _, bal := range update.Balances { - bnc.log.Tracef("balance: %+v", bal) + bnc.log.Debugf("outboundAccountPosition balance: %+v", bal) } bnc.balanceMtx.Lock() for _, bal := range update.Balances { - symbol := strings.ToLower(bal.Asset) + symbol := strings.ToUpper(bal.Asset) bnc.balances[symbol] = &bncBalance{ available: bal.Free, locked: bal.Locked, } } bnc.balanceMtx.Unlock() + bnc.sendCexUpdateNotes() } -func (bnc *binance) getTradeUpdater(tradeID string) (chan *TradeUpdate, *tradeInfo, error) { +func (bnc *binance) getTradeUpdater(tradeID string) (chan *Trade, *tradeInfo, error) { bnc.tradeUpdaterMtx.RLock() defer bnc.tradeUpdaterMtx.RUnlock() @@ -1092,7 +1168,7 @@ func (bnc *binance) removeTradeUpdater(tradeID string) { } func (bnc *binance) handleExecutionReport(update *binanceStreamUpdate) { - bnc.log.Tracef("Received executionReport: %+v", update) + bnc.log.Debugf("Received executionReport: %+v", update) status := update.CurrentOrderStatus var id string @@ -1108,7 +1184,7 @@ func (bnc *binance) handleExecutionReport(update *binanceStreamUpdate) { return } - complete := status == "FILLED" || status == "CANCELED" || status == "REJECTED" || status == "EXPIRED" + complete := status != "NEW" && status != "PARTIALLY_FILLED" baseCfg, err := bncAssetCfg(tradeInfo.baseID) if err != nil { @@ -1122,11 +1198,14 @@ func (bnc *binance) handleExecutionReport(update *binanceStreamUpdate) { return } - updater <- &TradeUpdate{ - TradeID: id, + updater <- &Trade{ + ID: id, Complete: complete, BaseFilled: uint64(update.Filled * float64(baseCfg.conversionFactor)), QuoteFilled: uint64(update.QuoteFilled * float64(quoteCfg.conversionFactor)), + BaseID: tradeInfo.baseID, + QuoteID: tradeInfo.quoteID, + Sell: tradeInfo.sell, } if complete { @@ -1557,6 +1636,44 @@ func (bnc *binance) VWAP(baseID, quoteID uint32, sell bool, qty uint64) (avgPric return book.vwap(!sell, qty) } +func (bnc *binance) TradeStatus(ctx context.Context, id string, baseID, quoteID uint32) { + var resp struct { + Symbol string `json:"symbol"` + OrderID int64 `json:"orderId"` + ClientOrderID string `json:"clientOrderId"` + Price string `json:"price"` + OrigQty string `json:"origQty"` + ExecutedQty string `json:"executedQty"` + CumulativeQuoteQty string `json:"cumulativeQuoteQty"` + Status string `json:"status"` + TimeInForce string `json:"timeInForce"` + } + + baseAsset, err := bncAssetCfg(baseID) + if err != nil { + bnc.log.Errorf("Error getting asset cfg for %d: %v", baseID, err) + return + } + + quoteAsset, err := bncAssetCfg(quoteID) + if err != nil { + bnc.log.Errorf("Error getting asset cfg for %d: %v", quoteID, err) + return + } + + v := make(url.Values) + v.Add("symbol", baseAsset.coin+quoteAsset.coin) + v.Add("origClientOrderId", id) + + err = bnc.getAPI(ctx, "/api/v3/order", v, true, true, &resp) + if err != nil { + bnc.log.Errorf("Error getting trade status: %v", err) + return + } + + bnc.log.Infof("Trade status: %+v", resp) +} + // dexMarkets returns all the possible dex markets for this binance market. // A symbol represents a single market on the CEX, but tokens on the DEX // have a different assetID for each network they are on, therefore they will diff --git a/client/mm/libxc/binance_live_test.go b/client/mm/libxc/binance_live_test.go index d73008e991..b6e772dd6d 100644 --- a/client/mm/libxc/binance_live_test.go +++ b/client/mm/libxc/binance_live_test.go @@ -99,7 +99,7 @@ func TestConnect(t *testing.T) { // This may fail due to balance being to low. You can try switching the side // of the trade or the qty. func TestTrade(t *testing.T) { - bnc := tNewBinance(t, dex.Mainnet) + bnc := tNewBinance(t, dex.Testnet) ctx, cancel := context.WithTimeout(context.Background(), time.Hour*23) defer cancel() _, err := bnc.Connect(ctx) @@ -111,6 +111,7 @@ func TestTrade(t *testing.T) { wg.Add(1) updates, unsubscribe, updaterID := bnc.SubscribeTradeUpdates() defer unsubscribe() + go func() { defer wg.Done() for { @@ -129,14 +130,14 @@ func TestTrade(t *testing.T) { } } }() - tradeID, err := bnc.Trade(ctx, 60, 0, false, 6000e2, 1e7, updaterID) + trade, err := bnc.Trade(ctx, 60, 0, false, 5600e2, 1e7, updaterID) if err != nil { t.Fatalf("trade error: %v", err) } - if true { // Cancel the trade + if false { // Cancel the trade time.Sleep(1 * time.Second) - err = bnc.CancelTrade(ctx, 60, 0, tradeID) + err = bnc.CancelTrade(ctx, 60, 0, trade.ID) if err != nil { t.Fatalf("error cancelling trade: %v", err) } @@ -314,7 +315,7 @@ func TestGetDepositAddress(t *testing.T) { } func TestBalances(t *testing.T) { - bnc := tNewBinance(t, dex.Mainnet) + bnc := tNewBinance(t, dex.Testnet) ctx, cancel := context.WithTimeout(context.Background(), time.Hour*23) defer cancel() @@ -323,7 +324,7 @@ func TestBalances(t *testing.T) { t.Fatalf("Connect error: %v", err) } - balance, err := bnc.Balance(966) + balance, err := bnc.Balance(0) if err != nil { t.Fatalf("balances error: %v", err) } @@ -342,6 +343,14 @@ func TestGetCoinInfo(t *testing.T) { t.Fatalf("error getting binance coin info: %v", err) } + bcoins, err := json.MarshalIndent(coins, "", " ") + if err != nil { + t.Fatalf("error marshaling binance coin info: %v", err) + } + + t.Logf("binance coin info:\n %v", string(bcoins)) + return + for _, c := range coins { if c.Coin == "USDC" { b, _ := json.MarshalIndent(c, "", " ") @@ -358,3 +367,16 @@ func TestGetCoinInfo(t *testing.T) { fmt.Printf("%q networks: %+v \n", c.Coin, networks) } } + +func TestTradeStatus(t *testing.T) { + bnc := tNewBinance(t, dex.Testnet) + ctx, cancel := context.WithTimeout(context.Background(), time.Hour*23) + defer cancel() + + _, err := bnc.Connect(ctx) + if err != nil { + t.Fatalf("Connect error: %v", err) + } + + bnc.TradeStatus(ctx, "6a570ce0ad7531f9101300000003", 60, 0) +} diff --git a/client/mm/libxc/binance_test.go b/client/mm/libxc/binance_test.go index ddefca8cc9..2814983c0a 100644 --- a/client/mm/libxc/binance_test.go +++ b/client/mm/libxc/binance_test.go @@ -22,7 +22,7 @@ func TestSubscribeCEXUpdates(t *testing.T) { func TestSubscribeTradeUpdates(t *testing.T) { bn := &binance{ - tradeUpdaters: make(map[int]chan *TradeUpdate), + tradeUpdaters: make(map[int]chan *Trade), } _, unsub0, _ := bn.SubscribeTradeUpdates() _, _, id1 := bn.SubscribeTradeUpdates() diff --git a/client/mm/libxc/interface.go b/client/mm/libxc/interface.go index 0faebfa215..0f677fb5c0 100644 --- a/client/mm/libxc/interface.go +++ b/client/mm/libxc/interface.go @@ -14,13 +14,17 @@ type ExchangeBalance struct { Locked uint64 `json:"locked"` } -// TradeUpdate is a notification sent when the status of a trade on the CEX -// has been updated. -type TradeUpdate struct { - TradeID string - Complete bool // cancelled or filled +// Trade represents a trade made on a CEX. +type Trade struct { + ID string + Sell bool + Qty uint64 + Rate uint64 + BaseID uint32 + QuoteID uint32 BaseFilled uint64 QuoteFilled uint64 + Complete bool // cancelled or filled } // Market is the base and quote assets of a market on a CEX. @@ -52,10 +56,10 @@ type CEX interface { // returned from this function is passed as the updaterID argument to // Trade, then updates to the trade will be sent on the updated channel // returned from this function. - SubscribeTradeUpdates() (updates <-chan *TradeUpdate, unsubscribe func(), subscriptionID int) + SubscribeTradeUpdates() (updates <-chan *Trade, unsubscribe func(), subscriptionID int) // Trade executes a trade on the CEX. updaterID takes a subscriptionID // returned from SubscribeTradeUpdates. - Trade(ctx context.Context, baseID, quoteID uint32, sell bool, rate, qty uint64, subscriptionID int) (string, error) + Trade(ctx context.Context, baseID, quoteID uint32, sell bool, rate, qty uint64, subscriptionID int) (*Trade, error) // UnsubscribeMarket unsubscribes from order book updates on a market. UnsubscribeMarket(baseID, quoteID uint32) error // VWAP returns the volume weighted average price for a certainWithdraw(address string, value, feeRate uint64) quantity diff --git a/client/mm/mm.go b/client/mm/mm.go index 02d5dceac2..7169367f41 100644 --- a/client/mm/mm.go +++ b/client/mm/mm.go @@ -5,11 +5,11 @@ package mm import ( "context" - "encoding/hex" "encoding/json" "errors" "fmt" "os" + "strings" "sync" "sync/atomic" @@ -18,7 +18,6 @@ import ( "decred.org/dcrdex/client/mm/libxc" "decred.org/dcrdex/client/orderbook" "decred.org/dcrdex/dex" - "decred.org/dcrdex/dex/calc" "decred.org/dcrdex/dex/order" ) @@ -30,7 +29,6 @@ type clientCore interface { SupportedAssets() map[uint32]*core.SupportedAsset SingleLotFees(form *core.SingleLotFeesForm) (uint64, uint64, uint64, error) Cancel(oidB dex.Bytes) error - Trade(pw []byte, form *core.TradeForm) (*core.Order, error) MaxBuy(host string, base, quote uint32, rate uint64) (*core.MaxOrderEstimate, error) MaxSell(host string, base, quote uint32) (*core.MaxOrderEstimate, error) AssetBalance(assetID uint32) (*core.WalletBalance, error) @@ -45,7 +43,9 @@ type clientCore interface { FiatConversionRates() map[uint32]float64 Send(pw []byte, assetID uint32, value uint64, address string, subtract bool) (asset.Coin, error) NewDepositAddress(assetID uint32) (string, error) - TransactionConfirmations(assetID uint32, txid string) (uint32, error) + Order(oidB dex.Bytes) (*core.Order, error) + WalletTransaction(uint32, dex.Bytes) (*asset.WalletTransaction, error) + ConfirmedWalletTransaction(uint32, dex.Bytes, func(*asset.WalletTransaction)) error } var _ clientCore = (*core.Core)(nil) @@ -59,106 +59,6 @@ type dexOrderBook interface { var _ dexOrderBook = (*orderbook.OrderBook)(nil) -// botBalance keeps track of the amount of funds available for a -// bot's use, and the amount that is currently locked/pending redemption. Only -// the Available balance matters for the behavior of the bots. The others are -// just tracked to inform the user. -type botBalance struct { - Available uint64 `json:"available"` - FundingOrder uint64 `json:"fundingOrder"` - PendingRedeem uint64 `json:"pendingRedeem"` -} - -// botBalance keeps track of the bot balances. -// When the MarketMaker is created, it will allocate the proper amount of -// funds for each bot. Then, as the bot makes trades, each bot's balances -// will be increased and decreased as needed. -// Below is of how the balances are adjusted during trading. This only -// outlines the changes to the Available balance. -// -// 1. A trade is made: -// -// - FromAsset: -// DECREASE: LockedFunds + FundingFees -// if isAccountLocker, RefundFeesLockedFunds -// -// - ToAsset: -// DECREASE: if isAccountLocker, RedeemFeesLockedFunds -// -// 2a. MatchConfirmed, Redeemed: -// - ToAsset: -// INCREASE: if isAccountLocker, RedeemedAmount -// else RedeemedAmount - MaxRedeemFeesForLotsRedeemed -// (the redeemed amount is tracked on the core.Order, so we -// do not know the exact amount used for this match. The -// difference is handled later.) -// -// 2b. MatchConfirmed, Refunded: -// - FromAsset: -// INCREASE: RefundedAmount - RefundFees -// if isAccountLocker, RefundFeesLockedFunds -// -// 4. order.LockedAmount == 0: (This means no more swap tx will be made, over lock can be returned) -// -// - FromAsset: -// INCREASE: OverLockedAmount (LockedFunds - SwappedAmount - MaxSwapFees) -// -// 5. All Fees Confirmed: -// -// - FromAsset: -// INCREASE: ExcessSwapFees (MaxSwapFees - ActualSwapFees) -// if isAccountLocker, ExcessRefundFees (RefundFeesLockedFunds - ActualRefundFees) -// else ExcessRefundFees (MaxRefundFees - ActualRefundFees) -// -// - ToAsset: -// INCREASE: if isAccountLocker, ExcessRedeemFees (RedeemFeesLockedFunds - ActualRedeemFees) -// else ExcessRedeemFees (MaxRedeemFeesForLotsRedeemed - ActualRedeemFees) -type botBalances struct { - mtx sync.RWMutex - balances map[uint32]*botBalance - // It is assumed that a bot only interacts with one CEX. - cexBalances map[uint32]uint64 -} - -// orderInfo stores the necessary information the MarketMaker needs for a -// particular order. -type orderInfo struct { - bot string - order *core.Order - initialFundsLocked uint64 - lotSize uint64 - // initialRedeemFeesLocked will be > 0 for assets that are account lockers - // (ETH). This means that the redeem fees will be initially locked, then - // the complete redeemed amount will be sent on redemption. - initialRedeemFeesLocked uint64 - initialRefundFeesLocked uint64 - singleLotSwapFees uint64 - singleLotRedeemFees uint64 - singleLotRefundFees uint64 - unusedLockedFundsReturned bool - excessFeesReturned bool - matchesSeen map[order.MatchID]struct{} - matchesSettled map[order.MatchID]struct{} -} - -// finishedProcessing returns true when the MarketMaker no longer needs to -// track an order. -func (o *orderInfo) finishedProcessing() bool { - if !o.unusedLockedFundsReturned || !o.excessFeesReturned { - return false - } - - for _, match := range o.order.Matches { - var matchID order.MatchID - copy(matchID[:], match.MatchID) - if _, found := o.matchesSettled[matchID]; !found { - return false - } - } - - return true -} - // MarketWithHost represents a market on a specific dex server. type MarketWithHost struct { Host string `json:"host"` @@ -173,14 +73,13 @@ func (m *MarketWithHost) String() string { // MarketMaker handles the market making process. It supports running different // strategies on different markets. type MarketMaker struct { - ctx context.Context - die context.CancelFunc - running atomic.Bool - log dex.Logger - core clientCore - doNotKillWhenBotsStop bool // used for testing - botBalances map[string]*botBalances - cfgPath string + ctx context.Context + die context.CancelFunc + running atomic.Bool + log dex.Logger + core clientCore + cfgPath string + // syncedOracle is only available while the MarketMaker is running. It // periodically refreshes the prices for the markets that have bots // running on them. @@ -195,8 +94,8 @@ type MarketMaker struct { runningBotsMtx sync.RWMutex runningBots map[MarketWithHost]interface{} - ordersMtx sync.RWMutex - orders map[order.OrderID]*orderInfo + ordersMtx sync.RWMutex + orderToBot map[order.OrderID]string } // NewMarketMaker creates a new MarketMaker. @@ -218,7 +117,7 @@ func NewMarketMaker(c clientCore, cfgPath string, log dex.Logger) (*MarketMaker, log: log, cfgPath: cfgPath, running: atomic.Bool{}, - orders: make(map[order.OrderID]*orderInfo), + orderToBot: make(map[order.OrderID]string), runningBots: make(map[MarketWithHost]interface{}), unsyncedOracle: newUnsyncedPriceOracle(log), }, nil @@ -383,10 +282,10 @@ func validateAndFilterEnabledConfigs(cfgs []*BotConfig) ([]*BotConfig, error) { return enabledCfgs, nil } -// setupBalances makes sure there is sufficient balance in both the dex -// client wallets and on the CEXes, and populates m.botBalances. -func (m *MarketMaker) setupBalances(cfgs []*BotConfig, cexes map[string]libxc.CEX) error { - m.botBalances = make(map[string]*botBalances, len(cfgs)) +// botInitialBaseBalances returns the initial base balances for each bot. +func botInitialBaseBalances(cfgs []*BotConfig, core clientCore, cexes map[string]libxc.CEX) (dexBalances, cexBalances map[string]map[uint32]uint64, err error) { + dexBalances = make(map[string]map[uint32]uint64, len(cfgs)) + cexBalances = make(map[string]map[uint32]uint64, len(cfgs)) type trackedBalance struct { available uint64 @@ -400,7 +299,7 @@ func (m *MarketMaker) setupBalances(cfgs []*BotConfig, cexes map[string]libxc.CE if _, found := dexBalanceTracker[assetID]; found { return nil } - bal, err := m.core.AssetBalance(assetID) + bal, err := core.AssetBalance(assetID) if err != nil { return fmt.Errorf("failed to get balance for asset %d: %v", assetID, err) } @@ -450,11 +349,11 @@ func (m *MarketMaker) setupBalances(cfgs []*BotConfig, cexes map[string]libxc.CE for _, cfg := range cfgs { err := trackAssetOnDEX(cfg.BaseAsset) if err != nil { - return err + return nil, nil, err } err = trackAssetOnDEX(cfg.QuoteAsset) if err != nil { - return err + return nil, nil, err } mktID := dexMarketID(cfg.Host, cfg.BaseAsset, cfg.QuoteAsset) @@ -465,518 +364,108 @@ func (m *MarketMaker) setupBalances(cfgs []*BotConfig, cexes map[string]libxc.CE baseRequired := calcBalance(cfg.BaseBalanceType, cfg.BaseBalance, baseBalance.available) quoteRequired := calcBalance(cfg.QuoteBalanceType, cfg.QuoteBalance, quoteBalance.available) if baseRequired == 0 && quoteRequired == 0 { - return fmt.Errorf("both base and quote balance are zero for market %s", mktID) + return nil, nil, fmt.Errorf("both base and quote balance are zero for market %s", mktID) } if baseRequired > baseBalance.available-baseBalance.reserved { - return fmt.Errorf("insufficient balance for asset %d", cfg.BaseAsset) + return nil, nil, fmt.Errorf("insufficient balance for asset %d", cfg.BaseAsset) } if quoteRequired > quoteBalance.available-quoteBalance.reserved { - return fmt.Errorf("insufficient balance for asset %d", cfg.QuoteAsset) + return nil, nil, fmt.Errorf("insufficient balance for asset %d", cfg.QuoteAsset) } baseBalance.reserved += baseRequired quoteBalance.reserved += quoteRequired - m.botBalances[mktID] = &botBalances{ - balances: map[uint32]*botBalance{ - cfg.BaseAsset: { - Available: baseRequired, - }, - cfg.QuoteAsset: { - Available: quoteRequired, - }, - }, + dexBalances[mktID] = map[uint32]uint64{ + cfg.BaseAsset: baseRequired, + cfg.QuoteAsset: quoteRequired, + } + + trackTokenFeeAsset := func(base bool) error { + assetID := cfg.QuoteAsset + balType := cfg.QuoteFeeAssetBalanceType + balAmount := cfg.QuoteFeeAssetBalance + baseOrQuote := "quote" + if base { + assetID = cfg.BaseAsset + balType = cfg.BaseFeeAssetBalanceType + balAmount = cfg.BaseFeeAssetBalance + baseOrQuote = "base" + } + token := asset.TokenInfo(assetID) + if token == nil { + return nil + } + err := trackAssetOnDEX(token.ParentID) + if err != nil { + return err + } + tokenFeeAsset := dexBalanceTracker[token.ParentID] + tokenFeeAssetRequired := calcBalance(balType, balAmount, tokenFeeAsset.available) + if tokenFeeAssetRequired == 0 { + return fmt.Errorf("%s fee asset balance is zero for market %s", baseOrQuote, mktID) + } + if tokenFeeAssetRequired > tokenFeeAsset.available-tokenFeeAsset.reserved { + return fmt.Errorf("insufficient balance for asset %d", token.ParentID) + } + tokenFeeAsset.reserved += tokenFeeAssetRequired + dexBalances[mktID][token.ParentID] += tokenFeeAssetRequired + return nil + } + err = trackTokenFeeAsset(true) + if err != nil { + return nil, nil, err + } + err = trackTokenFeeAsset(false) + if err != nil { + return nil, nil, err } // Calculate CEX balances if cfg.CEXCfg != nil { baseSymbol := dex.BipIDSymbol(cfg.BaseAsset) if baseSymbol == "" { - return fmt.Errorf("unknown asset ID %d", cfg.BaseAsset) + return nil, nil, fmt.Errorf("unknown asset ID %d", cfg.BaseAsset) } baseAssetSymbol := dex.TokenSymbol(baseSymbol) quoteSymbol := dex.BipIDSymbol(cfg.QuoteAsset) if quoteSymbol == "" { - return fmt.Errorf("unknown asset ID %d", cfg.QuoteAsset) + return nil, nil, fmt.Errorf("unknown asset ID %d", cfg.QuoteAsset) } quoteAssetSymbol := dex.TokenSymbol(quoteSymbol) err = trackAssetOnCEX(baseAssetSymbol, cfg.BaseAsset, cfg.CEXCfg.Name) if err != nil { - return err + return nil, nil, err } err = trackAssetOnCEX(quoteAssetSymbol, cfg.QuoteAsset, cfg.CEXCfg.Name) if err != nil { - return err + return nil, nil, err } baseCEXBalance := cexBalanceTracker[cfg.CEXCfg.Name][baseAssetSymbol] quoteCEXBalance := cexBalanceTracker[cfg.CEXCfg.Name][quoteAssetSymbol] cexBaseRequired := calcBalance(cfg.CEXCfg.BaseBalanceType, cfg.CEXCfg.BaseBalance, baseCEXBalance.available) cexQuoteRequired := calcBalance(cfg.QuoteBalanceType, cfg.QuoteBalance, quoteCEXBalance.available) if cexBaseRequired == 0 && cexQuoteRequired == 0 { - return fmt.Errorf("both base and quote CEX balances are zero for market %s", mktID) + return nil, nil, fmt.Errorf("both base and quote CEX balances are zero for market %s", mktID) } if cexBaseRequired > baseCEXBalance.available-baseCEXBalance.reserved { - return fmt.Errorf("insufficient CEX base balance for asset %d", cfg.BaseAsset) + return nil, nil, fmt.Errorf("insufficient CEX base balance for asset %d", cfg.BaseAsset) } if cexQuoteRequired > quoteCEXBalance.available-quoteCEXBalance.reserved { - return fmt.Errorf("insufficient CEX quote balance for asset %d", cfg.QuoteAsset) + return nil, nil, fmt.Errorf("insufficient CEX quote balance for asset %d", cfg.QuoteAsset) } baseCEXBalance.reserved += cexBaseRequired quoteCEXBalance.reserved += cexQuoteRequired - m.botBalances[mktID].cexBalances = map[uint32]uint64{ + + cexBalances[mktID] = map[uint32]uint64{ cfg.BaseAsset: cexBaseRequired, cfg.QuoteAsset: cexQuoteRequired, } } } - return nil -} - -// isAccountLocker returns if the asset's wallet is an asset.AccountLocker. -func (m *MarketMaker) isAccountLocker(assetID uint32) bool { - walletState := m.core.WalletState(assetID) - if walletState == nil { - m.log.Errorf("isAccountLocker: wallet state not found for asset %d", assetID) - return false - } - - return walletState.Traits.IsAccountLocker() -} - -// isWithdrawer returns if the asset's wallet is an asset.Withdrawer. -func (m *MarketMaker) isWithdrawer(assetID uint32) bool { - walletState := m.core.WalletState(assetID) - if walletState == nil { - m.log.Errorf("isAccountLocker: wallet state not found for asset %d", assetID) - return false - } - - return walletState.Traits.IsWithdrawer() -} - -type botBalanceType uint8 - -const ( - balTypeAvailable botBalanceType = iota - balTypeFundingOrder - balTypePendingRedeem -) - -const ( - balanceModIncrease = true - balanceModDecrease = false -) - -// balanceMod is passed to modifyBotBalance to increase or decrease one -// of the bot's balances for an asset. -type balanceMod struct { - increase bool - assetID uint32 - typ botBalanceType - amount uint64 -} - -// modifyBotBalance does modifications to the various bot balances. -func (m *MarketMaker) modifyBotBalance(botID string, mods []*balanceMod) { - bb := m.botBalances[botID] - if bb == nil { - m.log.Errorf("increaseBotBalance: bot %s not found", botID) - return - } - - bb.mtx.Lock() - defer bb.mtx.Unlock() - - for _, mod := range mods { - assetBalance, found := bb.balances[mod.assetID] - if !found { - m.log.Errorf("modifyBotBalance: asset %d not found for bot %s", mod.assetID, botID) - continue - } - - newFieldValue := func(balanceType string, initialValue uint64) uint64 { - if mod.increase { - return initialValue + mod.amount - } else { - if initialValue < mod.amount { - m.log.Errorf("modifyBotBalance: bot %s has insufficient %s for asset %d. "+ - "balance: %d, amount: %d", botID, balanceType, mod.assetID, initialValue, mod.amount) - return 0 - } - return initialValue - mod.amount - } - } - - switch mod.typ { - case balTypeAvailable: - assetBalance.Available = newFieldValue("available balance", assetBalance.Available) - case balTypeFundingOrder: - assetBalance.FundingOrder = newFieldValue("funding order", assetBalance.FundingOrder) - case balTypePendingRedeem: - assetBalance.PendingRedeem = newFieldValue("pending redeem", assetBalance.PendingRedeem) - } - } -} - -// botBalance returns a bot's balance of an asset. -func (m *MarketMaker) botBalance(botID string, assetID uint32) uint64 { - bb := m.botBalances[botID] - if bb == nil { - m.log.Errorf("balance: bot %s not found", botID) - return 0 - } - - bb.mtx.RLock() - defer bb.mtx.RUnlock() - - if _, found := bb.balances[assetID]; found { - return bb.balances[assetID].Available - } - - m.log.Errorf("balance: asset %d not found for bot %s", assetID, botID) - return 0 -} - -func (m *MarketMaker) modifyBotCEXBalance(botID string, assetID uint32, amount uint64, increase bool) { - bb := m.botBalances[botID] - if bb == nil { - m.log.Errorf("modifyBotCEXBalance: bot %s not found", botID) - return - } - - bb.mtx.RLock() - defer bb.mtx.RUnlock() - - if _, found := bb.cexBalances[assetID]; !found { - // Even if the balance is 0, it should have been defined in - // setupBalances. - m.log.Errorf("modifyBotCEXBalance: bot %s does not have balance for asset %d", botID, assetID) - return - } - - if increase { - bb.cexBalances[assetID] += amount - return - } - - if bb.cexBalances[assetID] < amount { - m.log.Errorf("modifyBotCEXBalance: bot %s: decreasing asset %d balance by %d but only have %d", - botID, assetID, amount, bb.cexBalances[assetID]) - bb.cexBalances[assetID] = 0 - return - } - - bb.cexBalances[assetID] -= amount -} - -func (m *MarketMaker) botCEXBalance(botID string, assetID uint32) uint64 { - bb := m.botBalances[botID] - if bb == nil { - m.log.Errorf("balance: bot %s not found", botID) - return 0 - } - - bb.mtx.RLock() - defer bb.mtx.RUnlock() - - if _, found := bb.cexBalances[assetID]; found { - return bb.cexBalances[assetID] - } - - m.log.Errorf("botCEXBalance: asset %d not found for bot %s", assetID, botID) - return 0 -} - -func (m *MarketMaker) getOrderInfo(id dex.Bytes) *orderInfo { - var oid order.OrderID - copy(oid[:], id) - - m.ordersMtx.RLock() - defer m.ordersMtx.RUnlock() - return m.orders[oid] -} - -func (m *MarketMaker) removeOrderInfo(id dex.Bytes) { - m.log.Tracef("Removing oid %s from tracked orders", id) - - var oid order.OrderID - copy(oid[:], id[:]) - - m.ordersMtx.Lock() - defer m.ordersMtx.Unlock() - delete(m.orders, oid) -} - -// handleMatchUpdate updates the bots balances based on a match's status. -// Balances are updated due to a match two times, once when the match is -// first seen, and once when the match is settled. -// -// - When a match is seen, it is assumed that the match will eventually be -// redeemed, so funding balance is decreased and pending redeem balance -// is increased. -// - When a match is settles, the balances are updated differently depending -// on whether the match was refunded or redeemed. -func (m *MarketMaker) handleMatchUpdate(match *core.Match, oid dex.Bytes) { - orderInfo := m.getOrderInfo(oid) - if orderInfo == nil { - m.log.Debugf("did not find order info for order %s", oid) - return - } - - var maxRedeemFees uint64 - if orderInfo.initialRedeemFeesLocked == 0 { - numLots := match.Qty / orderInfo.lotSize - maxRedeemFees = numLots * orderInfo.singleLotRedeemFees - } - - var matchID order.MatchID - copy(matchID[:], match.MatchID) - - if _, seen := orderInfo.matchesSeen[matchID]; !seen { - orderInfo.matchesSeen[matchID] = struct{}{} - var balanceMods []*balanceMod - if orderInfo.order.Sell { - balanceMods = []*balanceMod{ - {balanceModDecrease, orderInfo.order.BaseID, balTypeFundingOrder, match.Qty}, - {balanceModIncrease, orderInfo.order.QuoteID, balTypePendingRedeem, calc.BaseToQuote(match.Rate, match.Qty) - maxRedeemFees}, - } - } else { - balanceMods = []*balanceMod{ - {balanceModDecrease, orderInfo.order.QuoteID, balTypeFundingOrder, calc.BaseToQuote(match.Rate, match.Qty)}, - {balanceModIncrease, orderInfo.order.BaseID, balTypePendingRedeem, match.Qty - maxRedeemFees}, - } - } - m.modifyBotBalance(orderInfo.bot, balanceMods) - } - - unconfirmed := match.Status != order.MatchConfirmed - notRefunded := match.Refund == nil - revokedPreSwap := match.Revoked && match.Swap == nil - if unconfirmed && notRefunded && !revokedPreSwap { - return - } - - if _, settled := orderInfo.matchesSettled[matchID]; settled { - return - } - orderInfo.matchesSettled[matchID] = struct{}{} - - if match.Refund != nil { - var singleLotRefundFees uint64 - if orderInfo.initialRefundFeesLocked == 0 { - singleLotRefundFees = orderInfo.singleLotRefundFees - } - var balanceMods []*balanceMod - if orderInfo.order.Sell { - balanceMods = []*balanceMod{ - {balanceModDecrease, orderInfo.order.QuoteID, balTypePendingRedeem, calc.BaseToQuote(match.Rate, match.Qty) - maxRedeemFees}, - {balanceModIncrease, orderInfo.order.BaseID, balTypeAvailable, match.Qty - singleLotRefundFees}, - } - } else { - balanceMods = []*balanceMod{ - {balanceModDecrease, orderInfo.order.BaseID, balTypePendingRedeem, match.Qty - maxRedeemFees}, - {balanceModIncrease, orderInfo.order.QuoteID, balTypeAvailable, calc.BaseToQuote(match.Rate, match.Qty) - singleLotRefundFees}, - } - } - m.modifyBotBalance(orderInfo.bot, balanceMods) - } else if match.Redeem != nil { - redeemAsset := orderInfo.order.BaseID - redeemQty := match.Qty - if orderInfo.order.Sell { - redeemAsset = orderInfo.order.QuoteID - redeemQty = calc.BaseToQuote(match.Rate, redeemQty) - } - balanceMods := []*balanceMod{ - {balanceModDecrease, redeemAsset, balTypePendingRedeem, redeemQty - maxRedeemFees}, - {balanceModIncrease, redeemAsset, balTypeAvailable, redeemQty - maxRedeemFees}, - } - m.modifyBotBalance(orderInfo.bot, balanceMods) - } else if match.Swap != nil { - // Something went wrong.. we made a swap tx, but did not get a refund or redeem. - m.log.Errorf("oid: %s, match %s is in confirmed state, but no refund or redeem", oid, matchID) - redeemAsset := orderInfo.order.BaseID - redeemQty := match.Qty - if orderInfo.order.Sell { - redeemAsset = orderInfo.order.QuoteID - redeemQty = calc.BaseToQuote(match.Rate, redeemQty) - } - balanceMods := []*balanceMod{ - {balanceModDecrease, redeemAsset, balTypePendingRedeem, redeemQty - maxRedeemFees}, - } - m.modifyBotBalance(orderInfo.bot, balanceMods) - } else { - // We did not even make a swap tx. The modifications here are the - // opposite of what happened when the match was first seen. - var balanceMods []*balanceMod - if orderInfo.order.Sell { - balanceMods = []*balanceMod{ - {balanceModIncrease, orderInfo.order.BaseID, balTypeFundingOrder, match.Qty}, - {balanceModDecrease, orderInfo.order.QuoteID, balTypePendingRedeem, calc.BaseToQuote(match.Rate, match.Qty) - maxRedeemFees}, - } - } else { - balanceMods = []*balanceMod{ - {balanceModIncrease, orderInfo.order.QuoteID, balTypeFundingOrder, calc.BaseToQuote(match.Rate, match.Qty)}, - {balanceModDecrease, orderInfo.order.BaseID, balTypePendingRedeem, match.Qty - maxRedeemFees}, - } - } - m.modifyBotBalance(orderInfo.bot, balanceMods) - } - - if orderInfo.finishedProcessing() { - m.removeOrderInfo(oid) - } -} - -// handleOrderNotification checks if any funds are ready to be made available -// for use by a bot depending on the order's state. -// - First, any updates to the balances based on the state of the matches -// are made. -// - If the order is no longer booked, the difference between the order's -// quantity and the amount that was matched can be returned to the bot. -// - If all fees have been confirmed, the rest of the difference between -// the amount that was either initially locked or max possible to be used -// and the amount that was actually used can be returned. -func (m *MarketMaker) handleOrderUpdate(o *core.Order) { - orderInfo := m.getOrderInfo(o.ID) - if orderInfo == nil { - return - } - - orderInfo.order = o - - for _, match := range o.Matches { - m.handleMatchUpdate(match, o.ID) - } - - notReadyToReturnOverLock := o.LockedAmt > 0 - returnedOverLockButNotReadyToReturnExcessFees := orderInfo.unusedLockedFundsReturned && !orderInfo.excessFeesReturned && !o.AllFeesConfirmed - complete := orderInfo.unusedLockedFundsReturned && orderInfo.excessFeesReturned - if notReadyToReturnOverLock || returnedOverLockButNotReadyToReturnExcessFees || complete { - return - } - - fromAsset, toAsset := o.BaseID, o.QuoteID - if !o.Sell { - fromAsset, toAsset = toAsset, fromAsset - } - - var swappedLots, swappedMatches, swappedQty, redeemedLots, refundedMatches uint64 - for _, match := range o.Matches { - if match.IsCancel { - continue - } - numLots := match.Qty / orderInfo.lotSize - fromAssetQty := match.Qty - if fromAsset == o.QuoteID { - fromAssetQty = calc.BaseToQuote(match.Rate, fromAssetQty) - } - if match.Swap != nil { - swappedLots += numLots - swappedMatches++ - swappedQty += fromAssetQty - } - if match.Refund != nil { - refundedMatches++ - } - if match.Redeem != nil { - redeemedLots += numLots - } - } - - if !orderInfo.unusedLockedFundsReturned { - maxSwapFees := swappedMatches * orderInfo.singleLotSwapFees - usedFunds := swappedQty + maxSwapFees - if usedFunds < orderInfo.initialFundsLocked { - overLock := orderInfo.initialFundsLocked - usedFunds - balanceMods := []*balanceMod{ - {balanceModIncrease, fromAsset, balTypeAvailable, overLock}, - {balanceModDecrease, fromAsset, balTypeFundingOrder, overLock}, - } - m.modifyBotBalance(orderInfo.bot, balanceMods) - } else { - m.log.Errorf("oid: %s - usedFunds %d >= initialFundsLocked %d", - o.ID, orderInfo.initialFundsLocked) - } - orderInfo.unusedLockedFundsReturned = true - } - - if !orderInfo.excessFeesReturned && o.AllFeesConfirmed { - // Return excess swap fees - maxSwapFees := swappedMatches * orderInfo.singleLotSwapFees - if maxSwapFees > o.FeesPaid.Swap { - balanceMods := []*balanceMod{ - {balanceModIncrease, fromAsset, balTypeAvailable, maxSwapFees - o.FeesPaid.Swap}, - {balanceModDecrease, fromAsset, balTypeFundingOrder, maxSwapFees}, - } - m.modifyBotBalance(orderInfo.bot, balanceMods) - } else if maxSwapFees < o.FeesPaid.Swap { - m.log.Errorf("oid: %s - maxSwapFees %d < swap fees %d", o.ID, maxSwapFees, o.FeesPaid.Swap) - } - - // Return excess redeem fees - if orderInfo.initialRedeemFeesLocked > 0 { // AccountLocker - if orderInfo.initialRedeemFeesLocked > o.FeesPaid.Redemption { - balanceMods := []*balanceMod{ - {balanceModIncrease, toAsset, balTypeAvailable, orderInfo.initialRedeemFeesLocked - o.FeesPaid.Redemption}, - {balanceModDecrease, toAsset, balTypeFundingOrder, orderInfo.initialRedeemFeesLocked}, - } - m.modifyBotBalance(orderInfo.bot, balanceMods) - } else { - m.log.Errorf("oid: %s - initialRedeemFeesLocked %d > redemption fees %d", - o.ID, orderInfo.initialRedeemFeesLocked, o.FeesPaid.Redemption) - } - } else { - maxRedeemFees := redeemedLots * orderInfo.singleLotRedeemFees - if maxRedeemFees > o.FeesPaid.Redemption { - balanceMods := []*balanceMod{ - {balanceModIncrease, toAsset, balTypeAvailable, maxRedeemFees - o.FeesPaid.Redemption}, - } - m.modifyBotBalance(orderInfo.bot, balanceMods) - } else if maxRedeemFees < o.FeesPaid.Redemption { - m.log.Errorf("oid: %v - maxRedeemFees %d < redemption fees %d", - hex.EncodeToString(o.ID), maxRedeemFees, o.FeesPaid.Redemption) - } - } - - // Return excess refund fees - if orderInfo.initialRefundFeesLocked > 0 { // AccountLocker - if orderInfo.initialRefundFeesLocked > o.FeesPaid.Refund { - balanceMods := []*balanceMod{ - {balanceModIncrease, fromAsset, balTypeAvailable, orderInfo.initialRefundFeesLocked - o.FeesPaid.Refund}, - {balanceModDecrease, fromAsset, balTypeFundingOrder, orderInfo.initialRefundFeesLocked}, - } - m.modifyBotBalance(orderInfo.bot, balanceMods) - } - } else { - maxRefundFees := refundedMatches * orderInfo.singleLotRefundFees - if maxRefundFees > o.FeesPaid.Refund { - balanceMods := []*balanceMod{ - {balanceModIncrease, fromAsset, balTypeAvailable, maxRefundFees - o.FeesPaid.Refund}, - } - m.modifyBotBalance(orderInfo.bot, balanceMods) - } else if maxRefundFees < o.FeesPaid.Refund { - m.log.Errorf("oid: %s - max refund fees %d < refund fees %d", - o.ID, maxRefundFees, o.FeesPaid.Refund) - } - } - - orderInfo.excessFeesReturned = true - } - - if orderInfo.finishedProcessing() { - m.removeOrderInfo(o.ID) - } -} - -func (m *MarketMaker) handleNotification(n core.Notification) { - switch note := n.(type) { - case *core.OrderNote: - m.handleOrderUpdate(note.Order) - case *core.MatchNote: - m.handleMatchUpdate(note.Match, note.OrderID) - } + return dexBalances, cexBalances, nil } func (m *MarketMaker) initCEXConnections(cfgs []*CEXConfig) (map[string]libxc.CEX, map[string]*dex.ConnectionMaster) { @@ -1007,12 +496,51 @@ func (m *MarketMaker) initCEXConnections(cfgs []*CEXConfig) (map[string]libxc.CE return cexes, cexCMs } +func (m *MarketMaker) logInitialBotBalances(dexBalances, cexBalances map[string]map[uint32]uint64) { + var msg strings.Builder + msg.WriteString("Initial market making balances:\n") + for mkt, botDexBals := range dexBalances { + msg.WriteString(fmt.Sprintf("-- %s:\n", mkt)) + + i := 0 + msg.WriteString(" DEX: ") + for assetID, amount := range botDexBals { + msg.WriteString(fmt.Sprintf("%s: %d", dex.BipIDSymbol(assetID), amount)) + if i <= len(botDexBals)-1 { + msg.WriteString(", ") + } + i++ + } + + i = 0 + if botCexBals, found := cexBalances[mkt]; found { + msg.WriteString("\n CEX: ") + for assetID, amount := range botCexBals { + msg.WriteString(fmt.Sprintf("%s: %d", dex.BipIDSymbol(assetID), amount)) + if i <= len(botCexBals)-1 { + msg.WriteString(", ") + } + i++ + } + } + } + + m.log.Info(msg.String()) +} + // Run starts the MarketMaker. There can only be one BotConfig per dex market. func (m *MarketMaker) Run(ctx context.Context, pw []byte, alternateConfigPath *string) error { if !m.running.CompareAndSwap(false, true) { return errors.New("market making is already running") } + var startedMarketMaking bool + defer func() { + if !startedMarketMaking { + m.running.Store(false) + } + }() + path := m.cfgPath if alternateConfigPath != nil { path = *alternateConfigPath @@ -1022,13 +550,6 @@ func (m *MarketMaker) Run(ctx context.Context, pw []byte, alternateConfigPath *s return fmt.Errorf("error getting market making config: %v", err) } - var startedMarketMaking bool - defer func() { - if !startedMarketMaking { - m.running.Store(false) - } - }() - m.ctx, m.die = context.WithCancel(ctx) enabledBots, err := validateAndFilterEnabledConfigs(cfg.BotConfigs) @@ -1055,9 +576,11 @@ func (m *MarketMaker) Run(ctx context.Context, pw []byte, alternateConfigPath *s cexes, cexCMs := m.initCEXConnections(cfg.CexConfigs) - if err := m.setupBalances(enabledBots, cexes); err != nil { + dexBaseBalances, cexBaseBalances, err := botInitialBaseBalances(enabledBots, m.core, cexes) + if err != nil { return err } + m.logInitialBotBalances(dexBaseBalances, cexBaseBalances) fiatRates := m.core.FiatConversionRates() startedMarketMaking = true @@ -1065,23 +588,6 @@ func (m *MarketMaker) Run(ctx context.Context, pw []byte, alternateConfigPath *s wg := new(sync.WaitGroup) - // Listen for core notifications. - wg.Add(1) - go func() { - defer wg.Done() - feed := m.core.NotificationFeed() - defer feed.ReturnFeed() - - for { - select { - case <-m.ctx.Done(): - return - case n := <-feed.C: - m.handleNotification(n) - } - } - }() - var cexCfgMap map[string]*CEXConfig if len(cfg.CexConfigs) > 0 { cexCfgMap = make(map[string]*CEXConfig, len(cfg.CexConfigs)) @@ -1110,7 +616,9 @@ func (m *MarketMaker) Run(ctx context.Context, pw []byte, alternateConfigPath *s mktID := dexMarketID(cfg.Host, cfg.BaseAsset, cfg.QuoteAsset) baseFiatRate := fiatRates[cfg.BaseAsset] quoteFiatRate := fiatRates[cfg.QuoteAsset] - RunBasicMarketMaker(m.ctx, cfg, m.wrappedCoreForBot(mktID), oracle, baseFiatRate, quoteFiatRate, logger) + exchangeAdaptor := unifiedExchangeAdaptorForBot(mktID, dexBaseBalances[mktID], cexBaseBalances[mktID], m.core, nil, logger) + exchangeAdaptor.run(ctx) + RunBasicMarketMaker(m.ctx, cfg, exchangeAdaptor, oracle, baseFiatRate, quoteFiatRate, logger) }(cfg) case cfg.SimpleArbConfig != nil: wg.Add(1) @@ -1128,7 +636,9 @@ func (m *MarketMaker) Run(ctx context.Context, pw []byte, alternateConfigPath *s defer func() { m.markBotAsRunning(mkt, false) }() - RunSimpleArbBot(m.ctx, cfg, m.wrappedCoreForBot(mktID), m.wrappedCEXForBot(mktID, cex), logger) + exchangeAdaptor := unifiedExchangeAdaptorForBot(mktID, dexBaseBalances[mktID], cexBaseBalances[mktID], m.core, cex, logger) + exchangeAdaptor.run(ctx) + RunSimpleArbBot(m.ctx, cfg, exchangeAdaptor, exchangeAdaptor, logger) }(cfg) case cfg.ArbMarketMakerConfig != nil: wg.Add(1) @@ -1146,7 +656,9 @@ func (m *MarketMaker) Run(ctx context.Context, pw []byte, alternateConfigPath *s defer func() { m.markBotAsRunning(mkt, false) }() - RunArbMarketMaker(m.ctx, cfg, m.core, m.wrappedCEXForBot(mktID, cex), logger) + exchangeAdaptor := unifiedExchangeAdaptorForBot(mktID, dexBaseBalances[mktID], cexBaseBalances[mktID], m.core, cex, logger) + exchangeAdaptor.run(ctx) + RunArbMarketMaker(m.ctx, cfg, exchangeAdaptor, exchangeAdaptor, logger) }(cfg) default: m.log.Errorf("No bot config provided. Skipping %s-%d-%d", cfg.Host, cfg.BaseAsset, cfg.QuoteAsset) diff --git a/client/mm/mm_arb_market_maker.go b/client/mm/mm_arb_market_maker.go index a6cab2a0b9..513a342a32 100644 --- a/client/mm/mm_arb_market_maker.go +++ b/client/mm/mm_arb_market_maker.go @@ -137,8 +137,8 @@ type arbMarketMaker struct { host string baseID uint32 quoteID uint32 - cex cex - core clientCore + cex botCexAdaptor + core botCoreAdaptor log dex.Logger cfg *ArbMarketMakerConfig mkt *core.Market @@ -173,12 +173,12 @@ func (m *arbMarketMaker) groupedOrders() (buys, sells map[int][]*groupedOrder) { return groupOrders(m.ords, m.oidToPlacement, m.mkt.LotSize) } -func (a *arbMarketMaker) handleCEXTradeUpdate(update *libxc.TradeUpdate) { +func (a *arbMarketMaker) handleCEXTradeUpdate(update *libxc.Trade) { a.log.Debugf("CEX trade update: %+v", update) if update.Complete { a.cexTradesMtx.Lock() - delete(a.cexTrades, update.TradeID) + delete(a.cexTrades, update.ID) a.cexTradesMtx.Unlock() return } @@ -209,7 +209,7 @@ func (a *arbMarketMaker) processDEXMatch(o *core.Order, match *core.Match) { a.cexTradesMtx.Lock() defer a.cexTradesMtx.Unlock() - tradeID, err := a.cex.Trade(a.ctx, a.baseID, a.quoteID, !o.Sell, cexRate, match.Qty) + cexTrade, err := a.cex.Trade(a.ctx, a.baseID, a.quoteID, !o.Sell, cexRate, match.Qty) if err != nil { a.log.Errorf("Error sending trade to CEX: %v", err) return @@ -218,7 +218,7 @@ func (a *arbMarketMaker) processDEXMatch(o *core.Order, match *core.Match) { // Keep track of the epoch in which the trade was sent to the CEX. This way // the bot can cancel the trade if it is not filled after a certain number // of epochs. - a.cexTrades[tradeID] = a.currEpoch.Load() + a.cexTrades[cexTrade.ID] = a.currEpoch.Load() } func (a *arbMarketMaker) processDEXMatchNote(note *core.MatchNote) { @@ -334,7 +334,7 @@ func (a *arbMarketMaker) cancelExpiredCEXTrades() { } } -func arbMarketMakerRebalance(newEpoch uint64, a arbMMRebalancer, c clientCore, cex cex, cfg *ArbMarketMakerConfig, mkt *core.Market, buyFees, +func arbMarketMakerRebalance(newEpoch uint64, a arbMMRebalancer, c botCoreAdaptor, cex botCexAdaptor, cfg *ArbMarketMakerConfig, mkt *core.Market, buyFees, sellFees *orderFees, reserves *autoRebalanceReserves, log dex.Logger) (cancels []dex.Bytes, buyOrders, sellOrders []*rateLots) { existingBuys, existingSells := a.groupedOrders() @@ -456,7 +456,8 @@ func arbMarketMakerRebalance(newEpoch uint64, a arbMMRebalancer, c clientCore, c cumulativeCEXDepth += uint64(float64(cfgPlacement.Lots*mkt.LotSize) * cfgPlacement.Multiplier) _, extrema, filled, err := a.vwap(sell, cumulativeCEXDepth) if err != nil { - log.Errorf("error calculating vwap: %v", err) + log.Errorf("Error calculating vwap: %v", err) + break } if !filled { log.Infof("CEX %s side has < %d %s on the orderbook.", map[bool]string{true: "sell", false: "buy"}[sell], cumulativeCEXDepth, mkt.BaseSymbol) @@ -662,7 +663,8 @@ func (a *arbMarketMaker) rebalanceAssets() { } if (totalDexBalance+cexBalance.Available)/2 < minAmount { - a.log.Warnf("Cannot rebalance %s because balance is too low on both DEX and CEX", symbol) + a.log.Warnf("Cannot rebalance %s because balance is too low on both DEX and CEX. Min amount: %v, CEX balance: %v, DEX Balance: %v", + symbol, minAmount, cexBalance.Available, totalDexBalance) return } @@ -769,6 +771,7 @@ func (a *arbMarketMaker) rebalance(epoch uint64) { return } defer a.rebalanceRunning.Store(false) + a.log.Tracef("rebalance: epoch %d", epoch) currEpoch := a.currEpoch.Load() if epoch <= currEpoch { @@ -944,7 +947,7 @@ func (a *arbMarketMaker) run() { a.cancelAllOrders() } -func RunArbMarketMaker(ctx context.Context, cfg *BotConfig, c clientCore, cex cex, log dex.Logger) { +func RunArbMarketMaker(ctx context.Context, cfg *BotConfig, c botCoreAdaptor, cex botCexAdaptor, log dex.Logger) { if cfg.ArbMarketMakerConfig == nil { // implies bug in caller log.Errorf("No arb market maker config provided. Exiting.") diff --git a/client/mm/mm_arb_market_maker_test.go b/client/mm/mm_arb_market_maker_test.go index 06e6157740..2b18abe49c 100644 --- a/client/mm/mm_arb_market_maker_test.go +++ b/client/mm/mm_arb_market_maker_test.go @@ -119,7 +119,7 @@ func TestArbMarketMakerRebalance(t *testing.T) { rebalancer *tArbMMRebalancer cfg *ArbMarketMakerConfig dexBalances map[uint32]uint64 - cexBalances map[uint32]*libxc.ExchangeBalance + cexBalances map[uint32]*botBalance reserves autoRebalanceReserves expectedCancels []dex.Bytes @@ -160,7 +160,7 @@ func TestArbMarketMakerRebalance(t *testing.T) { 42: lotSize * 3, 0: calc.BaseToQuote(1e6, 3*lotSize), }, - cexBalances: map[uint32]*libxc.ExchangeBalance{ + cexBalances: map[uint32]*botBalance{ 0: {Available: 1e19}, 42: {Available: 1e19}, }, @@ -207,7 +207,7 @@ func TestArbMarketMakerRebalance(t *testing.T) { 42: lotSize * 3, 0: calc.BaseToQuote(1e6, 3*lotSize), }, - cexBalances: map[uint32]*libxc.ExchangeBalance{ + cexBalances: map[uint32]*botBalance{ 0: {Available: 1e19}, 42: {Available: 1e19}, }, @@ -248,7 +248,7 @@ func TestArbMarketMakerRebalance(t *testing.T) { 42: lotSize * 3, 0: calc.BaseToQuote(1e6, 3*lotSize), }, - cexBalances: map[uint32]*libxc.ExchangeBalance{ + cexBalances: map[uint32]*botBalance{ 0: {Available: 1e19}, 42: {Available: 1e19}, }, @@ -295,7 +295,7 @@ func TestArbMarketMakerRebalance(t *testing.T) { 42: lotSize * 3, 0: calc.BaseToQuote(1e6, 3*lotSize), }, - cexBalances: map[uint32]*libxc.ExchangeBalance{ + cexBalances: map[uint32]*botBalance{ 0: {Available: 1e19}, 42: {Available: 1e19}, }, @@ -333,7 +333,7 @@ func TestArbMarketMakerRebalance(t *testing.T) { 42: 2*(lotSize+sellFees.swap) + sellFees.funding, 0: calc.BaseToQuote(divideRate(1.9e6, 1+profit), lotSize) + calc.BaseToQuote(divideRate(1.7e6, 1+profit), lotSize) + 2*buyFees.swap + buyFees.funding, }, - cexBalances: map[uint32]*libxc.ExchangeBalance{ + cexBalances: map[uint32]*botBalance{ 0: {Available: 1e19}, 42: {Available: 1e19}, }, @@ -390,7 +390,7 @@ func TestArbMarketMakerRebalance(t *testing.T) { 42: 2*(lotSize+sellFees.swap) + sellFees.funding - 1, 0: calc.BaseToQuote(divideRate(1.9e6, 1+profit), lotSize) + calc.BaseToQuote(divideRate(1.7e6, 1+profit), lotSize) + 2*buyFees.swap + buyFees.funding - 1, }, - cexBalances: map[uint32]*libxc.ExchangeBalance{ + cexBalances: map[uint32]*botBalance{ 0: {Available: 1e19}, 42: {Available: 1e19}, }, @@ -437,7 +437,7 @@ func TestArbMarketMakerRebalance(t *testing.T) { 42: 1e19, 0: 1e19, }, - cexBalances: map[uint32]*libxc.ExchangeBalance{ + cexBalances: map[uint32]*botBalance{ 0: {Available: calc.BaseToQuote(2.2e6, mkt.LotSize) + calc.BaseToQuote(2.4e6, mkt.LotSize)}, 42: {Available: 2 * mkt.LotSize}, }, @@ -494,7 +494,7 @@ func TestArbMarketMakerRebalance(t *testing.T) { 42: 1e19, 0: 1e19, }, - cexBalances: map[uint32]*libxc.ExchangeBalance{ + cexBalances: map[uint32]*botBalance{ 0: {Available: calc.BaseToQuote(2.2e6, mkt.LotSize) + calc.BaseToQuote(2.4e6, mkt.LotSize) - 1}, 42: {Available: 2*mkt.LotSize - 1}, }, @@ -553,7 +553,7 @@ func TestArbMarketMakerRebalance(t *testing.T) { 42: 1e19, 0: 1e19, }, - cexBalances: map[uint32]*libxc.ExchangeBalance{ + cexBalances: map[uint32]*botBalance{ 0: {Available: calc.BaseToQuote(2.2e6, mkt.LotSize) + calc.BaseToQuote(2.4e6, mkt.LotSize)}, 42: {Available: 2 * mkt.LotSize}, }, @@ -614,7 +614,7 @@ func TestArbMarketMakerRebalance(t *testing.T) { 42: 1e19, 0: 1e19, }, - cexBalances: map[uint32]*libxc.ExchangeBalance{ + cexBalances: map[uint32]*botBalance{ 0: {Available: calc.BaseToQuote(2.2e6, mkt.LotSize) + calc.BaseToQuote(2.4e6, mkt.LotSize) - 1}, 42: {Available: 2*mkt.LotSize - 1}, }, @@ -652,7 +652,7 @@ func TestArbMarketMakerRebalance(t *testing.T) { 42: 2*(lotSize+sellFees.swap) + sellFees.funding + 2*lotSize, 0: calc.BaseToQuote(divideRate(1.9e6, 1+profit), lotSize) + calc.BaseToQuote(divideRate(1.7e6, 1+profit), lotSize) + 2*buyFees.swap + buyFees.funding, }, - cexBalances: map[uint32]*libxc.ExchangeBalance{ + cexBalances: map[uint32]*botBalance{ 0: {Available: 1e19}, 42: {Available: 1e19}, }, @@ -712,7 +712,7 @@ func TestArbMarketMakerRebalance(t *testing.T) { 42: 2*(lotSize+sellFees.swap) + sellFees.funding + 2*lotSize - 1, 0: calc.BaseToQuote(divideRate(1.9e6, 1+profit), lotSize) + calc.BaseToQuote(divideRate(1.7e6, 1+profit), lotSize) + 2*buyFees.swap + buyFees.funding, }, - cexBalances: map[uint32]*libxc.ExchangeBalance{ + cexBalances: map[uint32]*botBalance{ 0: {Available: 1e19}, 42: {Available: 1e19}, }, @@ -767,7 +767,7 @@ func TestArbMarketMakerRebalance(t *testing.T) { 42: 1e19, 0: 1e19, }, - cexBalances: map[uint32]*libxc.ExchangeBalance{ + cexBalances: map[uint32]*botBalance{ 0: {Available: calc.BaseToQuote(2.2e6, mkt.LotSize) + calc.BaseToQuote(2.4e6, mkt.LotSize) + lotSize}, 42: {Available: 2 * mkt.LotSize}, }, @@ -827,7 +827,7 @@ func TestArbMarketMakerRebalance(t *testing.T) { 42: 1e19, 0: 1e19, }, - cexBalances: map[uint32]*libxc.ExchangeBalance{ + cexBalances: map[uint32]*botBalance{ 0: {Available: calc.BaseToQuote(2.2e6, mkt.LotSize) + calc.BaseToQuote(2.4e6, mkt.LotSize) + lotSize - 1}, 42: {Available: 2 * mkt.LotSize}, }, @@ -854,11 +854,11 @@ func TestArbMarketMakerRebalance(t *testing.T) { for _, test := range tests { tCore := newTCore() tCore.setAssetBalances(test.dexBalances) - cex := newTWrappedCEX() + cex := newTBotCEXAdaptor() cex.balances = test.cexBalances cancels, buys, sells := arbMarketMakerRebalance(newEpoch, test.rebalancer, - tCore, cex, test.cfg, mkt, buyFees, sellFees, &test.reserves, tLogger) + newTBotCoreAdaptor(tCore), cex, test.cfg, mkt, buyFees, sellFees, &test.reserves, tLogger) if len(cancels) != len(test.expectedCancels) { t.Fatalf("%s: expected %d cancels, got %d", test.name, len(test.expectedCancels), len(cancels)) @@ -936,7 +936,7 @@ func TestArbMarketMakerDEXUpdates(t *testing.T) { name string orders []*core.Order notes []core.Notification - expectedCEXTrades []*cexOrder + expectedCEXTrades []*libxc.Trade } tests := []*test{ @@ -1004,20 +1004,20 @@ func TestArbMarketMakerDEXUpdates(t *testing.T) { }, }, }, - expectedCEXTrades: []*cexOrder{ + expectedCEXTrades: []*libxc.Trade{ { - baseID: 42, - quoteID: 0, - qty: lotSize, - rate: divideRate(8e5, 1+profit), - sell: false, + BaseID: 42, + QuoteID: 0, + Qty: lotSize, + Rate: divideRate(8e5, 1+profit), + Sell: false, }, { - baseID: 42, - quoteID: 0, - qty: lotSize, - rate: multiplyRate(6e5, 1+profit), - sell: true, + BaseID: 42, + QuoteID: 0, + Qty: lotSize, + Rate: multiplyRate(6e5, 1+profit), + Sell: true, }, nil, nil, @@ -1087,20 +1087,20 @@ func TestArbMarketMakerDEXUpdates(t *testing.T) { }, }, }, - expectedCEXTrades: []*cexOrder{ + expectedCEXTrades: []*libxc.Trade{ { - baseID: 42, - quoteID: 0, - qty: lotSize, - rate: divideRate(8e5, 1+profit), - sell: false, + BaseID: 42, + QuoteID: 0, + Qty: lotSize, + Rate: divideRate(8e5, 1+profit), + Sell: false, }, { - baseID: 42, - quoteID: 0, - qty: lotSize, - rate: multiplyRate(6e5, 1+profit), - sell: true, + BaseID: 42, + QuoteID: 0, + Qty: lotSize, + Rate: multiplyRate(6e5, 1+profit), + Sell: true, }, nil, nil, @@ -1109,7 +1109,7 @@ func TestArbMarketMakerDEXUpdates(t *testing.T) { } runTest := func(test *test) { - cex := newTWrappedCEX() + cex := newTBotCEXAdaptor() tCore := newTCore() ctx, cancel := context.WithCancel(context.Background()) defer cancel() @@ -1124,7 +1124,7 @@ func TestArbMarketMakerDEXUpdates(t *testing.T) { arbMM := &arbMarketMaker{ cex: cex, - core: tCore, + core: newTBotCoreAdaptor(tCore), ctx: ctx, ords: ords, baseID: 42, @@ -1624,8 +1624,8 @@ func TestArbMarketMakerAutoRebalance(t *testing.T) { } runTest := func(test *test) { - cex := newTWrappedCEX() - cex.balances = map[uint32]*libxc.ExchangeBalance{ + cex := newTBotCEXAdaptor() + cex.balances = map[uint32]*botBalance{ baseID: {Available: test.cexBaseBalance}, quoteID: {Available: test.cexQuoteBalance}, } @@ -1641,7 +1641,7 @@ func TestArbMarketMakerAutoRebalance(t *testing.T) { mm := &arbMarketMaker{ ctx: ctx, cex: cex, - core: tCore, + core: newTBotCoreAdaptor(tCore), baseID: baseID, quoteID: quoteID, oidToPlacement: test.oidToPlacement, diff --git a/client/mm/mm_basic.go b/client/mm/mm_basic.go index b4f92b7b13..3912b2d812 100644 --- a/client/mm/mm_basic.go +++ b/client/mm/mm_basic.go @@ -208,7 +208,7 @@ type basicMarketMaker struct { cfg *BasicMarketMakingConfig book dexOrderBook log dex.Logger - core clientCore + core botCoreAdaptor oracle oracle mkt *core.Market // the fiat rate is the rate determined by comparing the fiat rates @@ -510,7 +510,7 @@ type rateLots struct { placementIndex int } -func basicMMRebalance(newEpoch uint64, m rebalancer, c clientCore, cfg *BasicMarketMakingConfig, mkt *core.Market, buyFees, +func basicMMRebalance(newEpoch uint64, m rebalancer, c botCoreAdaptor, cfg *BasicMarketMakingConfig, mkt *core.Market, buyFees, sellFees *orderFees, log dex.Logger) (cancels []dex.Bytes, buyOrders, sellOrders []*rateLots) { basisPrice := m.basisPrice() if basisPrice == 0 { @@ -692,6 +692,7 @@ func (m *basicMarketMaker) rebalance(newEpoch uint64) { return } defer m.rebalanceRunning.Store(false) + m.log.Tracef("rebalance: epoch %d", newEpoch) m.feesMtx.RLock() buyFees, sellFees := m.buyFees, m.sellFees @@ -860,7 +861,7 @@ func (m *basicMarketMaker) run() { } // RunBasicMarketMaker starts a basic market maker bot. -func RunBasicMarketMaker(ctx context.Context, cfg *BotConfig, c clientCore, oracle oracle, baseFiatRate, quoteFiatRate float64, log dex.Logger) { +func RunBasicMarketMaker(ctx context.Context, cfg *BotConfig, c botCoreAdaptor, oracle oracle, baseFiatRate, quoteFiatRate float64, log dex.Logger) { if cfg.BasicMMConfig == nil { // implies bug in caller log.Errorf("No market making config provided. Exiting.") diff --git a/client/mm/mm_basic_test.go b/client/mm/mm_basic_test.go index fe20e1f75d..e605915bee 100644 --- a/client/mm/mm_basic_test.go +++ b/client/mm/mm_basic_test.go @@ -1051,7 +1051,7 @@ func TestRebalance(t *testing.T) { epoch = newEpoch } - cancels, buys, sells := basicMMRebalance(epoch, tt.rebalancer, tCore, tt.cfg, mkt, buyFees, sellFees, log) + cancels, buys, sells := basicMMRebalance(epoch, tt.rebalancer, newTBotCoreAdaptor(tCore), tt.cfg, mkt, buyFees, sellFees, log) if len(cancels) != len(tt.expectedCancels) { t.Fatalf("%s: cancel count mismatch. expected %d, got %d", tt.name, len(tt.expectedCancels), len(cancels)) @@ -1246,7 +1246,7 @@ func TestBreakEvenHalfSpread(t *testing.T) { tCore.singleLotFeesErr = tt.singleLotFeesErr basicMM := &basicMarketMaker{ - core: tCore, + core: newTBotCoreAdaptor(tCore), mkt: mkt, log: log, } diff --git a/client/mm/mm_simple_arb.go b/client/mm/mm_simple_arb.go index ceec31d01f..13f0cf5b28 100644 --- a/client/mm/mm_simple_arb.go +++ b/client/mm/mm_simple_arb.go @@ -71,15 +71,12 @@ type arbSequence struct { } type simpleArbMarketMaker struct { - ctx context.Context - host string - baseID uint32 - quoteID uint32 - cex cex - // cexTradeUpdatesID is passed to the Trade function of the cex - // so that the cex knows to send update notifications for the - // trade back to this bot. - core clientCore + ctx context.Context + host string + baseID uint32 + quoteID uint32 + cex botCexAdaptor + core botCoreAdaptor log dex.Logger cfg *SimpleArbConfig mkt *core.Market @@ -129,7 +126,8 @@ func (a *simpleArbMarketMaker) rebalanceAsset(base bool) { } if (dexBalance.Available+cexBalance.Available)/2 < minAmount { - a.log.Warnf("Cannot rebalance %s because balance is too low on both DEX and CEX", symbol) + a.log.Warnf("Cannot rebalance %s because balance is too low on both DEX and CEX. Min amount: %v, CEX balance: %v, DEX Balance: %v", + symbol, minAmount, dexBalance.Available, cexBalance.Available) return } @@ -189,6 +187,7 @@ func (a *simpleArbMarketMaker) rebalance(newEpoch uint64) { return } defer a.rebalanceRunning.Store(false) + a.log.Tracef("rebalance: epoch %d", newEpoch) exists, sellOnDex, lotsToArb, dexRate, cexRate := a.arbExists() if exists { @@ -371,7 +370,7 @@ func (a *simpleArbMarketMaker) executeArb(sellOnDex bool, lotsToArb, dexRate, ce defer a.activeArbsMtx.Unlock() // Place cex order first. If placing dex order fails then can freely cancel cex order. - cexTradeID, err := a.cex.Trade(a.ctx, a.baseID, a.quoteID, !sellOnDex, cexRate, lotsToArb*a.mkt.LotSize) + cexTrade, err := a.cex.Trade(a.ctx, a.baseID, a.quoteID, !sellOnDex, cexRate, lotsToArb*a.mkt.LotSize) if err != nil { a.log.Errorf("error placing cex order: %v", err) return @@ -405,7 +404,7 @@ func (a *simpleArbMarketMaker) executeArb(sellOnDex bool, lotsToArb, dexRate, ce a.log.Errorf("expected 1 dex order, got %v", len(dexOrders)) } - err := a.cex.CancelTrade(a.ctx, a.baseID, a.quoteID, cexTradeID) + err := a.cex.CancelTrade(a.ctx, a.baseID, a.quoteID, cexTrade.ID) if err != nil { a.log.Errorf("error canceling cex order: %v", err) // TODO: keep retrying failed cancel @@ -416,7 +415,7 @@ func (a *simpleArbMarketMaker) executeArb(sellOnDex bool, lotsToArb, dexRate, ce a.activeArbs = append(a.activeArbs, &arbSequence{ dexOrder: dexOrders[0], dexRate: dexRate, - cexOrderID: cexTradeID, + cexOrderID: cexTrade.ID, cexRate: cexRate, sellOnDEX: sellOnDex, startEpoch: epoch, @@ -488,7 +487,7 @@ func (a *simpleArbMarketMaker) removeActiveArb(i int) { // handleCEXTradeUpdate is called when the CEX sends a notification that the // status of a trade has changed. -func (a *simpleArbMarketMaker) handleCEXTradeUpdate(update *libxc.TradeUpdate) { +func (a *simpleArbMarketMaker) handleCEXTradeUpdate(update *libxc.Trade) { if !update.Complete { return } @@ -497,7 +496,7 @@ func (a *simpleArbMarketMaker) handleCEXTradeUpdate(update *libxc.TradeUpdate) { defer a.activeArbsMtx.Unlock() for i, arb := range a.activeArbs { - if arb.cexOrderID == update.TradeID { + if arb.cexOrderID == update.ID { arb.cexOrderFilled = true if arb.dexOrderFilled { a.removeActiveArb(i) @@ -617,7 +616,7 @@ func (a *simpleArbMarketMaker) cancelAllOrders() { } } -func RunSimpleArbBot(ctx context.Context, cfg *BotConfig, c clientCore, cex cex, log dex.Logger) { +func RunSimpleArbBot(ctx context.Context, cfg *BotConfig, c botCoreAdaptor, cex botCexAdaptor, log dex.Logger) { if cfg.SimpleArbConfig == nil { // implies bug in caller log.Errorf("No arb config provided. Exiting.") diff --git a/client/mm/mm_simple_arb_test.go b/client/mm/mm_simple_arb_test.go index b70ba2f4b5..b1a989078c 100644 --- a/client/mm/mm_simple_arb_test.go +++ b/client/mm/mm_simple_arb_test.go @@ -5,7 +5,6 @@ import ( "context" "errors" "fmt" - "sync" "testing" "decred.org/dcrdex/client/asset" @@ -19,227 +18,6 @@ import ( var log = dex.StdOutLogger("T", dex.LevelTrace) -type vwapResult struct { - avg uint64 - extrema uint64 -} - -type dexOrder struct { - lots, rate uint64 - sell bool -} - -type cexOrder struct { - baseID, quoteID uint32 - qty, rate uint64 - sell bool -} - -type withdrawArgs struct { - address string - amt uint64 - assetID uint32 -} - -type tCEX struct { - bidsVWAP map[uint64]vwapResult - asksVWAP map[uint64]vwapResult - vwapErr error - balances map[uint32]*libxc.ExchangeBalance - balanceErr error - tradeID string - tradeErr error - lastTrade *cexOrder - cancelledTrades []string - cancelTradeErr error - tradeUpdates chan *libxc.TradeUpdate - tradeUpdatesID int - lastConfirmDepositTx string - confirmDepositAmt uint64 - depositConfirmed bool - depositAddress string - withdrawAmt uint64 - withdrawTxID string - lastWithdrawArgs *withdrawArgs -} - -func newTCEX() *tCEX { - return &tCEX{ - bidsVWAP: make(map[uint64]vwapResult), - asksVWAP: make(map[uint64]vwapResult), - balances: make(map[uint32]*libxc.ExchangeBalance), - cancelledTrades: make([]string, 0), - tradeUpdates: make(chan *libxc.TradeUpdate), - } -} - -var _ libxc.CEX = (*tCEX)(nil) - -func (c *tCEX) Connect(ctx context.Context) (*sync.WaitGroup, error) { - return nil, nil -} -func (c *tCEX) Balances() (map[uint32]*libxc.ExchangeBalance, error) { - return nil, nil -} -func (c *tCEX) Markets() ([]*libxc.Market, error) { - return nil, nil -} -func (c *tCEX) Balance(assetID uint32) (*libxc.ExchangeBalance, error) { - return c.balances[assetID], c.balanceErr -} -func (c *tCEX) Trade(ctx context.Context, baseID, quoteID uint32, sell bool, rate, qty uint64, updaterID int) (string, error) { - if c.tradeErr != nil { - return "", c.tradeErr - } - c.lastTrade = &cexOrder{baseID, quoteID, qty, rate, sell} - return c.tradeID, nil -} -func (c *tCEX) CancelTrade(ctx context.Context, seID, quoteID uint32, tradeID string) error { - if c.cancelTradeErr != nil { - return c.cancelTradeErr - } - c.cancelledTrades = append(c.cancelledTrades, tradeID) - return nil -} -func (c *tCEX) SubscribeMarket(ctx context.Context, baseID, quoteID uint32) error { - return nil -} -func (c *tCEX) UnsubscribeMarket(baseID, quoteID uint32) error { - return nil -} -func (c *tCEX) VWAP(baseID, quoteID uint32, sell bool, qty uint64) (vwap, extrema uint64, filled bool, err error) { - if c.vwapErr != nil { - return 0, 0, false, c.vwapErr - } - - if sell { - res, found := c.asksVWAP[qty] - if !found { - return 0, 0, false, nil - } - return res.avg, res.extrema, true, nil - } - - res, found := c.bidsVWAP[qty] - if !found { - return 0, 0, false, nil - } - return res.avg, res.extrema, true, nil -} -func (c *tCEX) SubscribeTradeUpdates() (<-chan *libxc.TradeUpdate, func(), int) { - return c.tradeUpdates, func() {}, c.tradeUpdatesID -} -func (c *tCEX) SubscribeCEXUpdates() (<-chan interface{}, func()) { - return nil, func() {} -} -func (c *tCEX) GetDepositAddress(ctx context.Context, assetID uint32) (string, error) { - return c.depositAddress, nil -} - -func (c *tCEX) Withdraw(ctx context.Context, assetID uint32, qty uint64, address string, onComplete func(uint64, string)) error { - c.lastWithdrawArgs = &withdrawArgs{ - address: address, - amt: qty, - assetID: assetID, - } - onComplete(c.withdrawAmt, c.withdrawTxID) - return nil -} - -func (c *tCEX) ConfirmDeposit(ctx context.Context, txID string, onConfirm func(bool, uint64)) { - c.lastConfirmDepositTx = txID - onConfirm(c.depositConfirmed, c.confirmDepositAmt) -} - -type tWrappedCEX struct { - bidsVWAP map[uint64]vwapResult - asksVWAP map[uint64]vwapResult - vwapErr error - balances map[uint32]*libxc.ExchangeBalance - balanceErr error - tradeID string - tradeErr error - lastTrade *cexOrder - cancelledTrades []string - cancelTradeErr error - tradeUpdates chan *libxc.TradeUpdate - lastWithdrawArgs *withdrawArgs - lastDepositArgs *withdrawArgs - confirmDeposit func() - confirmWithdraw func() -} - -func newTWrappedCEX() *tWrappedCEX { - return &tWrappedCEX{ - bidsVWAP: make(map[uint64]vwapResult), - asksVWAP: make(map[uint64]vwapResult), - balances: make(map[uint32]*libxc.ExchangeBalance), - cancelledTrades: make([]string, 0), - tradeUpdates: make(chan *libxc.TradeUpdate), - } -} - -var _ cex = (*tWrappedCEX)(nil) - -func (c *tWrappedCEX) Balance(assetID uint32) (*libxc.ExchangeBalance, error) { - return c.balances[assetID], c.balanceErr -} -func (c *tWrappedCEX) CancelTrade(ctx context.Context, baseID, quoteID uint32, tradeID string) error { - if c.cancelTradeErr != nil { - return c.cancelTradeErr - } - c.cancelledTrades = append(c.cancelledTrades, tradeID) - return nil -} -func (c *tWrappedCEX) SubscribeMarket(ctx context.Context, baseID, quoteID uint32) error { - return nil -} -func (c *tWrappedCEX) SubscribeTradeUpdates() (updates <-chan *libxc.TradeUpdate, unsubscribe func()) { - return c.tradeUpdates, func() {} -} -func (c *tWrappedCEX) Trade(ctx context.Context, baseID, quoteID uint32, sell bool, rate, qty uint64) (string, error) { - if c.tradeErr != nil { - return "", c.tradeErr - } - c.lastTrade = &cexOrder{baseID, quoteID, qty, rate, sell} - return c.tradeID, nil -} -func (c *tWrappedCEX) VWAP(baseID, quoteID uint32, sell bool, qty uint64) (vwap, extrema uint64, filled bool, err error) { - if c.vwapErr != nil { - return 0, 0, false, c.vwapErr - } - - if sell { - res, found := c.asksVWAP[qty] - if !found { - return 0, 0, false, nil - } - return res.avg, res.extrema, true, nil - } - - res, found := c.bidsVWAP[qty] - if !found { - return 0, 0, false, nil - } - return res.avg, res.extrema, true, nil -} -func (c *tWrappedCEX) Deposit(ctx context.Context, assetID uint32, amount uint64, onConfirm func()) error { - c.lastDepositArgs = &withdrawArgs{ - assetID: assetID, - amt: amount, - } - c.confirmDeposit = onConfirm - return nil -} -func (c *tWrappedCEX) Withdraw(ctx context.Context, assetID uint32, amount uint64, onConfirm func()) error { - c.lastWithdrawArgs = &withdrawArgs{ - assetID: assetID, - amt: amount, - } - c.confirmWithdraw = onConfirm - return nil -} - func TestArbRebalance(t *testing.T) { mkt := &core.Market{ LotSize: uint64(40 * 1e8), @@ -377,7 +155,7 @@ func TestArbRebalance(t *testing.T) { // The strategy uses maxSell/maxBuy to determine how much it can trade. // dexBalances is just used for auto rebalancing. dexBalances map[uint32]uint64 - cexBalances map[uint32]*libxc.ExchangeBalance + cexBalances map[uint32]*botBalance dexVWAPErr error cexVWAPErr error cexTradeErr error @@ -388,7 +166,7 @@ func TestArbRebalance(t *testing.T) { autoRebalance *AutoRebalanceConfig expectedDexOrder *dexOrder - expectedCexOrder *cexOrder + expectedCexOrder *libxc.Trade expectedDEXCancels []dex.Bytes expectedCEXCancels []string expectedWithdrawal *assetAmt @@ -410,7 +188,7 @@ func TestArbRebalance(t *testing.T) { Lots: 5, }, }, - cexBalances: map[uint32]*libxc.ExchangeBalance{ + cexBalances: map[uint32]*botBalance{ 0: {Available: 1e19}, 42: {Available: 1e19}, }, @@ -429,7 +207,7 @@ func TestArbRebalance(t *testing.T) { Lots: 5, }, }, - cexBalances: map[uint32]*libxc.ExchangeBalance{ + cexBalances: map[uint32]*botBalance{ 0: {Available: 1e19}, 42: {Available: 1e19}, }, @@ -438,12 +216,12 @@ func TestArbRebalance(t *testing.T) { rate: 2e6, sell: false, }, - expectedCexOrder: &cexOrder{ - baseID: 42, - quoteID: 0, - qty: mkt.LotSize, - rate: 2.2e6, - sell: true, + expectedCexOrder: &libxc.Trade{ + BaseID: 42, + QuoteID: 0, + Qty: mkt.LotSize, + Rate: 2.2e6, + Sell: true, }, }, // "1 lot, buy on dex, sell on cex, but dex base balance not enough" @@ -460,7 +238,7 @@ func TestArbRebalance(t *testing.T) { Lots: 5, }, }, - cexBalances: map[uint32]*libxc.ExchangeBalance{ + cexBalances: map[uint32]*botBalance{ 0: {Available: 1e19}, 42: {Available: mkt.LotSize / 2}, }, @@ -480,7 +258,7 @@ func TestArbRebalance(t *testing.T) { }, }, - cexBalances: map[uint32]*libxc.ExchangeBalance{ + cexBalances: map[uint32]*botBalance{ 0: {Available: 1e19}, 42: {Available: 1e19}, }, @@ -490,12 +268,12 @@ func TestArbRebalance(t *testing.T) { rate: 2e6, sell: false, }, - expectedCexOrder: &cexOrder{ - baseID: 42, - quoteID: 0, - qty: mkt.LotSize, - rate: 2.2e6, - sell: true, + expectedCexOrder: &libxc.Trade{ + BaseID: 42, + QuoteID: 0, + Qty: mkt.LotSize, + Rate: 2.2e6, + Sell: true, }, }, // "1 lot, sell on dex, buy on cex" @@ -512,7 +290,7 @@ func TestArbRebalance(t *testing.T) { Lots: 5, }, }, - cexBalances: map[uint32]*libxc.ExchangeBalance{ + cexBalances: map[uint32]*botBalance{ 0: {Available: 1e19}, 42: {Available: 1e19}, }, @@ -521,12 +299,12 @@ func TestArbRebalance(t *testing.T) { rate: 2.2e6, sell: true, }, - expectedCexOrder: &cexOrder{ - baseID: 42, - quoteID: 0, - qty: mkt.LotSize, - rate: 2e6, - sell: false, + expectedCexOrder: &libxc.Trade{ + BaseID: 42, + QuoteID: 0, + Qty: mkt.LotSize, + Rate: 2e6, + Sell: false, }, }, // "2 lot, buy on cex, sell on dex, but cex quote balance only enough for 1" @@ -543,7 +321,7 @@ func TestArbRebalance(t *testing.T) { Lots: 5, }, }, - cexBalances: map[uint32]*libxc.ExchangeBalance{ + cexBalances: map[uint32]*botBalance{ 0: {Available: calc.BaseToQuote(2e6, mkt.LotSize*3/2)}, 42: {Available: 1e19}, }, @@ -552,12 +330,12 @@ func TestArbRebalance(t *testing.T) { rate: 2.2e6, sell: true, }, - expectedCexOrder: &cexOrder{ - baseID: 42, - quoteID: 0, - qty: mkt.LotSize, - rate: 2e6, - sell: false, + expectedCexOrder: &libxc.Trade{ + BaseID: 42, + QuoteID: 0, + Qty: mkt.LotSize, + Rate: 2e6, + Sell: false, }, }, // "1 lot, sell on dex, buy on cex" @@ -574,7 +352,7 @@ func TestArbRebalance(t *testing.T) { Lots: 5, }, }, - cexBalances: map[uint32]*libxc.ExchangeBalance{ + cexBalances: map[uint32]*botBalance{ 0: {Available: 1e19}, 42: {Available: 1e19}, }, @@ -583,12 +361,12 @@ func TestArbRebalance(t *testing.T) { rate: 2.2e6, sell: true, }, - expectedCexOrder: &cexOrder{ - baseID: 42, - quoteID: 0, - qty: mkt.LotSize, - rate: 2e6, - sell: false, + expectedCexOrder: &libxc.Trade{ + BaseID: 42, + QuoteID: 0, + Qty: mkt.LotSize, + Rate: 2e6, + Sell: false, }, }, // "2 lots arb still above profit trigger, but second not worth it on its own" @@ -605,7 +383,7 @@ func TestArbRebalance(t *testing.T) { Lots: 5, }, }, - cexBalances: map[uint32]*libxc.ExchangeBalance{ + cexBalances: map[uint32]*botBalance{ 0: {Available: 1e19}, 42: {Available: 1e19}, }, @@ -614,12 +392,12 @@ func TestArbRebalance(t *testing.T) { rate: 2e6, sell: false, }, - expectedCexOrder: &cexOrder{ - baseID: 42, - quoteID: 0, - qty: mkt.LotSize, - rate: 2.2e6, - sell: true, + expectedCexOrder: &libxc.Trade{ + BaseID: 42, + QuoteID: 0, + Qty: mkt.LotSize, + Rate: 2.2e6, + Sell: true, }, }, // "2 lot, buy on cex, sell on dex, but cex quote balance only enough for 1" @@ -636,7 +414,7 @@ func TestArbRebalance(t *testing.T) { Lots: 5, }, }, - cexBalances: map[uint32]*libxc.ExchangeBalance{ + cexBalances: map[uint32]*botBalance{ 0: {Available: calc.BaseToQuote(2e6, mkt.LotSize*3/2)}, 42: {Available: 1e19}, }, @@ -645,12 +423,12 @@ func TestArbRebalance(t *testing.T) { rate: 2.2e6, sell: true, }, - expectedCexOrder: &cexOrder{ - baseID: 42, - quoteID: 0, - qty: mkt.LotSize, - rate: 2e6, - sell: false, + expectedCexOrder: &libxc.Trade{ + BaseID: 42, + QuoteID: 0, + Qty: mkt.LotSize, + Rate: 2e6, + Sell: false, }, }, // "cex no asks" @@ -680,7 +458,7 @@ func TestArbRebalance(t *testing.T) { }, }, - cexBalances: map[uint32]*libxc.ExchangeBalance{ + cexBalances: map[uint32]*botBalance{ 0: {Available: 1e19}, 42: {Available: 1e19}, }, @@ -712,7 +490,7 @@ func TestArbRebalance(t *testing.T) { }, }, - cexBalances: map[uint32]*libxc.ExchangeBalance{ + cexBalances: map[uint32]*botBalance{ 0: {Available: 1e19}, 42: {Available: 1e19}, }, @@ -726,7 +504,7 @@ func TestArbRebalance(t *testing.T) { Lots: 5, }, }, - cexBalances: map[uint32]*libxc.ExchangeBalance{ + cexBalances: map[uint32]*botBalance{ 0: {Available: 1e19}, 42: {Available: 1e19}, }, @@ -741,7 +519,7 @@ func TestArbRebalance(t *testing.T) { Lots: 5, }, }, - cexBalances: map[uint32]*libxc.ExchangeBalance{ + cexBalances: map[uint32]*botBalance{ 0: {Available: 1e19}, 42: {Available: 1e19}, }, @@ -761,7 +539,7 @@ func TestArbRebalance(t *testing.T) { Lots: 5, }, }, - cexBalances: map[uint32]*libxc.ExchangeBalance{ + cexBalances: map[uint32]*botBalance{ 0: {Available: 1e19}, 42: {Available: 1e19}, }, @@ -781,7 +559,7 @@ func TestArbRebalance(t *testing.T) { Lots: 5, }, }, - cexBalances: map[uint32]*libxc.ExchangeBalance{ + cexBalances: map[uint32]*botBalance{ 0: {Available: 1e19}, 42: {Available: 1e19}, }, @@ -802,7 +580,7 @@ func TestArbRebalance(t *testing.T) { }, }, - cexBalances: map[uint32]*libxc.ExchangeBalance{ + cexBalances: map[uint32]*botBalance{ 0: {Available: 1e19}, 42: {Available: 1e19}, }, @@ -872,7 +650,7 @@ func TestArbRebalance(t *testing.T) { }, expectedCEXCancels: []string{cexTradeIDs[1], cexTradeIDs[3]}, expectedDEXCancels: []dex.Bytes{orderIDs[1][:], orderIDs[2][:]}, - cexBalances: map[uint32]*libxc.ExchangeBalance{ + cexBalances: map[uint32]*botBalance{ 0: {Available: 1e19}, 42: {Available: 1e19}, }, @@ -891,7 +669,7 @@ func TestArbRebalance(t *testing.T) { Lots: 5, }, }, - cexBalances: map[uint32]*libxc.ExchangeBalance{ + cexBalances: map[uint32]*botBalance{ 0: {Available: 1e19}, 42: {Available: 1e19}, }, @@ -952,7 +730,7 @@ func TestArbRebalance(t *testing.T) { Lots: 5, }, }, - cexBalances: map[uint32]*libxc.ExchangeBalance{ + cexBalances: map[uint32]*botBalance{ 0: {Available: 1e19}, 42: {Available: 1e19}, }, @@ -976,7 +754,7 @@ func TestArbRebalance(t *testing.T) { 42: 1e14, 0: 1e17, }, - cexBalances: map[uint32]*libxc.ExchangeBalance{ + cexBalances: map[uint32]*botBalance{ 42: {Available: 1e19}, 0: {Available: 1e10}, }, @@ -1011,7 +789,7 @@ func TestArbRebalance(t *testing.T) { 42: 9.5e15, 0: 1.1e12, }, - cexBalances: map[uint32]*libxc.ExchangeBalance{ + cexBalances: map[uint32]*botBalance{ 42: {Available: 1.1e16}, 0: {Available: 9.5e11}, }, @@ -1048,7 +826,7 @@ func TestArbRebalance(t *testing.T) { 42: 9.5e15, 0: 1.1e12, }, - cexBalances: map[uint32]*libxc.ExchangeBalance{ + cexBalances: map[uint32]*botBalance{ 42: {Available: 1.1e16}, 0: {Available: 9.5e11}, }, @@ -1077,7 +855,7 @@ func TestArbRebalance(t *testing.T) { 42: 1e19, 0: 1e10, }, - cexBalances: map[uint32]*libxc.ExchangeBalance{ + cexBalances: map[uint32]*botBalance{ 42: {Available: 1e14}, 0: {Available: 1e17}, }, @@ -1112,7 +890,7 @@ func TestArbRebalance(t *testing.T) { 42: 1e19, 0: 1e10, }, - cexBalances: map[uint32]*libxc.ExchangeBalance{ + cexBalances: map[uint32]*botBalance{ 42: {Available: 1e14}, 0: {Available: 1e17}, }, @@ -1126,7 +904,7 @@ func TestArbRebalance(t *testing.T) { } runTest := func(test *test) { - cex := newTWrappedCEX() + cex := newTBotCEXAdaptor() cex.vwapErr = test.cexVWAPErr cex.balances = test.cexBalances cex.tradeErr = test.cexTradeErr @@ -1173,7 +951,7 @@ func TestArbRebalance(t *testing.T) { mkt: mkt, baseID: 42, quoteID: 0, - core: tCore, + core: newTBotCoreAdaptor(tCore), activeArbs: test.existingArbs, cfg: &SimpleArbConfig{ ProfitTrigger: profitTrigger, @@ -1417,7 +1195,7 @@ func TestArbDexTradeUpdates(t *testing.T) { } runTest := func(test *test) { - cex := newTWrappedCEX() + cex := newTBotCEXAdaptor() tCore := newTCore() ctx, cancel := context.WithCancel(context.Background()) @@ -1429,7 +1207,7 @@ func TestArbDexTradeUpdates(t *testing.T) { cex: cex, baseID: 42, quoteID: 0, - core: tCore, + core: newTBotCoreAdaptor(tCore), activeArbs: test.activeArbs, cfg: &SimpleArbConfig{ ProfitTrigger: 0.01, @@ -1539,9 +1317,7 @@ func TestCexTradeUpdates(t *testing.T) { } runTest := func(test *test) { - cex := newTWrappedCEX() - tCore := newTCore() - + cex := newTBotCEXAdaptor() ctx, cancel := context.WithCancel(context.Background()) defer cancel() @@ -1551,7 +1327,7 @@ func TestCexTradeUpdates(t *testing.T) { cex: cex, baseID: 42, quoteID: 0, - core: tCore, + core: newTBotCoreAdaptor(newTCore()), activeArbs: test.activeArbs, cfg: &SimpleArbConfig{ ProfitTrigger: 0.01, @@ -1562,13 +1338,13 @@ func TestCexTradeUpdates(t *testing.T) { go arbEngine.run() - cex.tradeUpdates <- &libxc.TradeUpdate{ - TradeID: test.updatedOrderID, + cex.tradeUpdates <- &libxc.Trade{ + ID: test.updatedOrderID, Complete: test.orderComplete, } // send dummy update - cex.tradeUpdates <- &libxc.TradeUpdate{ - TradeID: "", + cex.tradeUpdates <- &libxc.Trade{ + ID: "", } if len(test.expectedActiveArbs) != len(arbEngine.activeArbs) { diff --git a/client/mm/mm_test.go b/client/mm/mm_test.go index 95ba31d116..a4c09bd1c8 100644 --- a/client/mm/mm_test.go +++ b/client/mm/mm_test.go @@ -5,9 +5,6 @@ import ( "encoding/hex" "fmt" "math/rand" - "os" - "path/filepath" - "reflect" "sync" "testing" "time" @@ -18,8 +15,6 @@ import ( "decred.org/dcrdex/client/mm/libxc" "decred.org/dcrdex/client/orderbook" "decred.org/dcrdex/dex" - "decred.org/dcrdex/dex/calc" - "decred.org/dcrdex/dex/encode" "decred.org/dcrdex/dex/order" ) @@ -57,6 +52,17 @@ var ( SwapConf: 1, } + tACCTAssetB = &dex.Asset{ + ID: 966, + Symbol: "polygon", + Version: 0, // match the stubbed (*TXCWallet).Info result + SwapSize: 135000, + SwapSizeBase: 135000, + RedeemSize: 68000, + MaxFeeRate: 20, + SwapConf: 1, + } + tWalletInfo = &asset.WalletInfo{ Version: 0, SupportedVersions: []uint32{0}, @@ -88,6 +94,18 @@ func init() { winfo: tWalletInfo, }, }) + asset.Register(tACCTAssetB.ID, &tCreator{ + tDriver: &tDriver{ + decodedCoinID: tACCTAssetB.Symbol + "-coin", + winfo: tWalletInfo, + }, + }) + asset.RegisterToken(60001, &dex.Token{ + ParentID: 60, + }, &asset.WalletDefinition{}, nil) + asset.RegisterToken(966001, &dex.Token{ + ParentID: 966, + }, nil, nil) rand.Seed(time.Now().UnixNano()) } @@ -132,6 +150,28 @@ func (t *tBookFeed) Next() <-chan *core.BookUpdate { return t.c } func (t *tBookFeed) Close() {} func (t *tBookFeed) Candles(dur string) error { return nil } +var _ core.BookFeed = (*tBookFeed)(nil) + +type tCoin struct { + txID []byte + value uint64 +} + +var _ asset.Coin = (*tCoin)(nil) + +func (c *tCoin) ID() dex.Bytes { + return c.txID +} +func (c *tCoin) String() string { + return hex.EncodeToString(c.txID) +} +func (c *tCoin) Value() uint64 { + return c.value +} +func (c *tCoin) TxID() string { + return hex.EncodeToString(c.txID) +} + type sendArgs struct { assetID uint32 value uint64 @@ -140,53 +180,60 @@ type sendArgs struct { } type tCore struct { - assetBalances map[uint32]*core.WalletBalance - assetBalanceErr error - market *core.Market - orderEstimate *core.OrderEstimate - sellSwapFees uint64 - sellRedeemFees uint64 - sellRefundFees uint64 - buySwapFees uint64 - buyRedeemFees uint64 - buyRefundFees uint64 - singleLotFeesErr error - preOrderParam *core.TradeForm - tradeResult *core.Order - multiTradeResult []*core.Order - noteFeed chan core.Notification - isAccountLocker map[uint32]bool - isWithdrawer map[uint32]bool - maxBuyEstimate *core.MaxOrderEstimate - maxBuyErr error - maxSellEstimate *core.MaxOrderEstimate - maxSellErr error - cancelsPlaced []dex.Bytes - buysPlaced []*core.TradeForm - sellsPlaced []*core.TradeForm - multiTradesPlaced []*core.MultiTradeForm - maxFundingFees uint64 - book *orderbook.OrderBook - bookFeed *tBookFeed - lastSendArgs *sendArgs - txConfs uint32 - txConfsErr error - txConfsTxID string - newDepositAddress string + assetBalances map[uint32]*core.WalletBalance + assetBalanceErr error + market *core.Market + orderEstimate *core.OrderEstimate + sellSwapFees uint64 + sellRedeemFees uint64 + sellRefundFees uint64 + buySwapFees uint64 + buyRedeemFees uint64 + buyRefundFees uint64 + singleLotFeesErr error + preOrderParam *core.TradeForm + tradeResult *core.Order + multiTradeResult []*core.Order + noteFeed chan core.Notification + isAccountLocker map[uint32]bool + isWithdrawer map[uint32]bool + isDynamicSwapper map[uint32]bool + maxBuyEstimate *core.MaxOrderEstimate + maxBuyErr error + maxSellEstimate *core.MaxOrderEstimate + maxSellErr error + cancelsPlaced []dex.Bytes + buysPlaced []*core.TradeForm + sellsPlaced []*core.TradeForm + multiTradesPlaced []*core.MultiTradeForm + maxFundingFees uint64 + book *orderbook.OrderBook + bookFeed *tBookFeed + lastSendArgs *sendArgs + sendCoin *tCoin + newDepositAddress string + orders map[order.OrderID]*core.Order + walletTxs map[string]*asset.WalletTransaction + confirmWalletTx chan bool + confirmWalletTxComplete chan bool } func newTCore() *tCore { return &tCore{ - assetBalances: make(map[uint32]*core.WalletBalance), - noteFeed: make(chan core.Notification), - isAccountLocker: make(map[uint32]bool), - isWithdrawer: make(map[uint32]bool), - cancelsPlaced: make([]dex.Bytes, 0), - buysPlaced: make([]*core.TradeForm, 0), - sellsPlaced: make([]*core.TradeForm, 0), + assetBalances: make(map[uint32]*core.WalletBalance), + noteFeed: make(chan core.Notification), + isAccountLocker: make(map[uint32]bool), + isWithdrawer: make(map[uint32]bool), + isDynamicSwapper: make(map[uint32]bool), + cancelsPlaced: make([]dex.Bytes, 0), + buysPlaced: make([]*core.TradeForm, 0), + sellsPlaced: make([]*core.TradeForm, 0), bookFeed: &tBookFeed{ c: make(chan *core.BookUpdate, 1), }, + walletTxs: make(map[string]*asset.WalletTransaction), + confirmWalletTx: make(chan bool), + confirmWalletTxComplete: make(chan bool), } } @@ -199,8 +246,6 @@ func (c *tCore) ExchangeMarket(host string, base, quote uint32) (*core.Market, e return c.market, nil } -var _ core.BookFeed = (*tBookFeed)(nil) - func (t *tCore) SyncBook(host string, base, quote uint32) (*orderbook.OrderBook, core.BookFeed, error) { return t.book, t.bookFeed, nil } @@ -220,14 +265,6 @@ func (c *tCore) Cancel(oidB dex.Bytes) error { c.cancelsPlaced = append(c.cancelsPlaced, oidB) return nil } -func (c *tCore) Trade(pw []byte, form *core.TradeForm) (*core.Order, error) { - if form.Sell { - c.sellsPlaced = append(c.sellsPlaced, form) - } else { - c.buysPlaced = append(c.buysPlaced, form) - } - return c.tradeResult, nil -} func (c *tCore) MaxBuy(host string, base, quote uint32, rate uint64) (*core.MaxOrderEstimate, error) { if c.maxBuyErr != nil { return nil, c.maxBuyErr @@ -254,6 +291,7 @@ func (c *tCore) MultiTrade(pw []byte, forms *core.MultiTradeForm) ([]*core.Order func (c *tCore) WalletState(assetID uint32) *core.WalletState { isAccountLocker := c.isAccountLocker[assetID] isWithdrawer := c.isWithdrawer[assetID] + isDynamicSwapper := c.isDynamicSwapper[assetID] var traits asset.WalletTrait if isAccountLocker { @@ -262,6 +300,9 @@ func (c *tCore) WalletState(assetID uint32) *core.WalletState { if isWithdrawer { traits |= asset.WalletTraitWithdrawer } + if isDynamicSwapper { + traits |= asset.WalletTraitDynamicSwapper + } return &core.WalletState{ Traits: traits, @@ -279,28 +320,22 @@ func (c *tCore) OpenWallet(assetID uint32, pw []byte) error { func (c *tCore) User() *core.User { return nil } -func (c *tCore) Broadcast(core.Notification) {} -func (c *tCore) FiatConversionRates() map[uint32]float64 { - return nil +func (c *tCore) WalletTransaction(assetID uint32, id dex.Bytes) (*asset.WalletTransaction, error) { + return c.walletTxs[hex.EncodeToString(id)], nil } +func (c *tCore) ConfirmedWalletTransaction(assetID uint32, id dex.Bytes, onConfirm func(*asset.WalletTransaction)) error { + go func() { + <-c.confirmWalletTx + onConfirm(c.walletTxs[hex.EncodeToString(id)]) + c.confirmWalletTxComplete <- true + }() -type tCoin struct { - txID []byte + return nil } -var _ asset.Coin = (*tCoin)(nil) - -func (c *tCoin) ID() dex.Bytes { - return c.txID -} -func (c *tCoin) String() string { - return hex.EncodeToString(c.txID) -} -func (c *tCoin) Value() uint64 { - return 0 -} -func (c *tCoin) TxID() string { - return hex.EncodeToString(c.txID) +func (c *tCore) Broadcast(core.Notification) {} +func (c *tCore) FiatConversionRates() map[uint32]float64 { + return nil } func (c *tCore) Send(pw []byte, assetID uint32, value uint64, address string, subtract bool) (asset.Coin, error) { @@ -310,26 +345,18 @@ func (c *tCore) Send(pw []byte, assetID uint32, value uint64, address string, su address: address, subtract: subtract, } - return &tCoin{}, nil + return c.sendCoin, nil } func (c *tCore) NewDepositAddress(assetID uint32) (string, error) { return c.newDepositAddress, nil } -func (c *tCore) TransactionConfirmations(assetID uint32, txID string) (confirmations uint32, err error) { - c.txConfsTxID = txID - return c.txConfs, c.txConfsErr -} - -func tMaxOrderEstimate(lots uint64, swapFees, redeemFees uint64) *core.MaxOrderEstimate { - return &core.MaxOrderEstimate{ - Swap: &asset.SwapEstimate{ - RealisticWorstCase: swapFees, - Lots: lots, - }, - Redeem: &asset.RedeemEstimate{ - RealisticWorstCase: redeemFees, - }, +func (c *tCore) Order(id dex.Bytes) (*core.Order, error) { + var oid order.OrderID + copy(oid[:], id) + if o, found := c.orders[oid]; found { + return o, nil } + return nil, fmt.Errorf("order %s not found", id) } func (c *tCore) setAssetBalances(balances map[uint32]uint64) { @@ -345,13 +372,34 @@ func (c *tCore) setAssetBalances(balances map[uint32]uint64) { } } -func (c *tCore) clearTradesAndCancels() { - c.cancelsPlaced = make([]dex.Bytes, 0) - c.buysPlaced = make([]*core.TradeForm, 0) - c.sellsPlaced = make([]*core.TradeForm, 0) - c.multiTradesPlaced = make([]*core.MultiTradeForm, 0) +type tBotCoreAdaptor struct { + clientCore + tCore *tCore +} + +func (c *tBotCoreAdaptor) AssetBalance(assetID uint32) (*botBalance, error) { + if c.tCore.assetBalanceErr != nil { + return nil, c.tCore.assetBalanceErr + } + return &botBalance{ + Available: c.tCore.assetBalances[assetID].Available, + }, nil +} + +func (c *tBotCoreAdaptor) MultiTrade(pw []byte, form *core.MultiTradeForm) ([]*core.Order, error) { + c.tCore.multiTradesPlaced = append(c.tCore.multiTradesPlaced, form) + return c.tCore.multiTradeResult, nil +} + +func newTBotCoreAdaptor(c *tCore) *tBotCoreAdaptor { + return &tBotCoreAdaptor{ + clientCore: c, + tCore: c, + } } +var _ botCoreAdaptor = (*tBotCoreAdaptor)(nil) + type tOrderBook struct { midGap uint64 midGapErr error @@ -398,24 +446,7 @@ func (o *tOracle) getMarketPrice(base, quote uint32) float64 { return o.marketPrice } -func tNewMarketMaker(t *testing.T, c clientCore) (*MarketMaker, func()) { - t.Helper() - dir, _ := os.MkdirTemp("", "") - cfgPath := filepath.Join(dir, "mm.conf") - mm, err := NewMarketMaker(c, cfgPath, tLogger) - if err != nil { - if err != nil { - t.Fatalf("constructor error: %v", err) - } - } - return mm, func() { os.RemoveAll(dir) } -} - -var tLogger = dex.StdOutLogger("mm_TEST", dex.LevelTrace) - -func TestSetupBalances(t *testing.T) { - tCore := newTCore() - +func TestInitialBaseBalances(t *testing.T) { dcrBtcID := fmt.Sprintf("%s-%d-%d", "host1", 42, 0) dcrEthID := fmt.Sprintf("%s-%d-%d", "host1", 42, 60) @@ -788,7 +819,6 @@ func TestSetupBalances(t *testing.T) { }, }, }, - // "CEX combine amount and percentages" { name: "CEX combine amount and percentages, too high error", @@ -849,19 +879,20 @@ func TestSetupBalances(t *testing.T) { wantErr: true, }, - // "CEX same asset on different chains" { - name: "CEX combine amount and percentages, too high error", + name: "CEX same asset on different chains", cfgs: []*BotConfig{ { - Host: "host1", - BaseAsset: 60001, - QuoteAsset: 0, - BaseBalanceType: Percentage, - BaseBalance: 50, - QuoteBalanceType: Percentage, - QuoteBalance: 50, + Host: "host1", + BaseAsset: 60001, + QuoteAsset: 0, + BaseBalanceType: Percentage, + BaseBalance: 50, + QuoteBalanceType: Percentage, + QuoteBalance: 50, + BaseFeeAssetBalanceType: Amount, + BaseFeeAssetBalance: 500, CEXCfg: &BotCEXCfg{ Name: "Binance", @@ -872,13 +903,15 @@ func TestSetupBalances(t *testing.T) { }, }, { - Host: "host1", - BaseAsset: 966001, - QuoteAsset: 60, - BaseBalanceType: Percentage, - BaseBalance: 50, - QuoteBalanceType: Percentage, - QuoteBalance: 50, + Host: "host1", + BaseAsset: 60, + QuoteAsset: 966001, + BaseBalanceType: Percentage, + BaseBalance: 50, + QuoteBalanceType: Percentage, + QuoteBalance: 50, + QuoteFeeAssetBalanceType: Amount, + QuoteFeeAssetBalance: 500, CEXCfg: &BotCEXCfg{ Name: "Binance", @@ -893,6 +926,7 @@ func TestSetupBalances(t *testing.T) { assetBalances: map[uint32]uint64{ 0: 1000, 60: 2000, + 966: 1000, 60001: 2000, 966001: 2000, }, @@ -911,9 +945,11 @@ func TestSetupBalances(t *testing.T) { dexMarketID("host1", 60001, 0): { 60001: 1000, 0: 500, + 60: 500, }, - dexMarketID("host1", 966001, 60): { + dexMarketID("host1", 60, 966001): { 966001: 1000, + 966: 500, 60: 1000, }, }, @@ -923,16 +959,79 @@ func TestSetupBalances(t *testing.T) { 60001: 1000, 0: 1500, }, - dexMarketID("host1", 966001, 60): { + dexMarketID("host1", 60, 966001): { 966001: 1000, 60: 1000, }, }, }, - // "CEX same asset on different chains, too high error" { - name: "CEX combine amount and percentages, too high error", + name: "CEX same asset on different chains, too high error", + cfgs: []*BotConfig{ + { + Host: "host1", + BaseAsset: 60001, + QuoteAsset: 0, + BaseBalanceType: Percentage, + BaseBalance: 50, + QuoteBalanceType: Percentage, + QuoteBalance: 50, + BaseFeeAssetBalanceType: Amount, + BaseFeeAssetBalance: 1, + + CEXCfg: &BotCEXCfg{ + Name: "Binance", + BaseBalanceType: Percentage, + BaseBalance: 50, + QuoteBalanceType: Percentage, + QuoteBalance: 50, + }, + }, + { + Host: "host1", + BaseAsset: 966001, + QuoteAsset: 60, + BaseBalanceType: Percentage, + BaseBalance: 50, + QuoteBalanceType: Percentage, + QuoteBalance: 100, + BaseFeeAssetBalanceType: Amount, + BaseFeeAssetBalance: 1, + + CEXCfg: &BotCEXCfg{ + Name: "Binance", + BaseBalanceType: Percentage, + BaseBalance: 51, + QuoteBalanceType: Percentage, + QuoteBalance: 100, + }, + }, + }, + + assetBalances: map[uint32]uint64{ + 0: 1000, + 60: 2000, + 966: 1000, + 60001: 2000, + 966001: 2000, + }, + + cexBalances: map[string]map[uint32]uint64{ + "Binance": { + 0: 3000, + 60: 2000, + 60001: 2000, + 966001: 2000, + 61001: 2000, + }, + }, + + wantErr: true, + }, + // "No base fee asset specified, error" + { + name: "No base fee asset specified, error", cfgs: []*BotConfig{ { Host: "host1", @@ -951,21 +1050,149 @@ func TestSetupBalances(t *testing.T) { QuoteBalance: 50, }, }, + { + Host: "host1", + BaseAsset: 60, + QuoteAsset: 966001, + BaseBalanceType: Percentage, + BaseBalance: 50, + QuoteBalanceType: Percentage, + QuoteBalance: 50, + QuoteFeeAssetBalanceType: Amount, + QuoteFeeAssetBalance: 500, + + CEXCfg: &BotCEXCfg{ + Name: "Binance", + BaseBalanceType: Percentage, + BaseBalance: 50, + QuoteBalanceType: Percentage, + QuoteBalance: 50, + }, + }, + }, + + assetBalances: map[uint32]uint64{ + 0: 1000, + 60: 2000, + 966: 1000, + 60001: 2000, + 966001: 2000, + }, + + cexBalances: map[string]map[uint32]uint64{ + "Binance": { + 0: 3000, + 60: 2000, + 60001: 2000, + 966001: 2000, + 61001: 2000, + }, + }, + + wantErr: true, + }, + // "No quote fee asset specified, error" + { + name: "No quote fee asset specified, error", + cfgs: []*BotConfig{ + { + Host: "host1", + BaseAsset: 60001, + QuoteAsset: 0, + BaseBalanceType: Percentage, + BaseBalance: 50, + QuoteBalanceType: Percentage, + QuoteBalance: 50, + BaseFeeAssetBalanceType: Amount, + BaseFeeAssetBalance: 500, + + CEXCfg: &BotCEXCfg{ + Name: "Binance", + BaseBalanceType: Percentage, + BaseBalance: 50, + QuoteBalanceType: Percentage, + QuoteBalance: 50, + }, + }, { Host: "host1", - BaseAsset: 966001, - QuoteAsset: 60, + BaseAsset: 60, + QuoteAsset: 966001, BaseBalanceType: Percentage, BaseBalance: 50, QuoteBalanceType: Percentage, - QuoteBalance: 100, + QuoteBalance: 50, CEXCfg: &BotCEXCfg{ Name: "Binance", BaseBalanceType: Percentage, - BaseBalance: 51, + BaseBalance: 50, QuoteBalanceType: Percentage, - QuoteBalance: 100, + QuoteBalance: 50, + }, + }, + }, + + assetBalances: map[uint32]uint64{ + 0: 1000, + 60: 2000, + 966: 1000, + 60001: 2000, + 966001: 2000, + }, + + cexBalances: map[string]map[uint32]uint64{ + "Binance": { + 0: 3000, + 60: 2000, + 60001: 2000, + 966001: 2000, + 61001: 2000, + }, + }, + + wantErr: true, + }, + // "Token asset insufficient balance, error" + { + name: "Token asset insufficient balance, error", + cfgs: []*BotConfig{ + { + Host: "host1", + BaseAsset: 60001, + QuoteAsset: 0, + BaseBalanceType: Percentage, + BaseBalance: 50, + QuoteBalanceType: Percentage, + QuoteBalance: 50, + BaseFeeAssetBalanceType: Percentage, + BaseFeeAssetBalance: 51, + + CEXCfg: &BotCEXCfg{ + Name: "Binance", + BaseBalanceType: Percentage, + BaseBalance: 50, + QuoteBalanceType: Percentage, + QuoteBalance: 50, + }, + }, + { + Host: "host1", + BaseAsset: 60, + QuoteAsset: 966001, + BaseBalanceType: Percentage, + BaseBalance: 50, + QuoteBalanceType: Percentage, + QuoteBalance: 50, + QuoteFeeAssetBalanceType: Amount, + QuoteFeeAssetBalance: 500, + + CEXCfg: &BotCEXCfg{ + Name: "Binance", + BaseBalanceType: Percentage, + BaseBalance: 50, + QuoteBalanceType: Percentage, + QuoteBalance: 50, }, }, }, @@ -973,6 +1200,7 @@ func TestSetupBalances(t *testing.T) { assetBalances: map[uint32]uint64{ 0: 1000, 60: 2000, + 966: 1000, 60001: 2000, 966001: 2000, }, @@ -992,11 +1220,9 @@ func TestSetupBalances(t *testing.T) { } runTest := func(test *ttest) { + tCore := newTCore() tCore.setAssetBalances(test.assetBalances) - mm, done := tNewMarketMaker(t, tCore) - defer done() - cexes := make(map[string]libxc.CEX) for cexName, balances := range test.cexBalances { cex := newTCEX() @@ -1009,7 +1235,7 @@ func TestSetupBalances(t *testing.T) { } } - err := mm.setupBalances(test.cfgs, cexes) + dexBalances, cexBalances, err := botInitialBaseBalances(test.cfgs, tCore, cexes) if test.wantErr { if err == nil { t.Fatalf("%s: expected error, got nil", test.name) @@ -1021,21 +1247,23 @@ func TestSetupBalances(t *testing.T) { } for botID, wantReserve := range test.wantReserves { - botReserves := mm.botBalances[botID] + + botDexBalances := dexBalances[botID] for assetID, wantReserve := range wantReserve { - if botReserves.balances[assetID].Available != wantReserve { + if botDexBalances[assetID] != wantReserve { t.Fatalf("%s: unexpected reserve for bot %s, asset %d. "+ "want %d, got %d", test.name, botID, assetID, wantReserve, - botReserves.balances[assetID]) + botDexBalances[assetID]) } } wantCEXReserves := test.wantCEXReserves[botID] + cexBalances := cexBalances[botID] for assetID, wantReserve := range wantCEXReserves { - if botReserves.cexBalances[assetID] != wantReserve { + if cexBalances[assetID] != wantReserve { t.Fatalf("%s: unexpected cex reserve for bot %s, asset %d. "+ "want %d, got %d", test.name, botID, assetID, wantReserve, - botReserves.cexBalances[assetID]) + cexBalances[assetID]) } } } @@ -1046,4354 +1274,250 @@ func TestSetupBalances(t *testing.T) { } } -func TestSegregatedCoreMaxSell(t *testing.T) { - tCore := newTCore() - tCore.isAccountLocker[60] = true - dcrBtcID := fmt.Sprintf("%s-%d-%d", "host1", 42, 0) - dcrEthID := fmt.Sprintf("%s-%d-%d", "host1", 42, 60) +type vwapResult struct { + avg uint64 + extrema uint64 +} - // Whatever is returned from PreOrder is returned from this function. - // What we need to test is what is passed to PreOrder. - orderEstimate := &core.OrderEstimate{ - Swap: &asset.PreSwap{ - Estimate: &asset.SwapEstimate{ - Lots: 5, - Value: 5e8, - MaxFees: 1600, - RealisticWorstCase: 12010, - RealisticBestCase: 6008, - }, - }, - Redeem: &asset.PreRedeem{ - Estimate: &asset.RedeemEstimate{ - RealisticBestCase: 2800, - RealisticWorstCase: 6500, - }, - }, - } - tCore.orderEstimate = orderEstimate - - expectedResult := &core.MaxOrderEstimate{ - Swap: &asset.SwapEstimate{ - Lots: 5, - Value: 5e8, - MaxFees: 1600, - RealisticWorstCase: 12010, - RealisticBestCase: 6008, - }, - Redeem: &asset.RedeemEstimate{ - RealisticBestCase: 2800, - RealisticWorstCase: 6500, - }, +type dexOrder struct { + lots, rate uint64 + sell bool +} + +type withdrawArgs struct { + address string + amt uint64 + assetID uint32 + txID string +} + +type tCEX struct { + bidsVWAP map[uint64]vwapResult + asksVWAP map[uint64]vwapResult + vwapErr error + balances map[uint32]*libxc.ExchangeBalance + balanceErr error + tradeID string + tradeErr error + lastTrade *libxc.Trade + cancelledTrades []string + cancelTradeErr error + tradeUpdates chan *libxc.Trade + tradeUpdatesID int + lastConfirmDepositTx string + confirmDeposit chan uint64 + confirmDepositComplete chan bool + depositAddress string + lastWithdrawArgs *withdrawArgs + confirmWithdrawal chan *withdrawArgs + confirmWithdrawalComplete chan bool +} + +func newTCEX() *tCEX { + return &tCEX{ + bidsVWAP: make(map[uint64]vwapResult), + asksVWAP: make(map[uint64]vwapResult), + balances: make(map[uint32]*libxc.ExchangeBalance), + cancelledTrades: make([]string, 0), + tradeUpdates: make(chan *libxc.Trade), + confirmDeposit: make(chan uint64), + confirmDepositComplete: make(chan bool), + confirmWithdrawal: make(chan *withdrawArgs), + confirmWithdrawalComplete: make(chan bool), } +} - tests := []struct { - name string - cfg *BotConfig - assetBalances map[uint32]uint64 - market *core.Market - swapFees uint64 - redeemFees uint64 - refundFees uint64 - - expectPreOrderParam *core.TradeForm - wantErr bool - }{ - { - name: "ok", - cfg: &BotConfig{ - Host: "host1", - BaseAsset: 42, - QuoteAsset: 0, - BaseBalanceType: Percentage, - BaseBalance: 50, - QuoteBalanceType: Percentage, - QuoteBalance: 50, - }, - assetBalances: map[uint32]uint64{ - 0: 1e7, - 42: 1e7, - }, - market: &core.Market{ - LotSize: 1e6, - }, - expectPreOrderParam: &core.TradeForm{ - Host: "host1", - IsLimit: true, - Base: 42, - Quote: 0, - Sell: true, - Qty: 4 * 1e6, - }, - swapFees: 1000, - redeemFees: 1000, - }, - { - name: "1 lot", - cfg: &BotConfig{ - Host: "host1", - BaseAsset: 42, - QuoteAsset: 0, - BaseBalanceType: Amount, - BaseBalance: 1e6 + 1000, - QuoteBalanceType: Amount, - QuoteBalance: 1000, - }, - assetBalances: map[uint32]uint64{ - 0: 1e7, - 42: 1e7, - }, - market: &core.Market{ - LotSize: 1e6, - }, - expectPreOrderParam: &core.TradeForm{ - Host: "host1", - IsLimit: true, - Base: 42, - Quote: 0, - Sell: true, - Qty: 1e6, - }, - swapFees: 1000, - redeemFees: 1000, - }, - { - name: "not enough for 1 swap", - cfg: &BotConfig{ - Host: "host1", - BaseAsset: 42, - QuoteAsset: 0, - BaseBalanceType: Amount, - BaseBalance: 1e6 + 999, - QuoteBalanceType: Amount, - QuoteBalance: 1000, - }, - assetBalances: map[uint32]uint64{ - 0: 1e7, - 42: 1e7, - }, - market: &core.Market{ - LotSize: 1e6, - }, - swapFees: 1000, - redeemFees: 1000, - wantErr: true, - }, - { - name: "not enough for 1 lot of redeem fees", - cfg: &BotConfig{ - Host: "host1", - BaseAsset: 42, - QuoteAsset: 60, - BaseBalanceType: Amount, - BaseBalance: 1e6 + 1000, - QuoteBalanceType: Amount, - QuoteBalance: 999, - }, - assetBalances: map[uint32]uint64{ - 42: 1e7, - 60: 1e7, - }, - market: &core.Market{ - LotSize: 1e6, - }, - swapFees: 1000, - redeemFees: 1000, - wantErr: true, - }, - { - name: "redeem fees don't matter if not account locker", - cfg: &BotConfig{ - Host: "host1", - BaseAsset: 42, - QuoteAsset: 0, - BaseBalanceType: Amount, - BaseBalance: 1e6 + 1000, - QuoteBalanceType: Amount, - QuoteBalance: 999, - }, - assetBalances: map[uint32]uint64{ - 42: 1e7, - 0: 1e7, - }, - market: &core.Market{ - LotSize: 1e6, - }, - swapFees: 1000, - redeemFees: 1000, - expectPreOrderParam: &core.TradeForm{ - Host: "host1", - IsLimit: true, - Base: 42, - Quote: 0, - Sell: true, - Qty: 1e6, - }, - }, - { - name: "2 lots with refund fees, not account locker", - cfg: &BotConfig{ - Host: "host1", - BaseAsset: 42, - QuoteAsset: 0, - BaseBalanceType: Amount, - BaseBalance: 2e6 + 2000, - QuoteBalanceType: Amount, - QuoteBalance: 1000, - }, - assetBalances: map[uint32]uint64{ - 0: 1e7, - 42: 1e7, - }, - market: &core.Market{ - LotSize: 1e6, - }, - expectPreOrderParam: &core.TradeForm{ - Host: "host1", - IsLimit: true, - Base: 42, - Quote: 0, - Sell: true, - Qty: 2e6, - }, - swapFees: 1000, - redeemFees: 1000, - refundFees: 1000, - }, - { - name: "1 lot with refund fees, account locker", - cfg: &BotConfig{ - Host: "host1", - BaseAsset: 42, - QuoteAsset: 60, - BaseBalanceType: Amount, - BaseBalance: 2e6 + 2000, - QuoteBalanceType: Amount, - QuoteBalance: 1000, - }, - assetBalances: map[uint32]uint64{ - 60: 1e7, - 42: 1e7, - }, - market: &core.Market{ - LotSize: 1e6, - }, - expectPreOrderParam: &core.TradeForm{ - Host: "host1", - IsLimit: true, - Base: 42, - Quote: 60, - Sell: true, - Qty: 1e6, - }, - swapFees: 1000, - redeemFees: 1000, - refundFees: 1000, - }, - } - - tempDir := t.TempDir() - for _, test := range tests { - tCore.setAssetBalances(test.assetBalances) - tCore.market = test.market - tCore.sellSwapFees = test.swapFees - tCore.sellRedeemFees = test.redeemFees - tCore.sellRefundFees = test.refundFees - - mm, err := NewMarketMaker(tCore, filepath.Join(tempDir, "mm.cfg"), tLogger) - if err != nil { - t.Fatalf("%s: unexpected error: %v", test.name, err) - } - - err = mm.setupBalances([]*BotConfig{test.cfg}, nil) - if err != nil { - t.Fatalf("%s: unexpected error: %v", test.name, err) - } - - mkt := dcrBtcID - if test.cfg.QuoteAsset == 60 { - mkt = dcrEthID - } - - segregatedCore := mm.wrappedCoreForBot(mkt) - res, err := segregatedCore.MaxSell("host1", test.cfg.BaseAsset, test.cfg.QuoteAsset) - if test.wantErr { - if err == nil { - t.Fatalf("%s: expected error but did not get", test.name) - } - continue - } - if err != nil { - t.Fatalf("%s: unexpected error: %v", test.name, err) - } - - if !reflect.DeepEqual(tCore.preOrderParam, test.expectPreOrderParam) { - t.Fatalf("%s: expected pre order param %+v != actual %+v", test.name, test.expectPreOrderParam, tCore.preOrderParam) - } - - if !reflect.DeepEqual(res, expectedResult) { - t.Fatalf("%s: expected max sell result %+v != actual %+v", test.name, expectedResult, res) - } - } -} - -func TestSegregatedCoreMaxBuy(t *testing.T) { - tCore := newTCore() - - tCore.isAccountLocker[60] = true - dcrBtcID := fmt.Sprintf("%s-%d-%d", "host1", 42, 0) - ethBtcID := fmt.Sprintf("%s-%d-%d", "host1", 60, 0) - - // Whatever is returned from PreOrder is returned from this function. - // What we need to test is what is passed to PreOrder. - orderEstimate := &core.OrderEstimate{ - Swap: &asset.PreSwap{ - Estimate: &asset.SwapEstimate{ - Lots: 5, - Value: 5e8, - MaxFees: 1600, - RealisticWorstCase: 12010, - RealisticBestCase: 6008, - }, - }, - Redeem: &asset.PreRedeem{ - Estimate: &asset.RedeemEstimate{ - RealisticBestCase: 2800, - RealisticWorstCase: 6500, - }, - }, - } - tCore.orderEstimate = orderEstimate - - expectedResult := &core.MaxOrderEstimate{ - Swap: &asset.SwapEstimate{ - Lots: 5, - Value: 5e8, - MaxFees: 1600, - RealisticWorstCase: 12010, - RealisticBestCase: 6008, - }, - Redeem: &asset.RedeemEstimate{ - RealisticBestCase: 2800, - RealisticWorstCase: 6500, - }, - } - - tests := []struct { - name string - cfg *BotConfig - assetBalances map[uint32]uint64 - market *core.Market - rate uint64 - swapFees uint64 - redeemFees uint64 - refundFees uint64 - - expectPreOrderParam *core.TradeForm - wantErr bool - }{ - { - name: "ok", - cfg: &BotConfig{ - Host: "host1", - BaseAsset: 42, - QuoteAsset: 0, - BaseBalanceType: Percentage, - BaseBalance: 50, - QuoteBalanceType: Percentage, - QuoteBalance: 50, - }, - rate: 5e7, - assetBalances: map[uint32]uint64{ - 0: 1e7, - 42: 1e7, - }, - market: &core.Market{ - LotSize: 1e6, - }, - expectPreOrderParam: &core.TradeForm{ - Host: "host1", - IsLimit: true, - Base: 42, - Quote: 0, - Sell: false, - Rate: 5e7, - Qty: 9 * 1e6, - }, - swapFees: 1000, - redeemFees: 1000, - }, - { - name: "1 lot", - cfg: &BotConfig{ - Host: "host1", - BaseAsset: 42, - QuoteAsset: 0, - BaseBalanceType: Amount, - BaseBalance: 1000, - QuoteBalanceType: Amount, - QuoteBalance: (1e6 * 5e7 / 1e8) + 1000, - }, - rate: 5e7, - assetBalances: map[uint32]uint64{ - 0: 1e7, - 42: 1e7, - }, - market: &core.Market{ - LotSize: 1e6, - }, - expectPreOrderParam: &core.TradeForm{ - Host: "host1", - IsLimit: true, - Base: 42, - Quote: 0, - Sell: false, - Qty: 1e6, - Rate: 5e7, - }, - swapFees: 1000, - redeemFees: 1000, - }, - { - name: "not enough for 1 swap", - cfg: &BotConfig{ - Host: "host1", - BaseAsset: 42, - QuoteAsset: 0, - BaseBalanceType: Amount, - BaseBalance: 1000, - QuoteBalanceType: Amount, - QuoteBalance: (1e6 * 5e7 / 1e8) + 999, - }, - rate: 5e7, - assetBalances: map[uint32]uint64{ - 0: 1e7, - 42: 1e7, - }, - market: &core.Market{ - LotSize: 1e6, - }, - swapFees: 1000, - redeemFees: 1000, - wantErr: true, - }, - { - name: "not enough for 1 lot of redeem fees", - cfg: &BotConfig{ - Host: "host1", - BaseAsset: 60, - QuoteAsset: 0, - BaseBalanceType: Amount, - BaseBalance: 999, - QuoteBalanceType: Amount, - QuoteBalance: (1e6 * 5e7 / 1e8) + 1000, - }, - rate: 5e7, - assetBalances: map[uint32]uint64{ - 0: 1e7, - 60: 1e7, - }, - market: &core.Market{ - LotSize: 1e6, - }, - swapFees: 1000, - redeemFees: 1000, - wantErr: true, - }, - { - name: "only account locker affected by redeem fees", - cfg: &BotConfig{ - Host: "host1", - BaseAsset: 42, - QuoteAsset: 0, - BaseBalanceType: Amount, - BaseBalance: 999, - QuoteBalanceType: Amount, - QuoteBalance: (1e6 * 5e7 / 1e8) + 1000, - }, - rate: 5e7, - assetBalances: map[uint32]uint64{ - 0: 1e7, - 42: 1e7, - }, - market: &core.Market{ - LotSize: 1e6, - }, - swapFees: 1000, - redeemFees: 1000, - expectPreOrderParam: &core.TradeForm{ - Host: "host1", - IsLimit: true, - Base: 42, - Quote: 0, - Sell: false, - Qty: 1e6, - Rate: 5e7, - }, - }, - { - name: "2 lots with refund fees, not account locker", - cfg: &BotConfig{ - Host: "host1", - BaseAsset: 42, - QuoteAsset: 0, - BaseBalanceType: Amount, - BaseBalance: 1000, - QuoteBalanceType: Amount, - QuoteBalance: (2e6 * 5e7 / 1e8) + 2000, - }, - rate: 5e7, - assetBalances: map[uint32]uint64{ - 0: 1e7, - 42: 1e7, - }, - market: &core.Market{ - LotSize: 1e6, - }, - expectPreOrderParam: &core.TradeForm{ - Host: "host1", - IsLimit: true, - Base: 42, - Quote: 0, - Sell: false, - Qty: 2e6, - Rate: 5e7, - }, - swapFees: 1000, - redeemFees: 1000, - refundFees: 1000, - }, - { - name: "1 lot with refund fees, account locker", - cfg: &BotConfig{ - Host: "host1", - BaseAsset: 60, - QuoteAsset: 0, - BaseBalanceType: Amount, - BaseBalance: 1000, - QuoteBalanceType: Amount, - QuoteBalance: (2e6 * 5e7 / 1e8) + 2000, - }, - rate: 5e7, - assetBalances: map[uint32]uint64{ - 60: 1e7, - 0: 1e7, - }, - market: &core.Market{ - LotSize: 1e6, - }, - expectPreOrderParam: &core.TradeForm{ - Host: "host1", - IsLimit: true, - Base: 60, - Quote: 0, - Sell: false, - Qty: 1e6, - Rate: 5e7, - }, - swapFees: 1000, - redeemFees: 1000, - refundFees: 1000, - }, - } - - tempDir := t.TempDir() - for _, test := range tests { - if test.name != "1 lot with refund fees, account locker" { - continue - } - tCore.setAssetBalances(test.assetBalances) - tCore.market = test.market - tCore.buySwapFees = test.swapFees - tCore.buyRedeemFees = test.redeemFees - - mm, err := NewMarketMaker(tCore, filepath.Join(tempDir, "mm.cfg"), tLogger) - if err != nil { - t.Fatalf("%s: unexpected error: %v", test.name, err) - } - - err = mm.setupBalances([]*BotConfig{test.cfg}, nil) - if err != nil { - t.Fatalf("%s: unexpected error: %v", test.name, err) - } - - mkt := dcrBtcID - if test.cfg.BaseAsset != 42 { - mkt = ethBtcID - } - segregatedCore := mm.wrappedCoreForBot(mkt) - res, err := segregatedCore.MaxBuy("host1", test.cfg.BaseAsset, test.cfg.QuoteAsset, test.rate) - if test.wantErr { - if err == nil { - t.Fatalf("%s: expected error but did not get", test.name) - } - continue - } - if err != nil { - t.Fatalf("%s: unexpected error: %v", test.name, err) - } - - if !reflect.DeepEqual(tCore.preOrderParam, test.expectPreOrderParam) { - t.Fatalf("%s: expected pre order param %+v != actual %+v", test.name, test.expectPreOrderParam, tCore.preOrderParam) - } - - if !reflect.DeepEqual(res, expectedResult) { - t.Fatalf("%s: expected max buy result %+v != actual %+v", test.name, expectedResult, res) - } - } -} - -func assetBalancesMatch(expected map[uint32]*botBalance, botName string, mm *MarketMaker) error { - for assetID, exp := range expected { - actual := mm.botBalances[botName].balances[assetID] - if !reflect.DeepEqual(exp, actual) { - return fmt.Errorf("asset %d expected %+v != actual %+v\n", assetID, exp, actual) - } - } - return nil -} - -func TestSegregatedCoreTrade(t *testing.T) { - t.Run("single trade", func(t *testing.T) { - testSegregatedCoreTrade(t, false) - }) - t.Run("multi trade", func(t *testing.T) { - testSegregatedCoreTrade(t, true) - }) -} - -func testSegregatedCoreTrade(t *testing.T, testMultiTrade bool) { - id := encode.RandomBytes(order.OrderIDSize) - id2 := encode.RandomBytes(order.OrderIDSize) - - matchIDs := make([]order.MatchID, 5) - for i := range matchIDs { - var matchID order.MatchID - copy(matchID[:], encode.RandomBytes(order.MatchIDSize)) - matchIDs[i] = matchID - } - - type noteAndBalances struct { - note core.Notification - balance map[uint32]*botBalance - } - - type test struct { - name string - multiTradeOnly bool - - cfg *BotConfig - multiTrade *core.MultiTradeForm - trade *core.TradeForm - assetBalances map[uint32]uint64 - postTradeBalances map[uint32]*botBalance - market *core.Market - swapFees uint64 - redeemFees uint64 - refundFees uint64 - tradeRes *core.Order - multiTradeRes []*core.Order - notifications []*noteAndBalances - isAccountLocker map[uint32]bool - maxFundingFees uint64 - - wantErr bool - } - - tests := []test{ - // "cancelled order, 1/2 lots filled, sell" - { - name: "cancelled order, 1/2 lots filled, sell", - cfg: &BotConfig{ - Host: "host1", - BaseAsset: 42, - QuoteAsset: 0, - BaseBalanceType: Percentage, - BaseBalance: 50, - QuoteBalanceType: Percentage, - QuoteBalance: 50, - }, - assetBalances: map[uint32]uint64{ - 0: 1e7, - 42: 1e7, - }, - trade: &core.TradeForm{ - Host: "host1", - IsLimit: true, - Base: 42, - Quote: 0, - Sell: true, - Qty: 2e6, - Rate: 5e7, - }, - market: &core.Market{ - LotSize: 1e6, - }, - swapFees: 1000, - redeemFees: 1000, - tradeRes: &core.Order{ - ID: id, - LockedAmt: 2e6 + 2000, - RedeemLockedAmt: 2000, - Sell: true, - }, - postTradeBalances: map[uint32]*botBalance{ - 0: { - Available: (1e7 / 2) - 2000, - FundingOrder: 2000, - }, - 42: { - Available: (1e7 / 2) - 2e6 - 2000, - FundingOrder: 2e6 + 2000, - }, - }, - notifications: []*noteAndBalances{ - { - note: &core.OrderNote{ - Order: &core.Order{ - ID: id, - Status: order.OrderStatusBooked, - BaseID: 42, - QuoteID: 0, - Qty: 2e6, - Sell: true, - Filled: 1e6, - LockedAmt: 1e6, - Matches: []*core.Match{ - { - MatchID: matchIDs[0][:], - Qty: 1e6, - Rate: 5e7, - Status: order.MakerSwapCast, - Swap: &core.Coin{}, - }, - }, - }, - }, - balance: map[uint32]*botBalance{ - 0: { - Available: (1e7 / 2) - 2000, - PendingRedeem: calc.BaseToQuote(5e7, 1e6), - FundingOrder: 2000, - }, - 42: { - Available: (1e7 / 2) - 2e6 - 2000, - FundingOrder: 1e6 + 2000, - }, - }, - }, - { - note: &core.MatchNote{ - OrderID: id, - Match: &core.Match{ - MatchID: matchIDs[0][:], - Qty: 1e6, - Rate: 5e7, - Swap: &core.Coin{}, - Redeem: &core.Coin{}, - Status: order.MatchComplete, - }, - }, - balance: map[uint32]*botBalance{ - 0: { - Available: (1e7 / 2) - 2000, - PendingRedeem: calc.BaseToQuote(5e7, 1e6), - FundingOrder: 2000, - }, - 42: { - Available: (1e7 / 2) - 2e6 - 2000, - FundingOrder: 1e6 + 2000, - }, - }, - }, - { - note: &core.MatchNote{ - OrderID: id, - Match: &core.Match{ - MatchID: matchIDs[0][:], - Qty: 1e6, - Rate: 5e7, - Status: order.MatchConfirmed, - Swap: &core.Coin{}, - Redeem: &core.Coin{}, - }, - }, - balance: map[uint32]*botBalance{ - 0: { - Available: (1e7 / 2) - 2000 + calc.BaseToQuote(5e7, 1e6), - FundingOrder: 2000, - }, - 42: { - Available: (1e7 / 2) - 2e6 - 2000, - FundingOrder: 1e6 + 2000, - }, - }, - }, - { - note: &core.OrderNote{ - Order: &core.Order{ - ID: id, - Status: order.OrderStatusCanceled, - BaseID: 42, - QuoteID: 0, - Qty: 2e6, - Sell: true, - Filled: 2e6, - AllFeesConfirmed: true, - FeesPaid: &core.FeeBreakdown{ - Swap: 800, - Redemption: 800, - }, - Matches: []*core.Match{ - { - MatchID: matchIDs[0][:], - Qty: 1e6, - Rate: 5e7, - Status: order.MakerSwapCast, - Swap: &core.Coin{}, - Redeem: &core.Coin{}, - }, - }, - }, - }, - balance: map[uint32]*botBalance{ - 0: { - Available: (1e7 / 2) + calc.BaseToQuote(5e7, 1e6) - 800, - }, - 42: { - Available: (1e7 / 2) - 1e6 - 800, - }, - }, - }, - }, - }, - // "cancelled order, 1/2 lots filled, buy" - { - name: "cancelled order, 1/2 lots filled, buy", - cfg: &BotConfig{ - Host: "host1", - BaseAsset: 42, - QuoteAsset: 0, - BaseBalanceType: Percentage, - BaseBalance: 50, - QuoteBalanceType: Percentage, - QuoteBalance: 50, - }, - assetBalances: map[uint32]uint64{ - 0: 1e7, - 42: 1e7, - }, - trade: &core.TradeForm{ - Host: "host1", - IsLimit: true, - Base: 42, - Quote: 0, - Sell: false, - Qty: 2e6, - Rate: 5e7, - }, - market: &core.Market{ - LotSize: 1e6, - }, - swapFees: 1000, - redeemFees: 1000, - tradeRes: &core.Order{ - ID: id, - LockedAmt: calc.BaseToQuote(5e7, 2e6) + 2000, - Sell: false, - }, - postTradeBalances: map[uint32]*botBalance{ - 0: { - Available: (1e7 / 2) - 2000 - calc.BaseToQuote(5e7, 2e6), - FundingOrder: calc.BaseToQuote(5e7, 2e6) + 2000, - }, - 42: { - Available: (1e7 / 2), - }, - }, - notifications: []*noteAndBalances{ - { - note: &core.OrderNote{ - Order: &core.Order{ - ID: id, - Status: order.OrderStatusBooked, - BaseID: 42, - QuoteID: 0, - Qty: 2e6, - Sell: false, - Filled: 1e6, - LockedAmt: 1e6, - Matches: []*core.Match{ - { - MatchID: matchIDs[0][:], - Qty: 1e6, - Rate: 5e7, - Swap: &core.Coin{}, - Status: order.MakerSwapCast, - }, - }, - }, - }, - balance: map[uint32]*botBalance{ - 0: { - Available: (1e7 / 2) - 2000 - calc.BaseToQuote(5e7, 2e6), - FundingOrder: calc.BaseToQuote(5e7, 1e6) + 2000, - }, - 42: { - Available: (1e7 / 2), - PendingRedeem: 1e6 - 1000, - }, - }, - }, - { - note: &core.MatchNote{ - OrderID: id, - Match: &core.Match{ - MatchID: matchIDs[0][:], - Qty: 1e6, - Rate: 5e7, - Swap: &core.Coin{}, - Redeem: &core.Coin{}, - Status: order.MatchComplete, - }, - }, - balance: map[uint32]*botBalance{ - 0: { - Available: (1e7 / 2) - 2000 - calc.BaseToQuote(5e7, 2e6), - FundingOrder: calc.BaseToQuote(5e7, 1e6) + 2000, - }, - 42: { - Available: (1e7 / 2), - PendingRedeem: 1e6 - 1000, - }, - }, - }, - { - note: &core.MatchNote{ - OrderID: id, - Match: &core.Match{ - MatchID: matchIDs[0][:], - Qty: 1e6, - Rate: 5e7, - Status: order.MatchConfirmed, - Swap: &core.Coin{}, - Redeem: &core.Coin{}, - }, - }, - balance: map[uint32]*botBalance{ - 0: { - Available: (1e7 / 2) - 2000 - calc.BaseToQuote(5e7, 2e6), - FundingOrder: calc.BaseToQuote(5e7, 1e6) + 2000, - }, - 42: { - Available: (1e7 / 2) + 1e6 - 1000, - }, - }, - }, - { - note: &core.OrderNote{ - Order: &core.Order{ - ID: id, - Status: order.OrderStatusCanceled, - BaseID: 42, - QuoteID: 0, - Qty: 2e6, - Sell: false, - Filled: 2e6, - AllFeesConfirmed: true, - Rate: 5e7, - FeesPaid: &core.FeeBreakdown{ - Swap: 800, - Redemption: 800, - }, - Matches: []*core.Match{ - { - MatchID: matchIDs[0][:], - Qty: 1e6, - Rate: 5e7, - Status: order.MatchConfirmed, - Swap: &core.Coin{}, - Redeem: &core.Coin{}, - }, - }, - }, - }, - balance: map[uint32]*botBalance{ - 0: { - Available: (1e7 / 2) - 800 - calc.BaseToQuote(5e7, 1e6), - }, - 42: { - Available: (1e7 / 2) + 1e6 - 800, - }, - }, - }, - }, - }, - // "fully filled order, sell" - { - name: "fully filled order, sell", - cfg: &BotConfig{ - Host: "host1", - BaseAsset: 42, - QuoteAsset: 0, - BaseBalanceType: Percentage, - BaseBalance: 50, - QuoteBalanceType: Percentage, - QuoteBalance: 50, - }, - assetBalances: map[uint32]uint64{ - 0: 1e7, - 42: 1e7, - }, - trade: &core.TradeForm{ - Host: "host1", - IsLimit: true, - Base: 42, - Quote: 0, - Sell: true, - Qty: 2e6, - Rate: 5e7, - }, - market: &core.Market{ - LotSize: 1e6, - }, - swapFees: 1000, - redeemFees: 1000, - tradeRes: &core.Order{ - ID: id, - LockedAmt: 2e6 + 2000, - RedeemLockedAmt: 2000, - Sell: true, - }, - postTradeBalances: map[uint32]*botBalance{ - 0: { - Available: (1e7 / 2) - 2000, - FundingOrder: 2000, - }, - 42: { - Available: (1e7 / 2) - 2e6 - 2000, - FundingOrder: 2e6 + 2000, - }, - }, - notifications: []*noteAndBalances{ - { - note: &core.OrderNote{ - Order: &core.Order{ - ID: id, - Status: order.OrderStatusBooked, - BaseID: 42, - QuoteID: 0, - Qty: 2e6, - Sell: true, - Filled: 1e6, - LockedAmt: 1e6, - Matches: []*core.Match{ - { - MatchID: matchIDs[0][:], - Qty: 1e6, - Rate: 5e7, - Status: order.MakerSwapCast, - Swap: &core.Coin{}, - }, - }, - }, - }, - balance: map[uint32]*botBalance{ - 0: { - Available: (1e7 / 2) - 2000, - FundingOrder: 2000, - PendingRedeem: calc.BaseToQuote(5e7, 1e6), - }, - 42: { - Available: (1e7 / 2) - 2e6 - 2000, - FundingOrder: 1e6 + 2000, - }, - }, - }, - { - note: &core.MatchNote{ - OrderID: id, - Match: &core.Match{ - MatchID: matchIDs[0][:], - Qty: 1e6, - Rate: 5e7, - Swap: &core.Coin{}, - Redeem: &core.Coin{}, - Status: order.MatchComplete, - }, - }, - balance: map[uint32]*botBalance{ - 0: { - Available: (1e7 / 2) - 2000, - FundingOrder: 2000, - PendingRedeem: calc.BaseToQuote(5e7, 1e6), - }, - 42: { - Available: (1e7 / 2) - 2e6 - 2000, - FundingOrder: 1e6 + 2000, - }, - }, - }, - { - note: &core.MatchNote{ - OrderID: id, - Match: &core.Match{ - MatchID: matchIDs[0][:], - Qty: 1e6, - Rate: 5e7, - Status: order.MatchConfirmed, - Swap: &core.Coin{}, - Redeem: &core.Coin{}, - }, - }, - balance: map[uint32]*botBalance{ - 0: { - Available: (1e7 / 2) - 2000 + calc.BaseToQuote(5e7, 1e6), - FundingOrder: 2000, - }, - 42: { - Available: (1e7 / 2) - 2e6 - 2000, - FundingOrder: 1e6 + 2000, - }, - }, - }, - { - note: &core.OrderNote{ - Order: &core.Order{ - ID: id, - Status: order.OrderStatusExecuted, - BaseID: 42, - QuoteID: 0, - Qty: 2e6, - Sell: true, - Filled: 2e6, - FeesPaid: &core.FeeBreakdown{ - Swap: 1600, - Redemption: 1600, - }, - Matches: []*core.Match{ - { - MatchID: matchIDs[0][:], - Qty: 1e6, - Rate: 5e7, - Status: order.MatchConfirmed, - Swap: &core.Coin{}, - Redeem: &core.Coin{}, - }, - { - MatchID: matchIDs[1][:], - Qty: 1e6, - Rate: 55e6, - Status: order.MakerSwapCast, - Swap: &core.Coin{}, - }, - }, - }, - }, - balance: map[uint32]*botBalance{ - 0: { - Available: (1e7 / 2) - 2000 + calc.BaseToQuote(5e7, 1e6), - FundingOrder: 2000, - PendingRedeem: calc.BaseToQuote(55e6, 1e6), - }, - 42: { - Available: (1e7 / 2) - 2e6 - 2000, - FundingOrder: 2000, - }, - }, - }, - { - note: &core.MatchNote{ - OrderID: id, - Match: &core.Match{ - MatchID: matchIDs[1][:], - Qty: 1e6, - Rate: 55e6, - Status: order.MatchComplete, - Swap: &core.Coin{}, - Redeem: &core.Coin{}, - }, - }, - balance: map[uint32]*botBalance{ - 0: { - Available: (1e7 / 2) - 2000 + calc.BaseToQuote(5e7, 1e6), - FundingOrder: 2000, - PendingRedeem: calc.BaseToQuote(55e6, 1e6), - }, - 42: { - Available: (1e7 / 2) - 2e6 - 2000, - FundingOrder: 2000, - }, - }, - }, - { - note: &core.MatchNote{ - OrderID: id, - Match: &core.Match{ - MatchID: matchIDs[1][:], - Qty: 1e6, - Rate: 55e6, - Status: order.MatchConfirmed, - Swap: &core.Coin{}, - Redeem: &core.Coin{}, - }, - }, - balance: map[uint32]*botBalance{ - 0: { - Available: (1e7 / 2) - 2000 + calc.BaseToQuote(5e7, 1e6) + calc.BaseToQuote(55e6, 1e6), - FundingOrder: 2000, - }, - 42: { - Available: (1e7 / 2) - 2e6 - 2000, - FundingOrder: 2000, - }, - }, - }, - { - note: &core.OrderNote{ - Order: &core.Order{ - ID: id, - Status: order.OrderStatusExecuted, - BaseID: 42, - QuoteID: 0, - Qty: 2e6, - Sell: true, - Filled: 2e6, - AllFeesConfirmed: true, - FeesPaid: &core.FeeBreakdown{ - Swap: 1600, - Redemption: 1600, - }, - Matches: []*core.Match{ - { - MatchID: matchIDs[0][:], - Qty: 1e6, - Rate: 5e7, - Swap: &core.Coin{}, - Redeem: &core.Coin{}, - Status: order.MatchConfirmed, - }, - { - MatchID: matchIDs[1][:], - Qty: 1e6, - Rate: 55e6, - Swap: &core.Coin{}, - Redeem: &core.Coin{}, - Status: order.MatchConfirmed, - }, - }, - }, - }, - balance: map[uint32]*botBalance{ - 0: { - Available: (1e7 / 2) - 1600 + calc.BaseToQuote(5e7, 1e6) + calc.BaseToQuote(55e6, 1e6), - }, - 42: { - Available: (1e7 / 2) - 2e6 - 1600, - }, - }, - }, - }, - }, - // "fully filled order, buy" - { - name: "fully filled order, buy", - cfg: &BotConfig{ - Host: "host1", - BaseAsset: 42, - QuoteAsset: 0, - BaseBalanceType: Percentage, - BaseBalance: 50, - QuoteBalanceType: Percentage, - QuoteBalance: 50, - }, - assetBalances: map[uint32]uint64{ - 0: 1e7, - 42: 1e7, - }, - trade: &core.TradeForm{ - Host: "host1", - IsLimit: true, - Base: 42, - Quote: 0, - Sell: false, - Qty: 2e6, - Rate: 5e7, - }, - market: &core.Market{ - LotSize: 1e6, - }, - swapFees: 1000, - redeemFees: 1000, - tradeRes: &core.Order{ - ID: id, - LockedAmt: calc.BaseToQuote(5e7, 2e6) + 2000, - Sell: true, - }, - postTradeBalances: map[uint32]*botBalance{ - 0: { - Available: (1e7 / 2) - 2000 - calc.BaseToQuote(5e7, 2e6), - FundingOrder: calc.BaseToQuote(5e7, 2e6) + 2000, - }, - 42: { - Available: (1e7 / 2), - }, - }, - notifications: []*noteAndBalances{ - { - note: &core.OrderNote{ - Order: &core.Order{ - ID: id, - Status: order.OrderStatusBooked, - BaseID: 42, - QuoteID: 0, - Qty: 2e6, - Sell: false, - Filled: 1e6, - LockedAmt: 1e6, - Matches: []*core.Match{ - { - MatchID: matchIDs[0][:], - Qty: 1e6, - Rate: 5e7, - Swap: &core.Coin{}, - Status: order.MakerSwapCast, - }, - }, - }, - }, - balance: map[uint32]*botBalance{ - 0: { - Available: (1e7 / 2) - 2000 - calc.BaseToQuote(5e7, 2e6), - FundingOrder: calc.BaseToQuote(5e7, 1e6) + 2000, - }, - 42: { - Available: (1e7 / 2), - PendingRedeem: 1e6 - 1000, - }, - }, - }, - { - note: &core.MatchNote{ - OrderID: id, - Match: &core.Match{ - MatchID: matchIDs[0][:], - Qty: 1e6, - Rate: 5e7, - Swap: &core.Coin{}, - Redeem: &core.Coin{}, - Status: order.MatchComplete, - }, - }, - balance: map[uint32]*botBalance{ - 0: { - Available: (1e7 / 2) - 2000 - calc.BaseToQuote(5e7, 2e6), - FundingOrder: calc.BaseToQuote(5e7, 1e6) + 2000, - }, - 42: { - Available: (1e7 / 2), - PendingRedeem: 1e6 - 1000, - }, - }, - }, - { - note: &core.MatchNote{ - OrderID: id, - Match: &core.Match{ - MatchID: matchIDs[0][:], - Qty: 1e6, - Rate: 5e7, - Status: order.MatchConfirmed, - Swap: &core.Coin{}, - Redeem: &core.Coin{}, - }, - }, - balance: map[uint32]*botBalance{ - 0: { - Available: (1e7 / 2) - 2000 - calc.BaseToQuote(5e7, 2e6), - FundingOrder: calc.BaseToQuote(5e7, 1e6) + 2000, - }, - 42: { - Available: (1e7 / 2) - 1000 + 1e6, - }, - }, - }, - { - note: &core.OrderNote{ - Order: &core.Order{ - ID: id, - Status: order.OrderStatusExecuted, - BaseID: 42, - QuoteID: 0, - Qty: 2e6, - Rate: 5e7, - Sell: false, - Filled: 2e6, - FeesPaid: &core.FeeBreakdown{ - Swap: 1600, - Redemption: 1600, - }, - Matches: []*core.Match{ - { - MatchID: matchIDs[0][:], - Qty: 1e6, - Rate: 5e7, - Swap: &core.Coin{}, - Redeem: &core.Coin{}, - Status: order.MatchConfirmed, - }, - { - MatchID: matchIDs[1][:], - Qty: 1e6, - Rate: 45e6, - Swap: &core.Coin{}, - Status: order.MakerSwapCast, - }, - }, - }, - }, - balance: map[uint32]*botBalance{ - 0: { - Available: (1e7 / 2) - 2000 - calc.BaseToQuote(5e7, 1e6) - calc.BaseToQuote(45e6, 1e6), - FundingOrder: 2000, - }, - 42: { - Available: (1e7 / 2) + 1e6 - 1000, - PendingRedeem: 1e6 - 1000, - }, - }, - }, - { - note: &core.MatchNote{ - OrderID: id, - Match: &core.Match{ - MatchID: matchIDs[1][:], - Qty: 1e6, - Rate: 45e6, - Status: order.MatchComplete, - Redeem: &core.Coin{}, - }, - }, - balance: map[uint32]*botBalance{ - 0: { - Available: (1e7 / 2) - 2000 - calc.BaseToQuote(5e7, 1e6) - calc.BaseToQuote(45e6, 1e6), - FundingOrder: 2000, - }, - 42: { - Available: (1e7 / 2) + 1e6 - 1000, - PendingRedeem: 1e6 - 1000, - }, - }, - }, - { - note: &core.MatchNote{ - OrderID: id, - Match: &core.Match{ - MatchID: matchIDs[1][:], - Qty: 1e6, - Rate: 45e6, - Status: order.MatchConfirmed, - Redeem: &core.Coin{}, - }, - }, - balance: map[uint32]*botBalance{ - 0: { - Available: (1e7 / 2) - 2000 - calc.BaseToQuote(5e7, 1e6) - calc.BaseToQuote(45e6, 1e6), - FundingOrder: 2000, - }, - 42: { - Available: (1e7 / 2) + 2e6 - 2000, - }, - }, - }, - }, - }, - // "fully filled order, sell, accountLocker" - { - name: "fully filled order, sell, accountLocker", - cfg: &BotConfig{ - Host: "host1", - BaseAsset: 42, - QuoteAsset: 0, - BaseBalanceType: Percentage, - BaseBalance: 50, - QuoteBalanceType: Percentage, - QuoteBalance: 50, - }, - assetBalances: map[uint32]uint64{ - 0: 1e7, - 42: 1e7, - }, - trade: &core.TradeForm{ - Host: "host1", - IsLimit: true, - Base: 42, - Quote: 0, - Sell: true, - Qty: 2e6, - Rate: 5e7, - }, - market: &core.Market{ - LotSize: 1e6, - }, - swapFees: 1000, - redeemFees: 1000, - refundFees: 800, - tradeRes: &core.Order{ - ID: id, - LockedAmt: 2e6 + 2000, - RedeemLockedAmt: 2000, - RefundLockedAmt: 1600, - Sell: true, - }, - postTradeBalances: map[uint32]*botBalance{ - 0: { - Available: (1e7 / 2) - 2000, - FundingOrder: 2000, - }, - 42: { - Available: (1e7 / 2) - 2e6 - 2000 - 1600, - FundingOrder: 2e6 + 2000 + 1600, - }, - }, - notifications: []*noteAndBalances{ - { - note: &core.OrderNote{ - Order: &core.Order{ - ID: id, - Status: order.OrderStatusBooked, - BaseID: 42, - QuoteID: 0, - Qty: 2e6, - Sell: true, - Filled: 1e6, - LockedAmt: 1e6, - Matches: []*core.Match{ - { - MatchID: matchIDs[0][:], - Qty: 1e6, - Rate: 5e7, - Swap: &core.Coin{}, - Status: order.MakerSwapCast, - }, - }, - }, - }, - balance: map[uint32]*botBalance{ - 0: { - Available: (1e7 / 2) - 2000, - FundingOrder: 2000, - PendingRedeem: calc.BaseToQuote(5e7, 1e6), - }, - 42: { - Available: (1e7 / 2) - 2e6 - 2000 - 1600, - FundingOrder: 1e6 + 2000 + 1600, - }, - }, - }, - { - note: &core.MatchNote{ - OrderID: id, - Match: &core.Match{ - MatchID: matchIDs[0][:], - Qty: 1e6, - Rate: 5e7, - Status: order.MatchComplete, - Swap: &core.Coin{}, - Redeem: &core.Coin{}, - }, - }, - balance: map[uint32]*botBalance{ - 0: { - Available: (1e7 / 2) - 2000, - FundingOrder: 2000, - PendingRedeem: calc.BaseToQuote(5e7, 1e6), - }, - 42: { - Available: (1e7 / 2) - 2e6 - 2000 - 1600, - FundingOrder: 1e6 + 2000 + 1600, - }, - }, - }, - { - note: &core.MatchNote{ - OrderID: id, - Match: &core.Match{ - MatchID: matchIDs[0][:], - Qty: 1e6, - Rate: 5e7, - Status: order.MatchConfirmed, - Swap: &core.Coin{}, - Redeem: &core.Coin{}, - }, - }, - balance: map[uint32]*botBalance{ - 0: { - Available: (1e7 / 2) - 2000 + calc.BaseToQuote(5e7, 1e6), - FundingOrder: 2000, - }, - 42: { - Available: (1e7 / 2) - 2e6 - 2000 - 1600, - FundingOrder: 1e6 + 2000 + 1600, - }, - }, - }, - { - note: &core.OrderNote{ - Order: &core.Order{ - ID: id, - Status: order.OrderStatusExecuted, - BaseID: 42, - QuoteID: 0, - Qty: 2e6, - Sell: true, - Filled: 2e6, - FeesPaid: &core.FeeBreakdown{ - Swap: 1600, - Redemption: 1600, - }, - Matches: []*core.Match{ - { - MatchID: matchIDs[0][:], - Qty: 1e6, - Rate: 5e7, - Status: order.MatchConfirmed, - Swap: &core.Coin{}, - Redeem: &core.Coin{}, - }, - { - MatchID: matchIDs[1][:], - Qty: 1e6, - Rate: 55e6, - Status: order.MakerSwapCast, - Swap: &core.Coin{}, - }, - }, - }, - }, - balance: map[uint32]*botBalance{ - 0: { - Available: (1e7 / 2) - 2000 + calc.BaseToQuote(5e7, 1e6), - FundingOrder: 2000, - PendingRedeem: calc.BaseToQuote(55e6, 1e6), - }, - 42: { - Available: (1e7 / 2) - 2e6 - 2000 - 1600, - FundingOrder: 2000 + 1600, - }, - }, - }, - { - note: &core.MatchNote{ - OrderID: id, - Match: &core.Match{ - MatchID: matchIDs[1][:], - Qty: 1e6, - Rate: 55e6, - Status: order.MatchComplete, - Redeem: &core.Coin{}, - Swap: &core.Coin{}, - }, - }, - balance: map[uint32]*botBalance{ - 0: { - Available: (1e7 / 2) - 2000 + calc.BaseToQuote(5e7, 1e6), - FundingOrder: 2000, - PendingRedeem: calc.BaseToQuote(55e6, 1e6), - }, - 42: { - Available: (1e7 / 2) - 2e6 - 2000 - 1600, - FundingOrder: 2000 + 1600, - }, - }, - }, - { - note: &core.MatchNote{ - OrderID: id, - Match: &core.Match{ - MatchID: matchIDs[1][:], - Qty: 1e6, - Rate: 55e6, - Status: order.MatchConfirmed, - Redeem: &core.Coin{}, - Swap: &core.Coin{}, - }, - }, - balance: map[uint32]*botBalance{ - 0: { - Available: (1e7 / 2) - 2000 + calc.BaseToQuote(5e7, 1e6) + calc.BaseToQuote(55e6, 1e6), - FundingOrder: 2000, - }, - 42: { - Available: (1e7 / 2) - 2e6 - 2000 - 1600, - FundingOrder: 2000 + 1600, - }, - }, - }, - { - note: &core.OrderNote{ - Order: &core.Order{ - ID: id, - Status: order.OrderStatusExecuted, - BaseID: 42, - QuoteID: 0, - Qty: 2e6, - Sell: true, - Filled: 2e6, - AllFeesConfirmed: true, - FeesPaid: &core.FeeBreakdown{ - Swap: 1600, - Redemption: 1600, - }, - Matches: []*core.Match{ - { - MatchID: matchIDs[0][:], - Qty: 1e6, - Rate: 5e7, - Status: order.MatchConfirmed, - Swap: &core.Coin{}, - Redeem: &core.Coin{}, - }, - { - MatchID: matchIDs[1][:], - Qty: 1e6, - Rate: 55e6, - Status: order.MatchConfirmed, - Swap: &core.Coin{}, - Redeem: &core.Coin{}, - }, - }, - }, - }, - balance: map[uint32]*botBalance{ - 0: { - Available: (1e7 / 2) - 1600 + calc.BaseToQuote(5e7, 1e6) + calc.BaseToQuote(55e6, 1e6), - }, - 42: { - Available: (1e7 / 2) - 2e6 - 1600, - }, - }, - }, - }, - }, - // "fully filled order, buy, accountLocker" - { - name: "fully filled order, buy, accountLocker", - cfg: &BotConfig{ - Host: "host1", - BaseAsset: 42, - QuoteAsset: 0, - BaseBalanceType: Percentage, - BaseBalance: 50, - QuoteBalanceType: Percentage, - QuoteBalance: 50, - }, - assetBalances: map[uint32]uint64{ - 0: 1e7, - 42: 1e7, - }, - trade: &core.TradeForm{ - Host: "host1", - IsLimit: true, - Base: 42, - Quote: 0, - Sell: false, - Qty: 2e6, - Rate: 5e7, - }, - market: &core.Market{ - LotSize: 1e6, - }, - swapFees: 1000, - redeemFees: 1000, - refundFees: 800, - tradeRes: &core.Order{ - ID: id, - LockedAmt: calc.BaseToQuote(5e7, 2e6) + 2000, - RefundLockedAmt: 1600, - Sell: true, - }, - postTradeBalances: map[uint32]*botBalance{ - 0: { - Available: (1e7 / 2) - 2000 - calc.BaseToQuote(5e7, 2e6) - 1600, - FundingOrder: calc.BaseToQuote(5e7, 2e6) + 2000 + 1600, - }, - 42: { - Available: (1e7 / 2), - }, - }, - notifications: []*noteAndBalances{ - { - note: &core.OrderNote{ - Order: &core.Order{ - ID: id, - Status: order.OrderStatusBooked, - BaseID: 42, - QuoteID: 0, - Qty: 2e6, - Sell: false, - Filled: 1e6, - LockedAmt: 1e6, - Matches: []*core.Match{ - { - MatchID: matchIDs[0][:], - Qty: 1e6, - Rate: 5e7, - Status: order.MakerSwapCast, - Swap: &core.Coin{}, - }, - }, - }, - }, - balance: map[uint32]*botBalance{ - 0: { - Available: (1e7 / 2) - 2000 - calc.BaseToQuote(5e7, 2e6) - 1600, - FundingOrder: calc.BaseToQuote(5e7, 1e6) + 2000 + 1600, - }, - 42: { - Available: (1e7 / 2), - PendingRedeem: 1e6 - 1000, - }, - }, - }, - { - note: &core.MatchNote{ - OrderID: id, - Match: &core.Match{ - MatchID: matchIDs[0][:], - Qty: 1e6, - Rate: 5e7, - Status: order.MatchComplete, - Swap: &core.Coin{}, - Redeem: &core.Coin{}, - }, - }, - balance: map[uint32]*botBalance{ - 0: { - Available: (1e7 / 2) - 2000 - calc.BaseToQuote(5e7, 2e6) - 1600, - FundingOrder: calc.BaseToQuote(5e7, 1e6) + 2000 + 1600, - }, - 42: { - Available: (1e7 / 2), - PendingRedeem: 1e6 - 1000, - }, - }, - }, - { - note: &core.MatchNote{ - OrderID: id, - Match: &core.Match{ - MatchID: matchIDs[0][:], - Qty: 1e6, - Rate: 5e7, - Status: order.MatchConfirmed, - Swap: &core.Coin{}, - Redeem: &core.Coin{}, - }, - }, - balance: map[uint32]*botBalance{ - 0: { - Available: (1e7 / 2) - 2000 - calc.BaseToQuote(5e7, 2e6) - 1600, - FundingOrder: calc.BaseToQuote(5e7, 1e6) + 2000 + 1600, - }, - 42: { - Available: (1e7 / 2) - 1000 + 1e6, - }, - }, - }, - { - note: &core.OrderNote{ - Order: &core.Order{ - ID: id, - Status: order.OrderStatusExecuted, - BaseID: 42, - QuoteID: 0, - Qty: 2e6, - Rate: 5e7, - Sell: false, - Filled: 2e6, - FeesPaid: &core.FeeBreakdown{ - Swap: 1600, - Redemption: 1600, - }, - Matches: []*core.Match{ - { - MatchID: matchIDs[0][:], - Qty: 1e6, - Rate: 5e7, - Status: order.MatchConfirmed, - Swap: &core.Coin{}, - Redeem: &core.Coin{}, - }, - { - MatchID: matchIDs[1][:], - Qty: 1e6, - Rate: 45e6, - Status: order.MakerSwapCast, - Swap: &core.Coin{}, - }, - }, - }, - }, - balance: map[uint32]*botBalance{ - 0: { - Available: (1e7 / 2) - 2000 - calc.BaseToQuote(5e7, 1e6) - calc.BaseToQuote(45e6, 1e6) - 1600, - FundingOrder: 2000 + 1600, - }, - 42: { - Available: (1e7 / 2) + 1e6 - 1000, - PendingRedeem: 1e6 - 1000, - }, - }, - }, - { - note: &core.MatchNote{ - OrderID: id, - Match: &core.Match{ - MatchID: matchIDs[1][:], - Qty: 1e6, - Rate: 45e6, - Status: order.MatchComplete, - Swap: &core.Coin{}, - Redeem: &core.Coin{}, - }, - }, - balance: map[uint32]*botBalance{ - 0: { - Available: (1e7 / 2) - 2000 - calc.BaseToQuote(5e7, 1e6) - calc.BaseToQuote(45e6, 1e6) - 1600, - FundingOrder: 2000 + 1600, - }, - 42: { - Available: (1e7 / 2) + 1e6 - 1000, - PendingRedeem: 1e6 - 1000, - }, - }, - }, - { - note: &core.MatchNote{ - OrderID: id, - Match: &core.Match{ - MatchID: matchIDs[1][:], - Qty: 1e6, - Rate: 45e6, - Status: order.MatchConfirmed, - Swap: &core.Coin{}, - Redeem: &core.Coin{}, - }, - }, - balance: map[uint32]*botBalance{ - 0: { - Available: (1e7 / 2) - 2000 - calc.BaseToQuote(5e7, 1e6) - calc.BaseToQuote(45e6, 1e6) - 1600, - FundingOrder: 2000 + 1600, - }, - 42: { - Available: (1e7 / 2) + 2e6 - 2000, - }, - }, - }, - }, - }, - // "buy, 1 match refunded, 1 revoked before swap, 1 redeemed match, not accountLocker" - { - name: "buy, 1 refunded, 1 revoked before swap, 1 redeemed match, not accountLocker", - cfg: &BotConfig{ - Host: "host1", - BaseAsset: 42, - QuoteAsset: 0, - BaseBalanceType: Percentage, - BaseBalance: 50, - QuoteBalanceType: Percentage, - QuoteBalance: 50, - }, - assetBalances: map[uint32]uint64{ - 0: 1e7, - 42: 1e7, - }, - trade: &core.TradeForm{ - Host: "host1", - IsLimit: true, - Base: 42, - Quote: 0, - Sell: false, - Qty: 3e6, - Rate: 5e7, - }, - market: &core.Market{ - LotSize: 1e6, - }, - swapFees: 1000, - redeemFees: 1000, - refundFees: 800, - tradeRes: &core.Order{ - ID: id, - LockedAmt: calc.BaseToQuote(5e7, 3e6) + 3000, - Sell: false, - }, - postTradeBalances: map[uint32]*botBalance{ - 0: { - Available: (1e7 / 2) - calc.BaseToQuote(5e7, 3e6) - 3000, - FundingOrder: calc.BaseToQuote(5e7, 3e6) + 3000, - }, - 42: { - Available: (1e7 / 2), - }, - }, - notifications: []*noteAndBalances{ - { - note: &core.OrderNote{ - Order: &core.Order{ - ID: id, - Status: order.OrderStatusBooked, - BaseID: 42, - QuoteID: 0, - Qty: 2e6, - Sell: false, - Filled: 2e6, - LockedAmt: 1e6, - Matches: []*core.Match{ - { - MatchID: matchIDs[0][:], - Qty: 1e6, - Rate: 5e7, - Status: order.MakerSwapCast, - Swap: &core.Coin{}, - }, - { - MatchID: matchIDs[1][:], - Qty: 1e6, - Rate: 5e7, - Status: order.NewlyMatched, - }, - { - MatchID: matchIDs[2][:], - Qty: 1e6, - Rate: 5e7, - Status: order.MakerSwapCast, - Swap: &core.Coin{}, - }, - }, - }, - }, - balance: map[uint32]*botBalance{ - 0: { - Available: (1e7 / 2) - 3000 - calc.BaseToQuote(5e7, 3e6), - FundingOrder: 3000, - }, - 42: { - Available: (1e7 / 2), - PendingRedeem: 3e6 - 3000, - }, - }, - }, - { - note: &core.MatchNote{ - OrderID: id, - Match: &core.Match{ - MatchID: matchIDs[0][:], - Qty: 1e6, - Rate: 5e7, - Revoked: true, - Swap: &core.Coin{}, - Refund: &core.Coin{}, - Status: order.MatchConfirmed, - }, - }, - balance: map[uint32]*botBalance{ - 0: { - Available: (1e7 / 2) - 3000 - calc.BaseToQuote(5e7, 2e6) - 800, - FundingOrder: 3000, - }, - 42: { - Available: (1e7 / 2), - PendingRedeem: 2e6 - 2000, - }, - }, - }, - { - note: &core.MatchNote{ - OrderID: id, - Match: &core.Match{ - MatchID: matchIDs[1][:], - Qty: 1e6, - Rate: 5e7, - Revoked: true, - Status: order.NewlyMatched, - }, - }, - balance: map[uint32]*botBalance{ - 0: { - Available: (1e7 / 2) - 3000 - calc.BaseToQuote(5e7, 2e6) - 800, - FundingOrder: 3000 + calc.BaseToQuote(5e7, 1e6), - }, - 42: { - Available: (1e7 / 2), - PendingRedeem: 1e6 - 1000, - }, - }, - }, - { - note: &core.MatchNote{ - OrderID: id, - Match: &core.Match{ - MatchID: matchIDs[2][:], - Qty: 1e6, - Rate: 5e7, - Swap: &core.Coin{}, - Redeem: &core.Coin{}, - Status: order.MatchConfirmed, - }, - }, - balance: map[uint32]*botBalance{ - 0: { - Available: (1e7 / 2) - 3000 - calc.BaseToQuote(5e7, 2e6) - 800, - FundingOrder: 3000 + calc.BaseToQuote(5e7, 1e6), - }, - 42: { - Available: (1e7 / 2) + 1e6 - 1000, - }, - }, - }, - { - note: &core.OrderNote{ - Order: &core.Order{ - ID: id, - Status: order.OrderStatusRevoked, - BaseID: 42, - QuoteID: 0, - Qty: 2e6, - Sell: false, - Filled: 2e6, - AllFeesConfirmed: true, - FeesPaid: &core.FeeBreakdown{ - Swap: 1600, - Refund: 400, - Redemption: 500, - }, - Matches: []*core.Match{ - { - MatchID: matchIDs[0][:], - Qty: 1e6, - Rate: 5e7, - Status: order.MatchComplete, - Swap: &core.Coin{}, - Revoked: true, - Refund: &core.Coin{}, - }, - { - MatchID: matchIDs[1][:], - Qty: 1e6, - Rate: 5e7, - Revoked: true, - Status: order.NewlyMatched, - }, - { - MatchID: matchIDs[2][:], - Qty: 1e6, - Rate: 5e7, - Status: order.MatchComplete, - Swap: &core.Coin{}, - Redeem: &core.Coin{}, - }, - }, - }, - }, - balance: map[uint32]*botBalance{ - 0: { - Available: (1e7 / 2) - calc.BaseToQuote(5e7, 1e6) - 1600 - 400, - }, - 42: { - Available: (1e7 / 2) + 1e6 - 500, - }, - }, - }, - }, - }, - // "sell, 1 match refunded, 1 revoked before swap, 1 redeemed match, not accountLocker" - { - name: "sell, 1 refunded, 1 revoked before swap, 1 redeemed match, not accountLocker", - cfg: &BotConfig{ - Host: "host1", - BaseAsset: 42, - QuoteAsset: 0, - BaseBalanceType: Percentage, - BaseBalance: 50, - QuoteBalanceType: Percentage, - QuoteBalance: 50, - }, - assetBalances: map[uint32]uint64{ - 0: 1e7, - 42: 1e7, - }, - trade: &core.TradeForm{ - Host: "host1", - IsLimit: true, - Base: 42, - Quote: 0, - Sell: true, - Qty: 3e6, - Rate: 5e7, - }, - market: &core.Market{ - LotSize: 1e6, - }, - swapFees: 1000, - redeemFees: 1000, - refundFees: 800, - tradeRes: &core.Order{ - ID: id, - LockedAmt: 3e6 + 3000, - Sell: false, - }, - postTradeBalances: map[uint32]*botBalance{ - 0: { - Available: (1e7 / 2), - }, - 42: { - Available: (1e7 / 2) - 3e6 - 3000, - FundingOrder: 3e6 + 3000, - }, - }, - notifications: []*noteAndBalances{ - { - note: &core.OrderNote{ - Order: &core.Order{ - ID: id, - Status: order.OrderStatusBooked, - BaseID: 42, - QuoteID: 0, - Qty: 2e6, - Sell: true, - Filled: 2e6, - LockedAmt: 1e6, - Matches: []*core.Match{ - { - MatchID: matchIDs[0][:], - Qty: 1e6, - Rate: 5e7, - Status: order.MakerSwapCast, - Swap: &core.Coin{}, - }, - { - MatchID: matchIDs[1][:], - Qty: 1e6, - Rate: 5e7, - Status: order.NewlyMatched, - }, - { - MatchID: matchIDs[2][:], - Qty: 1e6, - Rate: 5e7, - Status: order.MakerSwapCast, - Swap: &core.Coin{}, - }, - }, - }, - }, - balance: map[uint32]*botBalance{ - 0: { - Available: (1e7 / 2), - PendingRedeem: calc.BaseToQuote(5e7, 3e6) - 3000, - }, - 42: { - Available: (1e7 / 2) - 3e6 - 3000, - FundingOrder: 3000, - }, - }, - }, - { - note: &core.MatchNote{ - OrderID: id, - Match: &core.Match{ - MatchID: matchIDs[0][:], - Qty: 1e6, - Rate: 5e7, - Revoked: true, - Swap: &core.Coin{}, - Refund: &core.Coin{}, - Status: order.MatchConfirmed, - }, - }, - balance: map[uint32]*botBalance{ - 0: { - Available: (1e7 / 2), - PendingRedeem: calc.BaseToQuote(5e7, 2e6) - 2000, - }, - 42: { - Available: (1e7 / 2) - 2e6 - 3000 - 800, - FundingOrder: 3000, - }, - }, - }, - { - note: &core.MatchNote{ - OrderID: id, - Match: &core.Match{ - MatchID: matchIDs[1][:], - Qty: 1e6, - Rate: 5e7, - Revoked: true, - Status: order.NewlyMatched, - }, - }, - balance: map[uint32]*botBalance{ - 0: { - Available: (1e7 / 2), - PendingRedeem: calc.BaseToQuote(5e7, 1e6) - 1000, - }, - 42: { - Available: (1e7 / 2) - 2e6 - 3000 - 800, - FundingOrder: 3000 + 1e6, - }, - }, - }, - { - note: &core.MatchNote{ - OrderID: id, - Match: &core.Match{ - MatchID: matchIDs[2][:], - Qty: 1e6, - Rate: 5e7, - Swap: &core.Coin{}, - Redeem: &core.Coin{}, - Status: order.MatchConfirmed, - }, - }, - balance: map[uint32]*botBalance{ - 0: { - Available: (1e7 / 2) + calc.BaseToQuote(5e7, 1e6) - 1000, - }, - 42: { - Available: (1e7 / 2) - 2e6 - 3000 - 800, - FundingOrder: 3000 + 1e6, - }, - }, - }, - { - note: &core.OrderNote{ - Order: &core.Order{ - ID: id, - Status: order.OrderStatusRevoked, - BaseID: 42, - QuoteID: 0, - Qty: 3e6, - Sell: true, - Filled: 3e6, - AllFeesConfirmed: true, - FeesPaid: &core.FeeBreakdown{ - Swap: 1600, - Refund: 400, - Redemption: 500, - }, - Matches: []*core.Match{ - { - MatchID: matchIDs[0][:], - Qty: 1e6, - Rate: 5e7, - Status: order.MatchComplete, - Swap: &core.Coin{}, - Revoked: true, - Refund: &core.Coin{}, - }, - { - MatchID: matchIDs[1][:], - Qty: 1e6, - Rate: 5e7, - Revoked: true, - Status: order.NewlyMatched, - }, - { - MatchID: matchIDs[2][:], - Qty: 1e6, - Rate: 5e7, - Status: order.MatchComplete, - Swap: &core.Coin{}, - Redeem: &core.Coin{}, - }, - }, - }, - }, - balance: map[uint32]*botBalance{ - 0: { - Available: (1e7 / 2) + calc.BaseToQuote(5e7, 1e6) - 500, - }, - 42: { - Available: (1e7 / 2) - 1e6 - 1600 - 400, - }, - }, - }, - }, - }, - // "buy, 1 match refunded, 1 revoked before swap, 1 redeemed match, accountLocker" - { - name: "buy, 1 refunded, 1 revoked before swap, 1 redeemed match, accountLocker", - cfg: &BotConfig{ - Host: "host1", - BaseAsset: 42, - QuoteAsset: 0, - BaseBalanceType: Percentage, - BaseBalance: 50, - QuoteBalanceType: Percentage, - QuoteBalance: 50, - }, - assetBalances: map[uint32]uint64{ - 0: 1e7, - 42: 1e7, - }, - trade: &core.TradeForm{ - Host: "host1", - IsLimit: true, - Base: 42, - Quote: 0, - Sell: false, - Qty: 3e6, - Rate: 5e7, - }, - market: &core.Market{ - LotSize: 1e6, - }, - swapFees: 1000, - redeemFees: 1000, - refundFees: 800, - tradeRes: &core.Order{ - ID: id, - LockedAmt: calc.BaseToQuote(5e7, 3e6) + 3000, - RefundLockedAmt: 2400, - Sell: false, - }, - postTradeBalances: map[uint32]*botBalance{ - 0: { - Available: (1e7 / 2) - calc.BaseToQuote(5e7, 3e6) - 3000 - 2400, - FundingOrder: calc.BaseToQuote(5e7, 3e6) + 3000 + 2400, - }, - 42: { - Available: (1e7 / 2), - }, - }, - notifications: []*noteAndBalances{ - { - note: &core.OrderNote{ - Order: &core.Order{ - ID: id, - Status: order.OrderStatusBooked, - BaseID: 42, - QuoteID: 0, - Qty: 2e6, - Sell: false, - Filled: 2e6, - LockedAmt: 1e6, - Matches: []*core.Match{ - { - MatchID: matchIDs[0][:], - Qty: 1e6, - Rate: 5e7, - Status: order.MakerSwapCast, - Swap: &core.Coin{}, - }, - { - MatchID: matchIDs[1][:], - Qty: 1e6, - Rate: 5e7, - Status: order.NewlyMatched, - }, - { - MatchID: matchIDs[2][:], - Qty: 1e6, - Rate: 5e7, - Status: order.MakerSwapCast, - Swap: &core.Coin{}, - }, - }, - }, - }, - balance: map[uint32]*botBalance{ - 0: { - Available: (1e7 / 2) - calc.BaseToQuote(5e7, 3e6) - 3000 - 2400, - FundingOrder: 3000 + 2400, - }, - 42: { - Available: (1e7 / 2), - PendingRedeem: 3e6 - 3000, - }, - }, - }, - { - note: &core.MatchNote{ - OrderID: id, - Match: &core.Match{ - MatchID: matchIDs[0][:], - Qty: 1e6, - Rate: 5e7, - Revoked: true, - Swap: &core.Coin{}, - Refund: &core.Coin{}, - Status: order.MatchConfirmed, - }, - }, - balance: map[uint32]*botBalance{ - 0: { - Available: (1e7 / 2) - calc.BaseToQuote(5e7, 2e6) - 3000 - 2400, - FundingOrder: 3000 + 2400, - }, - 42: { - Available: (1e7 / 2), - PendingRedeem: 2e6 - 2000, - }, - }, - }, - { - note: &core.MatchNote{ - OrderID: id, - Match: &core.Match{ - MatchID: matchIDs[1][:], - Qty: 1e6, - Rate: 5e7, - Revoked: true, - Status: order.NewlyMatched, - }, - }, - balance: map[uint32]*botBalance{ - 0: { - Available: (1e7 / 2) - calc.BaseToQuote(5e7, 2e6) - 3000 - 2400, - FundingOrder: 3000 + 2400 + calc.BaseToQuote(5e7, 1e6), - }, - 42: { - Available: (1e7 / 2), - PendingRedeem: 1e6 - 1000, - }, - }, - }, - { - note: &core.MatchNote{ - OrderID: id, - Match: &core.Match{ - MatchID: matchIDs[2][:], - Qty: 1e6, - Rate: 5e7, - Swap: &core.Coin{}, - Redeem: &core.Coin{}, - Status: order.MatchConfirmed, - }, - }, - balance: map[uint32]*botBalance{ - 0: { - Available: (1e7 / 2) - calc.BaseToQuote(5e7, 2e6) - 3000 - 2400, - FundingOrder: 3000 + 2400 + calc.BaseToQuote(5e7, 1e6), - }, - 42: { - Available: (1e7 / 2) + 1e6 - 1000, - }, - }, - }, - { - note: &core.OrderNote{ - Order: &core.Order{ - ID: id, - Status: order.OrderStatusRevoked, - BaseID: 42, - QuoteID: 0, - Qty: 3e6, - Sell: false, - Filled: 3e6, - AllFeesConfirmed: true, - FeesPaid: &core.FeeBreakdown{ - Swap: 1600, - Refund: 400, - Redemption: 500, - }, - Matches: []*core.Match{ - { - MatchID: matchIDs[0][:], - Qty: 1e6, - Rate: 5e7, - Status: order.MatchComplete, - Swap: &core.Coin{}, - Revoked: true, - Refund: &core.Coin{}, - }, - { - MatchID: matchIDs[1][:], - Qty: 1e6, - Rate: 5e7, - Revoked: true, - Status: order.NewlyMatched, - }, - { - MatchID: matchIDs[2][:], - Qty: 1e6, - Rate: 5e7, - Status: order.MatchComplete, - Swap: &core.Coin{}, - Redeem: &core.Coin{}, - }, - }, - }, - }, - balance: map[uint32]*botBalance{ - 0: { - Available: (1e7 / 2) - calc.BaseToQuote(5e7, 1e6) - 1600 - 400, - }, - 42: { - Available: (1e7 / 2) + 1e6 - 500, - }, - }, - }, - }, - }, - // "sell, 1 match refunded, 1 revoked before swap, 1 redeemed match, accountLocker" - { - name: "sell, 1 refunded, 1 revoked before swap, 1 redeemed match, accountLocker", - cfg: &BotConfig{ - Host: "host1", - BaseAsset: 42, - QuoteAsset: 0, - BaseBalanceType: Percentage, - BaseBalance: 50, - QuoteBalanceType: Percentage, - QuoteBalance: 50, - }, - assetBalances: map[uint32]uint64{ - 0: 1e7, - 42: 1e7, - }, - trade: &core.TradeForm{ - Host: "host1", - IsLimit: true, - Base: 42, - Quote: 0, - Sell: true, - Qty: 3e6, - Rate: 5e7, - }, - market: &core.Market{ - LotSize: 1e6, - }, - swapFees: 1000, - redeemFees: 1000, - refundFees: 800, - tradeRes: &core.Order{ - ID: id, - LockedAmt: 3e6 + 3000, - RefundLockedAmt: 2400, - Sell: false, - }, - postTradeBalances: map[uint32]*botBalance{ - 0: { - Available: (1e7 / 2), - }, - 42: { - Available: (1e7 / 2) - 3e6 - 3000 - 2400, - FundingOrder: 3e6 + 3000 + 2400, - }, - }, - notifications: []*noteAndBalances{ - { - note: &core.OrderNote{ - Order: &core.Order{ - ID: id, - Status: order.OrderStatusBooked, - BaseID: 42, - QuoteID: 0, - Qty: 2e6, - Sell: true, - Filled: 2e6, - LockedAmt: 1e6, - Matches: []*core.Match{ - { - MatchID: matchIDs[0][:], - Qty: 1e6, - Rate: 5e7, - Status: order.MakerSwapCast, - Swap: &core.Coin{}, - }, - { - MatchID: matchIDs[1][:], - Qty: 1e6, - Rate: 5e7, - Status: order.NewlyMatched, - }, - { - MatchID: matchIDs[2][:], - Qty: 1e6, - Rate: 5e7, - Status: order.MakerSwapCast, - Swap: &core.Coin{}, - }, - }, - }, - }, - balance: map[uint32]*botBalance{ - 0: { - Available: (1e7 / 2), - PendingRedeem: calc.BaseToQuote(5e7, 3e6) - 3000, - }, - 42: { - Available: (1e7 / 2) - 3e6 - 3000 - 2400, - FundingOrder: 3000 + 2400, - }, - }, - }, - { - note: &core.MatchNote{ - OrderID: id, - Match: &core.Match{ - MatchID: matchIDs[0][:], - Qty: 1e6, - Rate: 5e7, - Revoked: true, - Swap: &core.Coin{}, - Refund: &core.Coin{}, - Status: order.MatchConfirmed, - }, - }, - balance: map[uint32]*botBalance{ - 0: { - Available: (1e7 / 2), - PendingRedeem: calc.BaseToQuote(5e7, 2e6) - 2000, - }, - 42: { - Available: (1e7 / 2) - 2e6 - 3000 - 2400, - FundingOrder: 3000 + 2400, - }, - }, - }, - { - note: &core.MatchNote{ - OrderID: id, - Match: &core.Match{ - MatchID: matchIDs[1][:], - Qty: 1e6, - Rate: 5e7, - Revoked: true, - Status: order.NewlyMatched, - }, - }, - balance: map[uint32]*botBalance{ - 0: { - Available: (1e7 / 2), - PendingRedeem: calc.BaseToQuote(5e7, 1e6) - 1000, - }, - 42: { - Available: (1e7 / 2) - 2e6 - 3000 - 2400, - FundingOrder: 3000 + 2400 + 1e6, - }, - }, - }, - { - note: &core.MatchNote{ - OrderID: id, - Match: &core.Match{ - MatchID: matchIDs[2][:], - Qty: 1e6, - Rate: 5e7, - Swap: &core.Coin{}, - Redeem: &core.Coin{}, - Status: order.MatchConfirmed, - }, - }, - balance: map[uint32]*botBalance{ - 0: { - Available: (1e7 / 2) + calc.BaseToQuote(5e7, 1e6) - 1000, - }, - 42: { - Available: (1e7 / 2) - 2e6 - 3000 - 2400, - FundingOrder: 3000 + 2400 + 1e6, - }, - }, - }, - { - note: &core.OrderNote{ - Order: &core.Order{ - ID: id, - Status: order.OrderStatusRevoked, - BaseID: 42, - QuoteID: 0, - Qty: 3e6, - Sell: true, - Filled: 3e6, - AllFeesConfirmed: true, - FeesPaid: &core.FeeBreakdown{ - Swap: 1600, - Refund: 400, - Redemption: 500, - }, - Matches: []*core.Match{ - { - MatchID: matchIDs[0][:], - Qty: 1e6, - Rate: 5e7, - Status: order.MatchComplete, - Swap: &core.Coin{}, - Revoked: true, - Refund: &core.Coin{}, - }, - { - MatchID: matchIDs[1][:], - Qty: 1e6, - Rate: 5e7, - Revoked: true, - Status: order.NewlyMatched, - }, - { - MatchID: matchIDs[2][:], - Qty: 1e6, - Rate: 5e7, - Status: order.MatchComplete, - Swap: &core.Coin{}, - Redeem: &core.Coin{}, - }, - }, - }, - }, - balance: map[uint32]*botBalance{ - 0: { - Available: (1e7 / 2) + calc.BaseToQuote(5e7, 1e6) - 500, - }, - 42: { - Available: (1e7 / 2) - 1e6 - 1600 - 400, - }, - }, - }, - }, - }, - // "edge enough balance for single buy" - { - name: "edge enough balance for single buy", - cfg: &BotConfig{ - Host: "host1", - BaseAsset: 42, - QuoteAsset: 0, - BaseBalanceType: Amount, - BaseBalance: 5e6, - QuoteBalanceType: Amount, - QuoteBalance: calc.BaseToQuote(5e7, 5e6) + 1500, - }, - assetBalances: map[uint32]uint64{ - 0: 1e7, - 42: 1e7, - }, - trade: &core.TradeForm{ - Host: "host1", - IsLimit: true, - Base: 42, - Quote: 0, - Sell: false, - Qty: 5e6, - Rate: 5e7, - }, - market: &core.Market{ - LotSize: 5e6, - }, - swapFees: 1000, - redeemFees: 1000, - maxFundingFees: 500, - tradeRes: &core.Order{ - ID: id, - LockedAmt: calc.BaseToQuote(5e7, 5e6) + 1000, - Sell: false, - FeesPaid: &core.FeeBreakdown{ - Funding: 400, - }, - }, - postTradeBalances: map[uint32]*botBalance{ - 0: { - Available: 100, - FundingOrder: calc.BaseToQuote(5e7, 5e6) + 1000, - }, - 42: { - Available: 5e6, - }, - }, - }, - // "edge not enough balance for single buy, with maxFundingFee > 0" - { - name: "edge not enough balance for single buy", - cfg: &BotConfig{ - Host: "host1", - BaseAsset: 42, - QuoteAsset: 0, - BaseBalanceType: Amount, - BaseBalance: 5e6, - QuoteBalanceType: Amount, - QuoteBalance: calc.BaseToQuote(5e7, 5e6) + 1499, - }, - assetBalances: map[uint32]uint64{ - 0: 1e7, - 42: 1e7, - }, - trade: &core.TradeForm{ - Host: "host1", - IsLimit: true, - Base: 42, - Quote: 0, - Sell: false, - Qty: 5e6, - Rate: 5e7, - }, - market: &core.Market{ - LotSize: 5e6, - }, - swapFees: 1000, - redeemFees: 1000, - maxFundingFees: 500, - wantErr: true, - }, - // "edge enough balance for single sell" - { - name: "edge enough balance for single sell", - cfg: &BotConfig{ - Host: "host1", - BaseAsset: 42, - QuoteAsset: 0, - BaseBalanceType: Amount, - BaseBalance: 5e6 + 1500, - QuoteBalanceType: Amount, - QuoteBalance: 5e6, - }, - assetBalances: map[uint32]uint64{ - 0: 1e7, - 42: 1e7, - }, - trade: &core.TradeForm{ - Host: "host1", - IsLimit: true, - Base: 42, - Quote: 0, - Sell: true, - Qty: 5e6, - Rate: 1e8, - }, - market: &core.Market{ - LotSize: 5e6, - }, - swapFees: 1000, - redeemFees: 1000, - maxFundingFees: 500, - tradeRes: &core.Order{ - ID: id, - LockedAmt: 5e6 + 1000, - RedeemLockedAmt: 0, - Sell: true, - FeesPaid: &core.FeeBreakdown{ - Funding: 400, - }, - }, - - postTradeBalances: map[uint32]*botBalance{ - 0: { - Available: 5e6, - }, - 42: { - Available: 100, - FundingOrder: 5e6 + 1000, - }, - }, - }, - // "edge not enough balance for single sell" - { - name: "edge not enough balance for single sell", - cfg: &BotConfig{ - Host: "host1", - BaseAsset: 42, - QuoteAsset: 0, - BaseBalanceType: Amount, - BaseBalance: 5e6 + 1499, - QuoteBalanceType: Amount, - QuoteBalance: 5e6, - }, - assetBalances: map[uint32]uint64{ - 0: 1e7, - 42: 1e7, - }, - trade: &core.TradeForm{ - Host: "host1", - IsLimit: true, - Base: 42, - Quote: 0, - Sell: true, - Qty: 5e6, - Rate: 1e8, - }, - market: &core.Market{ - LotSize: 5e6, - }, - swapFees: 1000, - redeemFees: 1000, - maxFundingFees: 500, - wantErr: true, - }, - // "edge enough balance for single buy with redeem fees" - { - name: "edge enough balance for single buy with redeem fees", - cfg: &BotConfig{ - Host: "host1", - BaseAsset: 42, - QuoteAsset: 0, - BaseBalanceType: Amount, - BaseBalance: 1000, - QuoteBalanceType: Amount, - QuoteBalance: calc.BaseToQuote(52e7, 5e6) + 1000, - }, - assetBalances: map[uint32]uint64{ - 0: 1e8, - 42: 1e7, - }, - trade: &core.TradeForm{ - Host: "host1", - IsLimit: true, - Base: 42, - Quote: 0, - Qty: 5e6, - Rate: 52e7, - }, - market: &core.Market{ - LotSize: 5e6, - }, - swapFees: 1000, - redeemFees: 1000, - tradeRes: &core.Order{ - ID: id, - LockedAmt: calc.BaseToQuote(52e7, 5e6) + 1000, - RedeemLockedAmt: 1000, - }, - postTradeBalances: map[uint32]*botBalance{ - 0: { - Available: 0, - FundingOrder: calc.BaseToQuote(52e7, 5e6) + 1000, - }, - 42: { - Available: 0, - FundingOrder: 1000, - }, - }, - isAccountLocker: map[uint32]bool{42: true}, - }, - // "edge not enough balance for single buy due to redeem fees" - { - name: "edge not enough balance for single buy due to redeem fees", - cfg: &BotConfig{ - Host: "host1", - BaseAsset: 42, - QuoteAsset: 0, - BaseBalanceType: Amount, - BaseBalance: 999, - QuoteBalanceType: Amount, - QuoteBalance: calc.BaseToQuote(52e7, 5e6) + 1000, - }, - assetBalances: map[uint32]uint64{ - 0: 1e8, - 42: 1e7, - }, - trade: &core.TradeForm{ - Host: "host1", - IsLimit: true, - Base: 42, - Quote: 0, - Qty: 5e6, - Rate: 52e7, - }, - market: &core.Market{ - LotSize: 5e6, - }, - swapFees: 1000, - redeemFees: 1000, - isAccountLocker: map[uint32]bool{42: true}, - wantErr: true, - }, - // "edge enough balance for single sell with redeem fees" - { - name: "edge enough balance for single sell with redeem fees", - cfg: &BotConfig{ - Host: "host1", - BaseAsset: 42, - QuoteAsset: 0, - BaseBalanceType: Amount, - BaseBalance: 5e6 + 1000, - QuoteBalanceType: Amount, - QuoteBalance: 1000, - }, - assetBalances: map[uint32]uint64{ - 0: 1e8, - 42: 1e7, - }, - trade: &core.TradeForm{ - Host: "host1", - IsLimit: true, - Sell: true, - Base: 42, - Quote: 0, - Qty: 5e6, - Rate: 52e7, - }, - market: &core.Market{ - LotSize: 5e6, - }, - swapFees: 1000, - redeemFees: 1000, - tradeRes: &core.Order{ - ID: id, - LockedAmt: 5e6 + 1000, - RedeemLockedAmt: 1000, - }, - postTradeBalances: map[uint32]*botBalance{ - 0: { - Available: 0, - FundingOrder: 1000, - }, - 42: { - Available: 0, - FundingOrder: 5e6 + 1000, - }, - }, - isAccountLocker: map[uint32]bool{0: true}, - }, - // "edge not enough balance for single buy due to redeem fees" - { - name: "edge not enough balance for single sell due to redeem fees", - cfg: &BotConfig{ - Host: "host1", - BaseAsset: 42, - QuoteAsset: 0, - BaseBalanceType: Amount, - BaseBalance: 5e6 + 1000, - QuoteBalanceType: Amount, - QuoteBalance: 999, - }, - assetBalances: map[uint32]uint64{ - 0: 1e8, - 42: 1e7, - }, - trade: &core.TradeForm{ - Host: "host1", - IsLimit: true, - Sell: true, - Base: 42, - Quote: 0, - Qty: 5e6, - Rate: 52e7, - }, - market: &core.Market{ - LotSize: 5e6, - }, - swapFees: 1000, - redeemFees: 1000, - isAccountLocker: map[uint32]bool{0: true}, - wantErr: true, - }, - // "edge enough balance for multi buy" - { - name: "edge enough balance for multi buy", - cfg: &BotConfig{ - Host: "host1", - BaseAsset: 42, - QuoteAsset: 0, - BaseBalanceType: Amount, - BaseBalance: 5e6, - QuoteBalanceType: Amount, - QuoteBalance: calc.BaseToQuote(5e7, 5e6) + calc.BaseToQuote(52e7, 5e6) + 2500, - }, - assetBalances: map[uint32]uint64{ - 0: 1e8, - 42: 1e7, - }, - multiTradeOnly: true, - multiTrade: &core.MultiTradeForm{ - Host: "host1", - Base: 42, - Quote: 0, - Placements: []*core.QtyRate{ - { - Qty: 5e6, - Rate: 52e7, - }, - { - Qty: 5e6, - Rate: 5e7, - }, - }, - }, - market: &core.Market{ - LotSize: 5e6, - }, - swapFees: 1000, - redeemFees: 1000, - maxFundingFees: 500, - multiTradeRes: []*core.Order{{ - ID: id, - LockedAmt: calc.BaseToQuote(5e7, 5e6) + 1000, - RedeemLockedAmt: 0, - Sell: true, - FeesPaid: &core.FeeBreakdown{ - Funding: 400, - }, - }, { - ID: id2, - LockedAmt: calc.BaseToQuote(52e7, 5e6) + 1000, - RedeemLockedAmt: 0, - Sell: true, - }, - }, - postTradeBalances: map[uint32]*botBalance{ - 0: { - Available: 100, - FundingOrder: calc.BaseToQuote(5e7, 5e6) + calc.BaseToQuote(52e7, 5e6) + 2000, - }, - 42: { - Available: 5e6, - }, - }, - }, - // "edge not enough balance for multi buy" - { - name: "edge not enough balance for multi buy", - cfg: &BotConfig{ - Host: "host1", - BaseAsset: 42, - QuoteAsset: 0, - BaseBalanceType: Amount, - BaseBalance: 5e6, - QuoteBalanceType: Amount, - QuoteBalance: calc.BaseToQuote(5e7, 5e6) + calc.BaseToQuote(52e7, 5e6) + 2499, - }, - assetBalances: map[uint32]uint64{ - 0: 1e8, - 42: 1e7, - }, - multiTradeOnly: true, - multiTrade: &core.MultiTradeForm{ - Host: "host1", - Base: 42, - Quote: 0, - Placements: []*core.QtyRate{ - { - Qty: 5e6, - Rate: 52e7, - }, - { - Qty: 5e6, - Rate: 5e7, - }, - }, - }, - market: &core.Market{ - LotSize: 5e6, - }, - swapFees: 1000, - redeemFees: 1000, - maxFundingFees: 500, - wantErr: true, - }, - // "edge enough balance for multi sell" - { - name: "edge enough balance for multi sell", - cfg: &BotConfig{ - Host: "host1", - BaseAsset: 42, - QuoteAsset: 0, - BaseBalanceType: Amount, - BaseBalance: 1e7 + 2500, - QuoteBalanceType: Amount, - QuoteBalance: 5e6, - }, - assetBalances: map[uint32]uint64{ - 0: 1e8, - 42: 1e8, - }, - multiTradeOnly: true, - multiTrade: &core.MultiTradeForm{ - Host: "host1", - Base: 42, - Quote: 0, - Sell: true, - Placements: []*core.QtyRate{ - { - Qty: 5e6, - Rate: 52e7, - }, - { - Qty: 5e6, - Rate: 5e7, - }, - }, - }, - market: &core.Market{ - LotSize: 5e6, - }, - swapFees: 1000, - redeemFees: 1000, - maxFundingFees: 500, - multiTradeRes: []*core.Order{{ - ID: id, - LockedAmt: 5e6 + 1000, - RedeemLockedAmt: 0, - Sell: true, - FeesPaid: &core.FeeBreakdown{ - Funding: 400, - }, - }, { - ID: id2, - LockedAmt: 5e6 + 1000, - RedeemLockedAmt: 0, - Sell: true, - }, - }, - postTradeBalances: map[uint32]*botBalance{ - 0: { - Available: 5e6, - }, - 42: { - Available: 100, - FundingOrder: 1e7 + 2000, - }, - }, - }, - // "edge not enough balance for multi sell" - { - name: "edge not enough balance for multi sell", - cfg: &BotConfig{ - Host: "host1", - BaseAsset: 42, - QuoteAsset: 0, - BaseBalanceType: Amount, - BaseBalance: 1e7 + 2499, - QuoteBalanceType: Amount, - QuoteBalance: 5e6, - }, - assetBalances: map[uint32]uint64{ - 0: 1e8, - 42: 1e8, - }, - multiTradeOnly: true, - multiTrade: &core.MultiTradeForm{ - Host: "host1", - Base: 42, - Quote: 0, - Sell: true, - Placements: []*core.QtyRate{ - { - Qty: 5e6, - Rate: 52e7, - }, - { - Qty: 5e6, - Rate: 5e7, - }, - }, - }, - market: &core.Market{ - LotSize: 5e6, - }, - swapFees: 1000, - redeemFees: 1000, - maxFundingFees: 500, - wantErr: true, - }, - // "edge enough balance for multi buy with redeem fees" - { - name: "edge enough balance for multi buy with redeem fees", - cfg: &BotConfig{ - Host: "host1", - BaseAsset: 42, - QuoteAsset: 0, - BaseBalanceType: Amount, - BaseBalance: 2000, - QuoteBalanceType: Amount, - QuoteBalance: calc.BaseToQuote(5e7, 5e6) + calc.BaseToQuote(52e7, 5e6) + 2000, - }, - assetBalances: map[uint32]uint64{ - 0: 1e8, - 42: 1e7, - }, - multiTradeOnly: true, - multiTrade: &core.MultiTradeForm{ - Host: "host1", - Base: 42, - Quote: 0, - Placements: []*core.QtyRate{ - { - Qty: 5e6, - Rate: 52e7, - }, - { - Qty: 5e6, - Rate: 5e7, - }, - }, - }, - market: &core.Market{ - LotSize: 5e6, - }, - swapFees: 1000, - redeemFees: 1000, - multiTradeRes: []*core.Order{{ - ID: id, - LockedAmt: calc.BaseToQuote(5e7, 5e6) + 1000, - RedeemLockedAmt: 1000, - Sell: true, - }, { - ID: id2, - LockedAmt: calc.BaseToQuote(52e7, 5e6) + 1000, - RedeemLockedAmt: 1000, - Sell: true, - }, - }, - postTradeBalances: map[uint32]*botBalance{ - 0: { - Available: 0, - FundingOrder: calc.BaseToQuote(5e7, 5e6) + calc.BaseToQuote(52e7, 5e6) + 2000, - }, - 42: { - Available: 0, - FundingOrder: 2000, - }, - }, - isAccountLocker: map[uint32]bool{42: true}, - }, - // "edge not enough balance for multi buy due to redeem fees" - { - name: "edge not enough balance for multi buy due to redeem fees", - cfg: &BotConfig{ - Host: "host1", - BaseAsset: 42, - QuoteAsset: 0, - BaseBalanceType: Amount, - BaseBalance: 1999, - QuoteBalanceType: Amount, - QuoteBalance: calc.BaseToQuote(5e7, 5e6) + calc.BaseToQuote(52e7, 5e6) + 2000, - }, - assetBalances: map[uint32]uint64{ - 0: 1e8, - 42: 1e7, - }, - multiTradeOnly: true, - multiTrade: &core.MultiTradeForm{ - Host: "host1", - Base: 42, - Quote: 0, - Placements: []*core.QtyRate{ - { - Qty: 5e6, - Rate: 52e7, - }, - { - Qty: 5e6, - Rate: 5e7, - }, - }, - }, - market: &core.Market{ - LotSize: 5e6, - }, - swapFees: 1000, - redeemFees: 1000, - wantErr: true, - isAccountLocker: map[uint32]bool{42: true}, - }, - // "edge enough balance for multi sell with redeem fees" - { - name: "edge enough balance for multi sell with redeem fees", - cfg: &BotConfig{ - Host: "host1", - BaseAsset: 42, - QuoteAsset: 0, - BaseBalanceType: Amount, - BaseBalance: 1e7 + 2000, - QuoteBalanceType: Amount, - QuoteBalance: 2000, - }, - assetBalances: map[uint32]uint64{ - 0: 1e8, - 42: 1e8, - }, - multiTradeOnly: true, - multiTrade: &core.MultiTradeForm{ - Host: "host1", - Base: 42, - Quote: 0, - Sell: true, - Placements: []*core.QtyRate{ - { - Qty: 5e6, - Rate: 52e7, - }, - { - Qty: 5e6, - Rate: 5e7, - }, - }, - }, - market: &core.Market{ - LotSize: 5e6, - }, - swapFees: 1000, - redeemFees: 1000, - multiTradeRes: []*core.Order{{ - ID: id, - LockedAmt: 5e6 + 1000, - RedeemLockedAmt: 1000, - Sell: true, - }, { - ID: id2, - LockedAmt: 5e6 + 1000, - RedeemLockedAmt: 1000, - Sell: true, - }, - }, - isAccountLocker: map[uint32]bool{0: true}, - postTradeBalances: map[uint32]*botBalance{ - 0: { - Available: 0, - FundingOrder: 2000, - }, - 42: { - Available: 0, - FundingOrder: 1e7 + 2000, - }, - }, - }, - // "edge not enough balance for multi sell due to redeem fees" - { - name: "edge enough balance for multi sell with redeem fees", - cfg: &BotConfig{ - Host: "host1", - BaseAsset: 42, - QuoteAsset: 0, - BaseBalanceType: Amount, - BaseBalance: 1e7 + 2000, - QuoteBalanceType: Amount, - QuoteBalance: 1999, - }, - assetBalances: map[uint32]uint64{ - 0: 1e8, - 42: 1e8, - }, - multiTradeOnly: true, - multiTrade: &core.MultiTradeForm{ - Host: "host1", - Base: 42, - Quote: 0, - Sell: true, - Placements: []*core.QtyRate{ - { - Qty: 5e6, - Rate: 52e7, - }, - { - Qty: 5e6, - Rate: 5e7, - }, - }, - }, - market: &core.Market{ - LotSize: 5e6, - }, - swapFees: 1000, - redeemFees: 1000, - isAccountLocker: map[uint32]bool{0: true}, - wantErr: true, - }, - } - - runTest := func(test *test) { - if test.multiTradeOnly && !testMultiTrade { - return - } - - mktID := dexMarketID(test.cfg.Host, test.cfg.BaseAsset, test.cfg.QuoteAsset) - - tCore := newTCore() - tCore.setAssetBalances(test.assetBalances) - tCore.market = test.market - var sell bool - if test.multiTradeOnly { - sell = test.multiTrade.Sell - } else { - sell = test.trade.Sell - } - - if sell { - tCore.sellSwapFees = test.swapFees - tCore.sellRedeemFees = test.redeemFees - tCore.sellRefundFees = test.refundFees - } else { - tCore.buySwapFees = test.swapFees - tCore.buyRedeemFees = test.redeemFees - tCore.buyRefundFees = test.refundFees - } - - if test.isAccountLocker == nil { - tCore.isAccountLocker = make(map[uint32]bool) - } else { - tCore.isAccountLocker = test.isAccountLocker - } - tCore.maxFundingFees = test.maxFundingFees - - if testMultiTrade { - if test.multiTradeOnly { - tCore.multiTradeResult = test.multiTradeRes - } else { - tCore.multiTradeResult = []*core.Order{test.tradeRes} - } - } else { - tCore.tradeResult = test.tradeRes - } - tCore.noteFeed = make(chan core.Notification) - - mm, done := tNewMarketMaker(t, tCore) - defer done() - mm.doNotKillWhenBotsStop = true - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - mm.UpdateBotConfig(test.cfg) - err := mm.Run(ctx, []byte{}, nil) - if err != nil { - t.Fatalf("%s: unexpected error: %v", test.name, err) - } - - segregatedCore := mm.wrappedCoreForBot(mktID) - - if testMultiTrade { - - if test.multiTradeOnly { - _, err = segregatedCore.MultiTrade([]byte{}, test.multiTrade) - } else { - _, err = segregatedCore.MultiTrade([]byte{}, &core.MultiTradeForm{ - Host: test.trade.Host, - Sell: test.trade.Sell, - Base: test.trade.Base, - Quote: test.trade.Quote, - Placements: []*core.QtyRate{ - { - Qty: test.trade.Qty, - Rate: test.trade.Rate, - }, - }, - Options: test.trade.Options, - }) - } - } else { - _, err = segregatedCore.Trade([]byte{}, test.trade) - } - if test.wantErr { - if err == nil { - t.Fatalf("%s: expected error but did not get", test.name) - } - return - } - if err != nil { - t.Fatalf("%s: unexpected error: %v", test.name, err) - } - - if err := assetBalancesMatch(test.postTradeBalances, mktID, mm); err != nil { - t.Fatalf("%s: unexpected post trade balance: %v", test.name, err) - } +var _ libxc.CEX = (*tCEX)(nil) - dummyNote := &core.BondRefundNote{} - for i, noteAndBalances := range test.notifications { - tCore.noteFeed <- noteAndBalances.note - tCore.noteFeed <- dummyNote - - if err := assetBalancesMatch(noteAndBalances.balance, mktID, mm); err != nil { - t.Fatalf("%s: unexpected balances after note %d: %v", test.name, i, err) - } - } +func (c *tCEX) Connect(ctx context.Context) (*sync.WaitGroup, error) { + return nil, nil +} +func (c *tCEX) Balances() (map[uint32]*libxc.ExchangeBalance, error) { + return nil, nil +} +func (c *tCEX) Markets() ([]*libxc.Market, error) { + return nil, nil +} +func (c *tCEX) Balance(assetID uint32) (*libxc.ExchangeBalance, error) { + return c.balances[assetID], c.balanceErr +} +func (c *tCEX) Trade(ctx context.Context, baseID, quoteID uint32, sell bool, rate, qty uint64, updaterID int) (*libxc.Trade, error) { + if c.tradeErr != nil { + return nil, c.tradeErr } - - for _, test := range tests { - runTest(&test) + c.lastTrade = &libxc.Trade{ + ID: c.tradeID, + BaseID: baseID, + QuoteID: quoteID, + Rate: rate, + Sell: sell, + Qty: qty, } + return c.lastTrade, nil } - -func cexBalancesMatch(expected map[uint32]uint64, botName string, mm *MarketMaker) error { - for assetID, exp := range expected { - actual := mm.botBalances[botName].cexBalances[assetID] - if exp != actual { - return fmt.Errorf("asset %d expected %d != actual %d", assetID, exp, actual) - } +func (c *tCEX) CancelTrade(ctx context.Context, seID, quoteID uint32, tradeID string) error { + if c.cancelTradeErr != nil { + return c.cancelTradeErr } - + c.cancelledTrades = append(c.cancelledTrades, tradeID) return nil } - -func TestSegregatedCEXTrade(t *testing.T) { - type noteAndBalances struct { - note *libxc.TradeUpdate - balances map[uint32]uint64 +func (c *tCEX) SubscribeMarket(ctx context.Context, baseID, quoteID uint32) error { + return nil +} +func (c *tCEX) UnsubscribeMarket(baseID, quoteID uint32) error { + return nil +} +func (c *tCEX) VWAP(baseID, quoteID uint32, sell bool, qty uint64) (vwap, extrema uint64, filled bool, err error) { + if c.vwapErr != nil { + return 0, 0, false, c.vwapErr } - tradeID := "abc" - - type test struct { - name string - - cfg *BotConfig - assetBalances map[uint32]uint64 - cexBalances map[uint32]uint64 - baseAsset uint32 - quoteAsset uint32 - sell bool - rate uint64 - qty uint64 - postTradeBals map[uint32]uint64 - notes []*noteAndBalances + if sell { + res, found := c.asksVWAP[qty] + if !found { + return 0, 0, false, nil + } + return res.avg, res.extrema, true, nil } - tests := []test{ - // "sell trade fully filled" - { - name: "sell trade fully filled", - cfg: &BotConfig{ - Host: "host", - BaseAsset: 42, - QuoteAsset: 0, - BaseBalanceType: Percentage, - BaseBalance: 100, - QuoteBalanceType: Percentage, - QuoteBalance: 100, - CEXCfg: &BotCEXCfg{ - Name: "Binance", - BaseBalanceType: Percentage, - BaseBalance: 100, - QuoteBalanceType: Percentage, - QuoteBalance: 100, - }, - }, - assetBalances: map[uint32]uint64{ - 42: 1e7, - 0: 1e7, - }, - cexBalances: map[uint32]uint64{ - 42: 1e7, - 0: 1e7, - }, - baseAsset: 42, - quoteAsset: 0, - sell: true, - rate: 5e7, - qty: 2e6, - postTradeBals: map[uint32]uint64{ - 42: 1e7 - 2e6, - 0: 1e7, - }, - notes: []*noteAndBalances{ - { - note: &libxc.TradeUpdate{ - TradeID: tradeID, - BaseFilled: 1e6, - QuoteFilled: calc.BaseToQuote(5.1e7, 1e6), - }, - balances: map[uint32]uint64{ - 42: 1e7 - 2e6, - 0: 1e7 + calc.BaseToQuote(5.1e7, 1e6), - }, - }, - { - note: &libxc.TradeUpdate{ - TradeID: tradeID, - BaseFilled: 2e6, - QuoteFilled: calc.BaseToQuote(5.05e7, 2e6), - Complete: true, - }, - balances: map[uint32]uint64{ - 42: 1e7 - 2e6, - 0: 1e7 + calc.BaseToQuote(5.05e7, 2e6), - }, - }, - }, - }, - // "buy trade fully filled" - { - name: "buy trade fully filled", - cfg: &BotConfig{ - Host: "host", - BaseAsset: 42, - QuoteAsset: 0, - BaseBalanceType: Percentage, - BaseBalance: 100, - QuoteBalanceType: Percentage, - QuoteBalance: 100, - CEXCfg: &BotCEXCfg{ - Name: "Binance", - BaseBalanceType: Percentage, - BaseBalance: 100, - QuoteBalanceType: Percentage, - QuoteBalance: 100, - }, - }, - assetBalances: map[uint32]uint64{ - 42: 1e7, - 0: 1e7, - }, - cexBalances: map[uint32]uint64{ - 42: 1e7, - 0: 1e7, - }, - baseAsset: 42, - quoteAsset: 0, - sell: false, - rate: 5e7, - qty: 2e6, - postTradeBals: map[uint32]uint64{ - 42: 1e7, - 0: 1e7 - calc.BaseToQuote(5e7, 2e6), - }, - notes: []*noteAndBalances{ - { - note: &libxc.TradeUpdate{ - TradeID: tradeID, - BaseFilled: 1e6, - QuoteFilled: calc.BaseToQuote(4.9e7, 1e6), - }, - balances: map[uint32]uint64{ - 42: 1e7 + 1e6, - 0: 1e7 - calc.BaseToQuote(5e7, 2e6), - }, - }, - { - note: &libxc.TradeUpdate{ - TradeID: tradeID, - BaseFilled: 2e6, - QuoteFilled: calc.BaseToQuote(4.95e7, 2e6), - Complete: true, - }, - balances: map[uint32]uint64{ - 42: 1e7 + 2e6, - 0: 1e7 - calc.BaseToQuote(4.95e7, 2e6), - }, - }, - }, - }, - // "sell trade partially filled" - { - name: "sell trade partially filled", - cfg: &BotConfig{ - Host: "host", - BaseAsset: 42, - QuoteAsset: 0, - BaseBalanceType: Percentage, - BaseBalance: 100, - QuoteBalanceType: Percentage, - QuoteBalance: 100, - CEXCfg: &BotCEXCfg{ - Name: "Binance", - BaseBalanceType: Percentage, - BaseBalance: 100, - QuoteBalanceType: Percentage, - QuoteBalance: 100, - }, - }, - assetBalances: map[uint32]uint64{ - 42: 1e7, - 0: 1e7, - }, - cexBalances: map[uint32]uint64{ - 42: 1e7, - 0: 1e7, - }, - baseAsset: 42, - quoteAsset: 0, - sell: true, - rate: 5e7, - qty: 2e6, - postTradeBals: map[uint32]uint64{ - 42: 1e7 - 2e6, - 0: 1e7, - }, - notes: []*noteAndBalances{ - { - note: &libxc.TradeUpdate{ - TradeID: tradeID, - BaseFilled: 1e6, - QuoteFilled: calc.BaseToQuote(5.1e7, 1e6), - }, - balances: map[uint32]uint64{ - 42: 1e7 - 2e6, - 0: 1e7 + calc.BaseToQuote(5.1e7, 1e6), - }, - }, - { - note: &libxc.TradeUpdate{ - TradeID: tradeID, - BaseFilled: 1e6, - QuoteFilled: calc.BaseToQuote(5.1e7, 1e6), - Complete: true, - }, - balances: map[uint32]uint64{ - 42: 1e7 - 1e6, - 0: 1e7 + calc.BaseToQuote(5.1e7, 1e6), - }, - }, - }, - }, - // "buy trade partially filled" - { - name: "buy trade partially filled", - cfg: &BotConfig{ - Host: "host", - BaseAsset: 42, - QuoteAsset: 0, - BaseBalanceType: Percentage, - BaseBalance: 100, - QuoteBalanceType: Percentage, - QuoteBalance: 100, - CEXCfg: &BotCEXCfg{ - Name: "Binance", - BaseBalanceType: Percentage, - BaseBalance: 100, - QuoteBalanceType: Percentage, - QuoteBalance: 100, - }, - }, - assetBalances: map[uint32]uint64{ - 42: 1e7, - 0: 1e7, - }, - cexBalances: map[uint32]uint64{ - 42: 1e7, - 0: 1e7, - }, - baseAsset: 42, - quoteAsset: 0, - sell: false, - rate: 5e7, - qty: 2e6, - postTradeBals: map[uint32]uint64{ - 42: 1e7, - 0: 1e7 - calc.BaseToQuote(5e7, 2e6), - }, - notes: []*noteAndBalances{ - { - note: &libxc.TradeUpdate{ - TradeID: tradeID, - BaseFilled: 1e6, - QuoteFilled: calc.BaseToQuote(4.9e7, 1e6), - }, - balances: map[uint32]uint64{ - 42: 1e7 + 1e6, - 0: 1e7 - calc.BaseToQuote(5e7, 2e6), - }, - }, - { - note: &libxc.TradeUpdate{ - TradeID: tradeID, - BaseFilled: 1e6, - QuoteFilled: calc.BaseToQuote(4.9e7, 1e6), - Complete: true, - }, - balances: map[uint32]uint64{ - 42: 1e7 + 1e6, - 0: 1e7 - calc.BaseToQuote(4.9e7, 1e6), - }, - }, - }, - }, + res, found := c.bidsVWAP[qty] + if !found { + return 0, 0, false, nil } + return res.avg, res.extrema, true, nil +} +func (c *tCEX) SubscribeTradeUpdates() (<-chan *libxc.Trade, func(), int) { + return c.tradeUpdates, func() {}, c.tradeUpdatesID +} +func (c *tCEX) SubscribeCEXUpdates() (<-chan interface{}, func()) { + return nil, func() {} +} +func (c *tCEX) GetDepositAddress(ctx context.Context, assetID uint32) (string, error) { + return c.depositAddress, nil +} - runTest := func(tt test) { - tCore := newTCore() - tCore.setAssetBalances(tt.assetBalances) - mm, done := tNewMarketMaker(t, tCore) - defer done() - - cex := newTCEX() - cex.balances = make(map[uint32]*libxc.ExchangeBalance) - cex.tradeID = tradeID - for assetID, balance := range tt.cexBalances { - cex.balances[assetID] = &libxc.ExchangeBalance{ - Available: balance, - } - } +func (c *tCEX) Withdraw(ctx context.Context, assetID uint32, qty uint64, address string, onComplete func(uint64, string)) error { + c.lastWithdrawArgs = &withdrawArgs{ + address: address, + amt: qty, + assetID: assetID, + } - botCfgs := []*BotConfig{tt.cfg} - cexes := map[string]libxc.CEX{ - tt.cfg.CEXCfg.Name: cex, - } + go func() { + withdrawal := <-c.confirmWithdrawal + onComplete(withdrawal.amt, withdrawal.txID) + c.confirmWithdrawalComplete <- true + }() - mm.setupBalances(botCfgs, cexes) + return nil +} - mktID := dexMarketID(tt.cfg.Host, tt.cfg.BaseAsset, tt.cfg.QuoteAsset) - wrappedCEX := mm.wrappedCEXForBot(mktID, cex) +func (c *tCEX) ConfirmDeposit(ctx context.Context, txID string, onConfirm func(bool, uint64)) { + c.lastConfirmDepositTx = txID - _, unsubscribe := wrappedCEX.SubscribeTradeUpdates() - defer unsubscribe() + go func() { + confirmDepositAmt := <-c.confirmDeposit + onConfirm(confirmDepositAmt > 0, confirmDepositAmt) + c.confirmDepositComplete <- true + }() +} - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() +type tBotCexAdaptor struct { + bidsVWAP map[uint64]vwapResult + asksVWAP map[uint64]vwapResult + vwapErr error + balances map[uint32]*botBalance + balanceErr error + tradeID string + tradeErr error + lastTrade *libxc.Trade + cancelledTrades []string + cancelTradeErr error + tradeUpdates chan *libxc.Trade + lastWithdrawArgs *withdrawArgs + lastDepositArgs *withdrawArgs + confirmDeposit func() + confirmWithdraw func() +} - _, err := wrappedCEX.Trade(ctx, tt.baseAsset, tt.quoteAsset, tt.sell, tt.rate, tt.qty) - if err != nil { - t.Fatalf("%s: unexpected Trade error: %v", tt.name, err) - } +func newTBotCEXAdaptor() *tBotCexAdaptor { + return &tBotCexAdaptor{ + bidsVWAP: make(map[uint64]vwapResult), + asksVWAP: make(map[uint64]vwapResult), + balances: make(map[uint32]*botBalance), + cancelledTrades: make([]string, 0), + tradeUpdates: make(chan *libxc.Trade), + } +} - err = cexBalancesMatch(tt.postTradeBals, mktID, mm) - if err != nil { - t.Fatalf("%s: post trade bals do not match: %v", tt.name, err) - } +var _ botCexAdaptor = (*tBotCexAdaptor)(nil) - for i, note := range tt.notes { - cex.tradeUpdates <- note.note - // send dummy update - cex.tradeUpdates <- &libxc.TradeUpdate{ - TradeID: "", - } - err = cexBalancesMatch(note.balances, mktID, mm) - if err != nil { - t.Fatalf("%s: balances do not match after note %d: %v", tt.name, i, err) - } - } - } +var tLogger = dex.StdOutLogger("mm_TEST", dex.LevelTrace) - for _, test := range tests { - runTest(test) +func (c *tBotCexAdaptor) Balance(assetID uint32) (*botBalance, error) { + return c.balances[assetID], c.balanceErr +} +func (c *tBotCexAdaptor) CancelTrade(ctx context.Context, baseID, quoteID uint32, tradeID string) error { + if c.cancelTradeErr != nil { + return c.cancelTradeErr } + c.cancelledTrades = append(c.cancelledTrades, tradeID) + return nil } - -func TestSegregatedCEXDeposit(t *testing.T) { - cexName := "Binance" - - type test struct { - name string - dexBalances map[uint32]uint64 - cexBalances map[uint32]uint64 - cfg *BotConfig - depositAmt uint64 - depositAsset uint32 - cexConfirm bool - cexReceivedAmt uint64 - isWithdrawer bool - - expError bool - expDexBalances map[uint32]*botBalance - expCexBalances map[uint32]uint64 +func (c *tBotCexAdaptor) SubscribeMarket(ctx context.Context, baseID, quoteID uint32) error { + return nil +} +func (c *tBotCexAdaptor) SubscribeTradeUpdates() (updates <-chan *libxc.Trade, unsubscribe func()) { + return c.tradeUpdates, func() {} +} +func (c *tBotCexAdaptor) Trade(ctx context.Context, baseID, quoteID uint32, sell bool, rate, qty uint64) (*libxc.Trade, error) { + if c.tradeErr != nil { + return nil, c.tradeErr } - tests := []test{ - { - name: "ok", - dexBalances: map[uint32]uint64{ - 42: 1e8, - 0: 1e8, - }, - cexBalances: map[uint32]uint64{ - 42: 1e7, - 0: 1e8, - }, - cfg: &BotConfig{ - Host: "dex.com", - BaseAsset: 42, - QuoteAsset: 0, - BaseBalanceType: Percentage, - BaseBalance: 100, - QuoteBalanceType: Percentage, - QuoteBalance: 100, - CEXCfg: &BotCEXCfg{ - Name: cexName, - BaseBalanceType: Percentage, - BaseBalance: 100, - QuoteBalanceType: Percentage, - QuoteBalance: 100, - }, - }, - depositAmt: 4e7, - depositAsset: 42, - cexConfirm: true, - cexReceivedAmt: 4e7 - 2000, - isWithdrawer: true, - expDexBalances: map[uint32]*botBalance{ - 42: { - Available: 1e8 - 4e7, - }, - 0: { - Available: 1e8, - }, - }, - expCexBalances: map[uint32]uint64{ - 42: 1e7 + 4e7 - 2000, - 0: 1e8, - }, - }, - { - name: "insufficient balance", - dexBalances: map[uint32]uint64{ - 42: 4e7 - 1, - 0: 1e8, - }, - cexBalances: map[uint32]uint64{ - 42: 1e7, - 0: 1e8, - }, - cfg: &BotConfig{ - Host: "dex.com", - BaseAsset: 42, - QuoteAsset: 0, - BaseBalanceType: Percentage, - BaseBalance: 100, - QuoteBalanceType: Percentage, - QuoteBalance: 100, - CEXCfg: &BotCEXCfg{ - Name: cexName, - BaseBalanceType: Percentage, - BaseBalance: 100, - QuoteBalanceType: Percentage, - QuoteBalance: 100, - }, - }, - depositAmt: 4e7, - depositAsset: 42, - cexConfirm: true, - cexReceivedAmt: 4e7 - 2000, - isWithdrawer: true, - expError: true, - }, - { - name: "cex failed to confirm deposit", - dexBalances: map[uint32]uint64{ - 42: 1e8, - 0: 1e8, - }, - cexBalances: map[uint32]uint64{ - 42: 1e7, - 0: 1e8, - }, - cfg: &BotConfig{ - Host: "dex.com", - BaseAsset: 42, - QuoteAsset: 0, - BaseBalanceType: Percentage, - BaseBalance: 100, - QuoteBalanceType: Percentage, - QuoteBalance: 100, - CEXCfg: &BotCEXCfg{ - Name: cexName, - BaseBalanceType: Percentage, - BaseBalance: 100, - QuoteBalanceType: Percentage, - QuoteBalance: 100, - }, - }, - depositAmt: 4e7, - depositAsset: 42, - cexConfirm: false, - cexReceivedAmt: 4e7 - 2000, - isWithdrawer: true, - expDexBalances: map[uint32]*botBalance{ - 42: { - Available: 1e8 - 4e7, - }, - 0: { - Available: 1e8, - }, - }, - expCexBalances: map[uint32]uint64{ - 42: 1e7, - 0: 1e8, - }, - }, + c.lastTrade = &libxc.Trade{ + ID: c.tradeID, + BaseID: baseID, + QuoteID: quoteID, + Rate: rate, + Sell: sell, + Qty: qty, + } + return c.lastTrade, nil +} +func (c *tBotCexAdaptor) VWAP(baseID, quoteID uint32, sell bool, qty uint64) (vwap, extrema uint64, filled bool, err error) { + if c.vwapErr != nil { + return 0, 0, false, c.vwapErr } - runTest := func(tt test) { - tCore := newTCore() - tCore.isWithdrawer[tt.depositAsset] = tt.isWithdrawer - tCore.setAssetBalances(tt.dexBalances) - - cex := newTCEX() - for assetID, balance := range tt.cexBalances { - cex.balances[assetID] = &libxc.ExchangeBalance{ - Available: balance, - } - } - cex.depositConfirmed = tt.cexConfirm - cex.confirmDepositAmt = tt.cexReceivedAmt - cex.depositAddress = hex.EncodeToString(encode.RandomBytes(32)) - - mm, done := tNewMarketMaker(t, tCore) - defer done() - mm.setupBalances([]*BotConfig{tt.cfg}, map[string]libxc.CEX{cexName: cex}) - mktID := dexMarketID(tt.cfg.Host, tt.cfg.BaseAsset, tt.cfg.QuoteAsset) - wrappedCEX := mm.wrappedCEXForBot(mktID, cex) - - wg := sync.WaitGroup{} - wg.Add(1) - onConfirm := func() { - wg.Done() - } - err := wrappedCEX.Deposit(context.Background(), tt.depositAsset, tt.depositAmt, onConfirm) - if err != nil { - if tt.expError { - return - } - t.Fatalf("%s: unexpected error: %v", tt.name, err) - } - if tt.expError { - t.Fatalf("%s: expected error but did not get", tt.name) - } - - wg.Wait() - - if err := assetBalancesMatch(tt.expDexBalances, mktID, mm); err != nil { - t.Fatalf("%s: %v", tt.name, err) - } - if err := cexBalancesMatch(tt.expCexBalances, mktID, mm); err != nil { - t.Fatalf("%s: %v", tt.name, err) - } - if tCore.lastSendArgs.address != cex.depositAddress { - t.Fatalf("%s: did not send to deposit address", tt.name) - } - if tCore.lastSendArgs.subtract != tt.isWithdrawer { - t.Fatalf("%s: withdrawer %v != subtract %v", tt.name, tt.isWithdrawer, tCore.lastSendArgs.subtract) - } - if tCore.lastSendArgs.value != tt.depositAmt { - t.Fatalf("%s: send value %d != expected %d", tt.name, tCore.lastSendArgs.value, tt.depositAmt) + if sell { + res, found := c.asksVWAP[qty] + if !found { + return 0, 0, false, nil } + return res.avg, res.extrema, true, nil } - for _, test := range tests { - runTest(test) + res, found := c.bidsVWAP[qty] + if !found { + return 0, 0, false, nil } + return res.avg, res.extrema, true, nil } - -func TestSegregatedCEXWithdraw(t *testing.T) { - cexName := "Binance" - - type test struct { - name string - dexBalances map[uint32]uint64 - cexBalances map[uint32]uint64 - cfg *BotConfig - withdrawAmt uint64 - withdrawAsset uint32 - cexWithdrawnAmt uint64 - - expError bool - expDexBalances map[uint32]*botBalance - expCexBalances map[uint32]uint64 - } - - tests := []test{ - { - name: "ok", - dexBalances: map[uint32]uint64{ - 42: 1e7, - 0: 1e8, - }, - cexBalances: map[uint32]uint64{ - 42: 1e8, - 0: 1e8, - }, - cfg: &BotConfig{ - Host: "dex.com", - BaseAsset: 42, - QuoteAsset: 0, - BaseBalanceType: Percentage, - BaseBalance: 100, - QuoteBalanceType: Percentage, - QuoteBalance: 100, - CEXCfg: &BotCEXCfg{ - Name: cexName, - BaseBalanceType: Percentage, - BaseBalance: 100, - QuoteBalanceType: Percentage, - QuoteBalance: 100, - }, - }, - withdrawAmt: 4e7, - withdrawAsset: 42, - cexWithdrawnAmt: 4e7 - 2000, - expDexBalances: map[uint32]*botBalance{ - 42: { - Available: 5e7 - 2000, - }, - 0: { - Available: 1e8, - }, - }, - expCexBalances: map[uint32]uint64{ - 42: 1e8 - 4e7, - 0: 1e8, - }, - }, - { - name: "insufficient balance", - dexBalances: map[uint32]uint64{ - 42: 1e8, - 0: 1e8, - }, - cexBalances: map[uint32]uint64{ - 42: 4e7 - 1, - 0: 1e8, - }, - cfg: &BotConfig{ - Host: "dex.com", - BaseAsset: 42, - QuoteAsset: 0, - BaseBalanceType: Percentage, - BaseBalance: 100, - QuoteBalanceType: Percentage, - QuoteBalance: 100, - CEXCfg: &BotCEXCfg{ - Name: cexName, - BaseBalanceType: Percentage, - BaseBalance: 50, - QuoteBalanceType: Percentage, - QuoteBalance: 50, - }, - }, - withdrawAmt: 4e7, - withdrawAsset: 42, - cexWithdrawnAmt: 4e7 - 2000, - expError: true, - }, - } - - runTest := func(tt test) { - tCore := newTCore() - tCore.setAssetBalances(tt.dexBalances) - tCore.txConfs = 1 - tCore.newDepositAddress = hex.EncodeToString(encode.RandomBytes(32)) - - cex := newTCEX() - for assetID, balance := range tt.cexBalances { - cex.balances[assetID] = &libxc.ExchangeBalance{ - Available: balance, - } - } - cex.withdrawAmt = tt.cexWithdrawnAmt - cex.withdrawTxID = hex.EncodeToString(encode.RandomBytes(32)) - - mm, done := tNewMarketMaker(t, tCore) - defer done() - mm.setupBalances([]*BotConfig{tt.cfg}, map[string]libxc.CEX{cexName: cex}) - mktID := dexMarketID(tt.cfg.Host, tt.cfg.BaseAsset, tt.cfg.QuoteAsset) - wrappedCEX := mm.wrappedCEXForBot(mktID, cex) - - wg := sync.WaitGroup{} - wg.Add(1) - onConfirm := func() { - wg.Done() - } - err := wrappedCEX.Withdraw(context.Background(), tt.withdrawAsset, tt.withdrawAmt, onConfirm) - if err != nil { - if tt.expError { - return - } - t.Fatalf("%s: unexpected error: %v", tt.name, err) - } - if tt.expError { - t.Fatalf("%s: expected error but did not get", tt.name) - } - wg.Wait() - - if err := assetBalancesMatch(tt.expDexBalances, mktID, mm); err != nil { - t.Fatalf("%s: %v", tt.name, err) - } - if err := cexBalancesMatch(tt.expCexBalances, mktID, mm); err != nil { - t.Fatalf("%s: %v", tt.name, err) - } - if cex.lastWithdrawArgs.address != tCore.newDepositAddress { - t.Fatalf("%s: did not send to deposit address", tt.name) - } +func (c *tBotCexAdaptor) Deposit(ctx context.Context, assetID uint32, amount uint64, onConfirm func()) error { + c.lastDepositArgs = &withdrawArgs{ + assetID: assetID, + amt: amount, } - - for _, test := range tests { - runTest(test) + c.confirmDeposit = onConfirm + return nil +} +func (c *tBotCexAdaptor) Withdraw(ctx context.Context, assetID uint32, amount uint64, onConfirm func()) error { + c.lastWithdrawArgs = &withdrawArgs{ + assetID: assetID, + amt: amount, } + c.confirmWithdraw = onConfirm + return nil } diff --git a/client/mm/wrapped_cex.go b/client/mm/wrapped_cex.go deleted file mode 100644 index 135df64465..0000000000 --- a/client/mm/wrapped_cex.go +++ /dev/null @@ -1,313 +0,0 @@ -// This code is available on the terms of the project LICENSE.md file, -// also available online at https://blueoakcouncil.org/license/1.0.0. - -package mm - -import ( - "context" - "errors" - "fmt" - "sync" - "time" - - "decred.org/dcrdex/client/asset" - "decred.org/dcrdex/client/mm/libxc" - "decred.org/dcrdex/dex" - "decred.org/dcrdex/dex/calc" -) - -type cexTrade struct { - qty uint64 - rate uint64 - sell bool - fromAsset uint32 - toAsset uint32 - baseFilled uint64 - quoteFilled uint64 -} - -// cex is an interface implemented by wrappedCEX. It is an interface used -// to interact with a centralized exchange. It assumes there is only one -// caller. It is generally similar to libxc.CEX, but with some notable -// differences: -// - A Deposit function is added, which takes funds from the bot's dex wallet -// and sends it to the cex. -// - SubscribeTradeUpdates/Trade do not return/take a subscription ID. -type cex interface { - Balance(assetID uint32) (*libxc.ExchangeBalance, error) - CancelTrade(ctx context.Context, baseID, quoteID uint32, tradeID string) error - SubscribeMarket(ctx context.Context, baseID, quoteID uint32) error - SubscribeTradeUpdates() (updates <-chan *libxc.TradeUpdate, unsubscribe func()) - Trade(ctx context.Context, baseID, quoteID uint32, sell bool, rate, qty uint64) (string, error) - VWAP(baseID, quoteID uint32, sell bool, qty uint64) (vwap, extrema uint64, filled bool, err error) - Deposit(ctx context.Context, assetID uint32, amount uint64, onConfirm func()) error - Withdraw(ctx context.Context, assetID uint32, amount uint64, onConfirm func()) error -} - -// wrappedCEX implements the cex interface. A separate instance should be -// created for each arbitrage bot, and it will behave as if the entire balance -// on the cex is the amount that was allocated to the bot. -type wrappedCEX struct { - libxc.CEX - - mm *MarketMaker - botID string - log dex.Logger - - subscriptionIDMtx sync.RWMutex - subscriptionID *int - - tradesMtx sync.RWMutex - trades map[string]*cexTrade -} - -var _ cex = (*wrappedCEX)(nil) - -// Balance returns the balance of the bot on the CEX. -func (w *wrappedCEX) Balance(assetID uint32) (*libxc.ExchangeBalance, error) { - return &libxc.ExchangeBalance{ - Available: w.mm.botCEXBalance(w.botID, assetID), - }, nil -} - -// Deposit deposits funds to the CEX. The deposited funds will be removed from -// the bot's wallet balance and allocated to the bot's CEX balance. -func (w *wrappedCEX) Deposit(ctx context.Context, assetID uint32, amount uint64, onConfirm func()) error { - balance := w.mm.botBalance(w.botID, assetID) - if balance < amount { - return fmt.Errorf("bot has insufficient balance to deposit %d. required: %v, have: %v", assetID, amount, balance) - } - - addr, err := w.CEX.GetDepositAddress(ctx, assetID) - if err != nil { - return err - } - - coin, err := w.mm.core.Send([]byte{}, assetID, amount, addr, w.mm.isWithdrawer(assetID)) - if err != nil { - return err - } - - w.mm.modifyBotBalance(w.botID, []*balanceMod{{balanceModDecrease, assetID, balTypeAvailable, amount}}) - - go func() { - conf := func(confirmed bool, amt uint64) { - if confirmed { - w.mm.modifyBotCEXBalance(w.botID, assetID, amt, balanceModIncrease) - } - onConfirm() - } - w.CEX.ConfirmDeposit(ctx, coin.TxID(), conf) - }() - - return nil -} - -// Withdraw withdraws funds from the CEX. The withdrawn funds will be removed -// from the bot's CEX balance and added to the bot's wallet balance. -func (w *wrappedCEX) Withdraw(ctx context.Context, assetID uint32, amount uint64, onConfirm func()) error { - symbol := dex.BipIDSymbol(assetID) - - balance := w.mm.botCEXBalance(w.botID, assetID) - if balance < amount { - return fmt.Errorf("bot has insufficient balance to withdraw %s. required: %v, have: %v", symbol, amount, balance) - } - - addr, err := w.mm.core.NewDepositAddress(assetID) - if err != nil { - return err - } - - conf := func(withdrawnAmt uint64, txID string) { - go func() { - checkTransaction := func() bool { - confs, err := w.mm.core.TransactionConfirmations(assetID, txID) - if err != nil { - if !errors.Is(err, asset.CoinNotFoundError) { - w.log.Errorf("error checking transaction confirmations: %v", err) - } - return false - } - if confs > 0 { - // TODO: get the amount withdrawn from the wallet instead of - // trusting the CEX. TxHistory could be used if it is - // implemented for all wallets. - w.mm.modifyBotBalance(w.botID, []*balanceMod{{balanceModIncrease, assetID, balTypeAvailable, withdrawnAmt}}) - onConfirm() - return true - } - return false - } - - if checkTransaction() { - return - } - - ticker := time.NewTicker(time.Minute * 1) - giveUp := time.NewTimer(2 * time.Hour) - defer ticker.Stop() - defer giveUp.Stop() - for { - select { - case <-ctx.Done(): - return - case <-ticker.C: - if checkTransaction() { - return - } - case <-giveUp.C: - w.log.Errorf("timed out waiting for withdrawal confirmation") - return - } - } - }() - } - - err = w.CEX.Withdraw(ctx, assetID, amount, addr, conf) - if err != nil { - return err - } - - w.mm.modifyBotCEXBalance(w.botID, assetID, amount, balanceModDecrease) - return nil -} - -func (w *wrappedCEX) handleTradeUpdate(update *libxc.TradeUpdate) { - w.tradesMtx.Lock() - defer w.tradesMtx.Unlock() - - trade, found := w.trades[update.TradeID] - if !found { - w.log.Errorf("wrappedCEX: trade ID %s not found", update.TradeID) - return - } - - if trade.sell && update.QuoteFilled > trade.quoteFilled { - quoteFilledDelta := update.QuoteFilled - trade.quoteFilled - w.mm.modifyBotCEXBalance(w.botID, trade.toAsset, quoteFilledDelta, balanceModIncrease) - trade.quoteFilled = update.QuoteFilled - trade.baseFilled = update.BaseFilled - } - - if !trade.sell && update.BaseFilled > trade.baseFilled { - baseFilledDelta := update.BaseFilled - trade.baseFilled - w.mm.modifyBotCEXBalance(w.botID, trade.toAsset, baseFilledDelta, balanceModIncrease) - trade.baseFilled = update.BaseFilled - trade.quoteFilled = update.QuoteFilled - } - - if !update.Complete { - return - } - - if trade.sell && trade.qty > trade.baseFilled { - unfilledQty := trade.qty - trade.baseFilled - w.mm.modifyBotCEXBalance(w.botID, trade.fromAsset, unfilledQty, balanceModIncrease) - } - - if !trade.sell && calc.BaseToQuote(trade.rate, trade.qty) > trade.quoteFilled { - unfilledQty := calc.BaseToQuote(trade.rate, trade.qty) - trade.quoteFilled - w.mm.modifyBotCEXBalance(w.botID, trade.fromAsset, unfilledQty, balanceModIncrease) - } - - delete(w.trades, update.TradeID) -} - -// SubscibeTradeUpdates subscribes to trade updates for the bot's trades on the -// CEX. This should be called before making any trades, and only once. -func (w *wrappedCEX) SubscribeTradeUpdates() (<-chan *libxc.TradeUpdate, func()) { - w.subscriptionIDMtx.Lock() - defer w.subscriptionIDMtx.Unlock() - if w.subscriptionID != nil { - w.log.Errorf("SubscribeTradeUpdates called more than once by bot %s", w.botID) - return nil, nil - } - - updates, unsubscribe, subscriptionID := w.CEX.SubscribeTradeUpdates() - w.subscriptionID = &subscriptionID - - ctx, cancel := context.WithCancel(context.Background()) - forwardUnsubscribe := func() { - cancel() - unsubscribe() - } - forwardUpdates := make(chan *libxc.TradeUpdate, 256) - go func() { - for { - select { - case <-ctx.Done(): - return - case note := <-updates: - w.handleTradeUpdate(note) - forwardUpdates <- note - } - } - }() - - return forwardUpdates, forwardUnsubscribe -} - -// Trade executes a trade on the CEX. The trade will be executed using the -// bot's CEX balance. -func (w *wrappedCEX) Trade(ctx context.Context, baseID, quoteID uint32, sell bool, rate, qty uint64) (string, error) { - var fromAssetID, toAssetID uint32 - var fromAssetQty uint64 - if sell { - fromAssetID = baseID - toAssetID = quoteID - fromAssetQty = qty - } else { - fromAssetID = quoteID - toAssetID = baseID - fromAssetQty = calc.BaseToQuote(rate, qty) - } - - fromAssetBal := w.mm.botCEXBalance(w.botID, fromAssetID) - if fromAssetBal < fromAssetQty { - return "", fmt.Errorf("asset bal < required for trade (%d < %d)", fromAssetBal, fromAssetQty) - } - - w.mm.modifyBotCEXBalance(w.botID, fromAssetID, fromAssetQty, balanceModDecrease) - var success bool - defer func() { - if !success { - w.mm.modifyBotCEXBalance(w.botID, fromAssetID, fromAssetQty, balanceModIncrease) - } - }() - - w.subscriptionIDMtx.RLock() - subscriptionID := w.subscriptionID - w.subscriptionIDMtx.RUnlock() - if w.subscriptionID == nil { - return "", fmt.Errorf("Trade called before SubscribeTradeUpdates") - } - - w.tradesMtx.Lock() - defer w.tradesMtx.Unlock() - tradeID, err := w.CEX.Trade(ctx, baseID, quoteID, sell, rate, qty, *subscriptionID) - if err != nil { - return "", err - } - - success = true - w.trades[tradeID] = &cexTrade{ - qty: qty, - sell: sell, - fromAsset: fromAssetID, - toAsset: toAssetID, - rate: rate, - } - - return tradeID, nil -} - -// wrappedCoreForBot returns a wrappedCore for the specified bot. -func (m *MarketMaker) wrappedCEXForBot(botID string, cex libxc.CEX) *wrappedCEX { - return &wrappedCEX{ - CEX: cex, - botID: botID, - log: m.log, - mm: m, - trades: make(map[string]*cexTrade), - } -} diff --git a/client/mm/wrapped_core.go b/client/mm/wrapped_core.go deleted file mode 100644 index 725cd81337..0000000000 --- a/client/mm/wrapped_core.go +++ /dev/null @@ -1,473 +0,0 @@ -// This code is available on the terms of the project LICENSE.md file, -// also available online at https://blueoakcouncil.org/license/1.0.0. - -package mm - -import ( - "fmt" - - "decred.org/dcrdex/client/asset" - "decred.org/dcrdex/client/core" - "decred.org/dcrdex/client/db" - "decred.org/dcrdex/dex" - "decred.org/dcrdex/dex/calc" - "decred.org/dcrdex/dex/order" -) - -// wrappedCore implements the clientCore interface. A separate -// instance should be created for each bot, and the core functions will behave -// as if the entire balance of the wallet is the amount that has been reserved -// for the bot. -type wrappedCore struct { - clientCore - - mm *MarketMaker - botID string - log dex.Logger -} - -var _ clientCore = (*wrappedCore)(nil) - -func (c *wrappedCore) maxBuyQty(host string, base, quote uint32, rate uint64, options map[string]string) (uint64, error) { - baseBalance := c.mm.botBalance(c.botID, base) - quoteBalance := c.mm.botBalance(c.botID, quote) - - mkt, err := c.ExchangeMarket(host, base, quote) - if err != nil { - return 0, err - } - - fundingFees, err := c.MaxFundingFees(quote, host, 1, options) - if err != nil { - return 0, err - } - - swapFees, redeemFees, refundFees, err := c.SingleLotFees(&core.SingleLotFeesForm{ - Host: host, - Base: base, - Quote: quote, - UseMaxFeeRate: true, - }) - if err != nil { - return 0, err - } - - if quoteBalance > fundingFees { - quoteBalance -= fundingFees - } else { - quoteBalance = 0 - } - - // Account based coins require the refund fees to be reserved as well. - if !c.mm.isAccountLocker(quote) { - refundFees = 0 - } - - lotSizeQuote := calc.BaseToQuote(rate, mkt.LotSize) - maxLots := quoteBalance / (lotSizeQuote + swapFees + refundFees) - - if redeemFees > 0 && c.mm.isAccountLocker(base) { - maxBaseLots := baseBalance / redeemFees - if maxLots > maxBaseLots { - maxLots = maxBaseLots - } - } - - return maxLots * mkt.LotSize, nil -} - -func (c *wrappedCore) maxSellQty(host string, base, quote, numTrades uint32, options map[string]string) (uint64, error) { - baseBalance := c.mm.botBalance(c.botID, base) - quoteBalance := c.mm.botBalance(c.botID, quote) - - mkt, err := c.ExchangeMarket(host, base, quote) - if err != nil { - return 0, err - } - - fundingFees, err := c.MaxFundingFees(base, host, numTrades, options) - if err != nil { - return 0, err - } - - swapFees, redeemFees, refundFees, err := c.SingleLotFees(&core.SingleLotFeesForm{ - Host: host, - Base: base, - Quote: quote, - Sell: true, - UseMaxFeeRate: true, - }) - if err != nil { - return 0, err - } - - if baseBalance > fundingFees { - baseBalance -= fundingFees - } else { - baseBalance = 0 - } - - // Account based coins require the refund fees to be reserved as well. - if !c.mm.isAccountLocker(base) { - refundFees = 0 - } - - maxLots := baseBalance / (mkt.LotSize + swapFees + refundFees) - if c.mm.isAccountLocker(quote) && redeemFees > 0 { - maxQuoteLots := quoteBalance / redeemFees - if maxLots > maxQuoteLots { - maxLots = maxQuoteLots - } - } - - return maxLots * mkt.LotSize, nil -} - -func (c *wrappedCore) sufficientBalanceForTrade(host string, base, quote uint32, sell bool, rate, qty uint64, options map[string]string) (bool, error) { - var maxQty uint64 - if sell { - var err error - maxQty, err = c.maxSellQty(host, base, quote, 1, options) - if err != nil { - return false, err - } - } else { - var err error - maxQty, err = c.maxBuyQty(host, base, quote, rate, options) - if err != nil { - return false, err - } - } - - return maxQty >= qty, nil -} - -func (c *wrappedCore) sufficientBalanceForMultiSell(host string, base, quote uint32, placements []*core.QtyRate, options map[string]string) (bool, error) { - var totalQty uint64 - for _, placement := range placements { - totalQty += placement.Qty - } - maxQty, err := c.maxSellQty(host, base, quote, uint32(len(placements)), options) - if err != nil { - return false, err - } - return maxQty >= totalQty, nil -} - -func (c *wrappedCore) sufficientBalanceForMultiBuy(host string, base, quote uint32, placements []*core.QtyRate, options map[string]string) (bool, error) { - baseBalance := c.mm.botBalance(c.botID, base) - quoteBalance := c.mm.botBalance(c.botID, quote) - - mkt, err := c.ExchangeMarket(host, base, quote) - if err != nil { - return false, err - } - - swapFees, redeemFees, refundFees, err := c.SingleLotFees(&core.SingleLotFeesForm{ - Host: host, - Base: base, - Quote: quote, - UseMaxFeeRate: true, - }) - if err != nil { - return false, err - } - - if !c.mm.isAccountLocker(quote) { - refundFees = 0 - } - - fundingFees, err := c.MaxFundingFees(quote, host, uint32(len(placements)), options) - if err != nil { - return false, err - } - if quoteBalance < fundingFees { - return false, nil - } - - var totalLots uint64 - remainingBalance := quoteBalance - fundingFees - for _, placement := range placements { - quoteQty := calc.BaseToQuote(placement.Rate, placement.Qty) - numLots := placement.Qty / mkt.LotSize - totalLots += numLots - req := quoteQty + (numLots * (swapFees + refundFees)) - if remainingBalance < req { - return false, nil - } - remainingBalance -= req - } - - if c.mm.isAccountLocker(base) && baseBalance < redeemFees*totalLots { - return false, nil - } - - return true, nil -} - -func (c *wrappedCore) sufficientBalanceForMultiTrade(host string, base, quote uint32, sell bool, placements []*core.QtyRate, options map[string]string) (bool, error) { - if sell { - return c.sufficientBalanceForMultiSell(host, base, quote, placements, options) - } - return c.sufficientBalanceForMultiBuy(host, base, quote, placements, options) -} - -// Trade checks that the bot has enough balance for the trade, and if not, -// immediately returns an error. Otherwise, it forwards the call to the -// underlying core. Then, the bot's balance in the balance handler is -// updated to reflect the trade, and the balanceHandler will start tracking -// updates to the order to ensure that the bot's balance is updated. -func (c *wrappedCore) Trade(pw []byte, form *core.TradeForm) (*core.Order, error) { - if !form.IsLimit { - return nil, fmt.Errorf("only limit orders are supported") - } - - enough, err := c.sufficientBalanceForTrade(form.Host, form.Base, form.Quote, form.Sell, form.Rate, form.Qty, form.Options) - if err != nil { - return nil, err - } - if !enough { - return nil, fmt.Errorf("insufficient balance") - } - - singleLotSwapFees, singleLotRedeemFees, singleLotRefundFees, err := c.SingleLotFees(&core.SingleLotFeesForm{ - Host: form.Host, - Base: form.Base, - Quote: form.Quote, - Sell: form.Sell, - UseMaxFeeRate: true, - }) - if err != nil { - return nil, err - } - - mkt, err := c.ExchangeMarket(form.Host, form.Base, form.Quote) - if err != nil { - return nil, err - } - - o, err := c.clientCore.Trade(pw, form) - if err != nil { - return nil, err - } - - var orderID order.OrderID - copy(orderID[:], o.ID) - - c.mm.ordersMtx.Lock() - c.mm.orders[orderID] = &orderInfo{ - bot: c.botID, - order: o, - initialFundsLocked: o.LockedAmt, - initialRedeemFeesLocked: o.RedeemLockedAmt, - initialRefundFeesLocked: o.RefundLockedAmt, - singleLotSwapFees: singleLotSwapFees, - singleLotRedeemFees: singleLotRedeemFees, - singleLotRefundFees: singleLotRefundFees, - lotSize: mkt.LotSize, - matchesSettled: make(map[order.MatchID]struct{}), - matchesSeen: make(map[order.MatchID]struct{}), - } - c.mm.ordersMtx.Unlock() - - fromAsset, toAsset := form.Quote, form.Base - if form.Sell { - fromAsset, toAsset = toAsset, fromAsset - } - - var fundingFees uint64 - if o.FeesPaid != nil { - fundingFees = o.FeesPaid.Funding - } - - balMods := []*balanceMod{ - {balanceModDecrease, fromAsset, balTypeAvailable, o.LockedAmt + o.RefundLockedAmt + fundingFees}, - {balanceModIncrease, fromAsset, balTypeFundingOrder, o.LockedAmt + o.RefundLockedAmt}, - } - if o.RedeemLockedAmt > 0 { - balMods = append(balMods, &balanceMod{balanceModDecrease, toAsset, balTypeAvailable, o.RedeemLockedAmt}) - balMods = append(balMods, &balanceMod{balanceModIncrease, toAsset, balTypeFundingOrder, o.RedeemLockedAmt}) - } - - c.mm.modifyBotBalance(c.botID, balMods) - - return o, nil -} - -func (c *wrappedCore) MultiTrade(pw []byte, form *core.MultiTradeForm) ([]*core.Order, error) { - enough, err := c.sufficientBalanceForMultiTrade(form.Host, form.Base, form.Quote, form.Sell, form.Placements, form.Options) - if err != nil { - return nil, err - } - if !enough { - return nil, fmt.Errorf("insufficient balance") - } - - singleLotSwapFees, singleLotRedeemFees, singleLotRefundFees, err := c.SingleLotFees(&core.SingleLotFeesForm{ - Host: form.Host, - Base: form.Base, - Quote: form.Quote, - Sell: form.Sell, - UseMaxFeeRate: true, - }) - if err != nil { - return nil, err - } - - mkt, err := c.ExchangeMarket(form.Host, form.Base, form.Quote) - if err != nil { - return nil, err - } - - fromAsset := form.Quote - toAsset := form.Base - if form.Sell { - fromAsset = form.Base - toAsset = form.Quote - } - form.MaxLock = c.mm.botBalance(c.botID, fromAsset) - - orders, err := c.clientCore.MultiTrade(pw, form) - if err != nil { - return nil, err - } - - var totalFromLocked, totalToLocked, fundingFeesPaid uint64 - for _, o := range orders { - var orderID order.OrderID - copy(orderID[:], o.ID) - - c.mm.ordersMtx.Lock() - c.mm.orders[orderID] = &orderInfo{ - bot: c.botID, - order: o, - initialFundsLocked: o.LockedAmt, - initialRedeemFeesLocked: o.RedeemLockedAmt, - initialRefundFeesLocked: o.RefundLockedAmt, - singleLotSwapFees: singleLotSwapFees, - singleLotRedeemFees: singleLotRedeemFees, - singleLotRefundFees: singleLotRefundFees, - lotSize: mkt.LotSize, - matchesSettled: make(map[order.MatchID]struct{}), - matchesSeen: make(map[order.MatchID]struct{}), - } - c.mm.ordersMtx.Unlock() - - totalFromLocked += o.LockedAmt - totalFromLocked += o.RefundLockedAmt - totalToLocked += o.RedeemLockedAmt - if o.FeesPaid != nil { - fundingFeesPaid += o.FeesPaid.Funding - } - } - - balMods := []*balanceMod{ - {balanceModDecrease, fromAsset, balTypeAvailable, totalFromLocked + fundingFeesPaid}, - {balanceModIncrease, fromAsset, balTypeFundingOrder, totalFromLocked}, - } - if totalToLocked > 0 { - balMods = append(balMods, &balanceMod{balanceModDecrease, toAsset, balTypeAvailable, totalToLocked}) - balMods = append(balMods, &balanceMod{balanceModIncrease, toAsset, balTypeFundingOrder, totalToLocked}) - } - c.mm.modifyBotBalance(c.botID, balMods) - - return orders, nil -} - -// MayBuy returns the maximum quantity of the base asset that the bot can -// buy for rate using its balance of the quote asset. -func (c *wrappedCore) MaxBuy(host string, base, quote uint32, rate uint64) (*core.MaxOrderEstimate, error) { - maxQty, err := c.maxBuyQty(host, base, quote, rate, nil) - if err != nil { - return nil, err - } - if maxQty == 0 { - return nil, fmt.Errorf("insufficient balance") - } - - orderEstimate, err := c.clientCore.PreOrder(&core.TradeForm{ - Host: host, - IsLimit: true, - Base: base, - Quote: quote, - Qty: maxQty, - Rate: rate, - // TODO: handle options. need new option for split if remaining balance < certain amount. - }) - if err != nil { - return nil, err - } - - return &core.MaxOrderEstimate{ - Swap: orderEstimate.Swap.Estimate, - Redeem: orderEstimate.Redeem.Estimate, - }, nil -} - -// MaxSell returned the maximum quantity of the base asset that the bot can -// sell. -func (c *wrappedCore) MaxSell(host string, base, quote uint32) (*core.MaxOrderEstimate, error) { - qty, err := c.maxSellQty(host, base, quote, 1, nil) - if err != nil { - return nil, err - } - if qty == 0 { - return nil, fmt.Errorf("insufficient balance") - } - - orderEstimate, err := c.clientCore.PreOrder(&core.TradeForm{ - Host: host, - IsLimit: true, - Sell: true, - Base: base, - Quote: quote, - Qty: qty, - }) - if err != nil { - return nil, err - } - - return &core.MaxOrderEstimate{ - Swap: orderEstimate.Swap.Estimate, - Redeem: orderEstimate.Redeem.Estimate, - }, nil -} - -// AssetBalance returns the bot's balance for a specific asset. -func (c *wrappedCore) AssetBalance(assetID uint32) (*core.WalletBalance, error) { - bal := c.mm.botBalance(c.botID, assetID) - - return &core.WalletBalance{ - Balance: &db.Balance{ - Balance: asset.Balance{ - Available: bal, - // TODO: handle locked funds - }, - }, - }, nil -} - -// PreOrder checks if the bot's balance is sufficient for the trade, and if it -// is, forwards the request to the underlying core. -func (c *wrappedCore) PreOrder(form *core.TradeForm) (*core.OrderEstimate, error) { - enough, err := c.sufficientBalanceForTrade(form.Host, form.Base, form.Quote, form.Sell, form.Rate, form.Qty, form.Options) - if err != nil { - return nil, err - } - - if !enough { - return nil, fmt.Errorf("insufficient balance") - } - - return c.clientCore.PreOrder(form) -} - -// wrappedCoreForBot returns a wrappedCore for the specified bot. -func (m *MarketMaker) wrappedCoreForBot(botID string) *wrappedCore { - return &wrappedCore{ - clientCore: m.core, - botID: botID, - log: m.log, - mm: m, - } -}