From eee601ffb0dc90d43a138dc0db3c2e02c73d1453 Mon Sep 17 00:00:00 2001 From: Peiqian Li Date: Mon, 1 Aug 2022 19:09:14 +0000 Subject: [PATCH] Initial full implementation of ZapPoolLiquidity. --- src/chainparams.cpp | 5 + src/consensus/params.h | 1 + src/init.cpp | 1 + src/masternodes/mn_checks.cpp | 136 ++++++++++++++++++ src/masternodes/mn_checks.h | 3 + src/masternodes/poolpairs.h | 15 ++ src/masternodes/rpc_customtx.cpp | 10 ++ src/masternodes/rpc_poolpair.cpp | 118 +++++++++++++++ test/functional/feature_poolpair_liquidity.py | 23 ++- 9 files changed, 308 insertions(+), 4 deletions(-) diff --git a/src/chainparams.cpp b/src/chainparams.cpp index 269361adb4..0d70103ea9 100644 --- a/src/chainparams.cpp +++ b/src/chainparams.cpp @@ -136,6 +136,7 @@ class CMainParams : public CChainParams { consensus.FortCanningRoadHeight = 1786000; // April 11, 2022. consensus.FortCanningCrunchHeight = 1936000; // June 2, 2022. consensus.FortCanningSpringHeight = 2033000; // July 6, 2022. + consensus.ApertureHeight = 2033001; // TODO: update this to a sensible height. consensus.GreatWorldHeight = std::numeric_limits::max(); consensus.pos.diffLimit = uint256S("00000fffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"); @@ -374,6 +375,7 @@ class CTestNetParams : public CChainParams { consensus.FortCanningRoadHeight = 893700; consensus.FortCanningCrunchHeight = 1011600; consensus.FortCanningSpringHeight = 1086000; + consensus.ApertureHeight = 1086001; // TODO: update this to a sensible value. consensus.GreatWorldHeight = std::numeric_limits::max(); consensus.pos.diffLimit = uint256S("00000fffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"); @@ -554,6 +556,7 @@ class CDevNetParams : public CChainParams { consensus.BayfrontHeight = 0; consensus.BayfrontMarinaHeight = 0; consensus.BayfrontGardensHeight = 0; + consensus.ApertureHeight = 0; consensus.ClarkeQuayHeight = 0; consensus.DakotaHeight = 10; consensus.DakotaCrescentHeight = 10; @@ -739,6 +742,7 @@ class CRegTestParams : public CChainParams { consensus.BayfrontHeight = 10000000; consensus.BayfrontMarinaHeight = 10000000; consensus.BayfrontGardensHeight = 10000000; + consensus.ApertureHeight = 10000000; consensus.ClarkeQuayHeight = 10000000; consensus.DakotaHeight = 10000000; consensus.DakotaCrescentHeight = 10000000; @@ -960,6 +964,7 @@ void SetupCommonArgActivationParams(Consensus::Params &consensus) { UpdateHeightValidation("AMK", "-amkheight", consensus.AMKHeight); UpdateHeightValidation("Bayfront", "-bayfrontheight", consensus.BayfrontHeight); UpdateHeightValidation("Bayfront Gardens", "-bayfrontgardensheight", consensus.BayfrontGardensHeight); + UpdateHeightValidation("Aperture", "-apertureheight", consensus.ApertureHeight); UpdateHeightValidation("Clarke Quay", "-clarkequayheight", consensus.ClarkeQuayHeight); UpdateHeightValidation("Dakota", "-dakotaheight", consensus.DakotaHeight); UpdateHeightValidation("Dakota Crescent", "-dakotacrescentheight", consensus.DakotaCrescentHeight); diff --git a/src/consensus/params.h b/src/consensus/params.h index f09891c38c..af14234563 100644 --- a/src/consensus/params.h +++ b/src/consensus/params.h @@ -98,6 +98,7 @@ struct Params { int FortCanningCrunchHeight; int FortCanningSpringHeight; int GreatWorldHeight; + int ApertureHeight; /** Foundation share after AMK, normalized to COIN = 100% */ CAmount foundationShareDFIP1; diff --git a/src/init.cpp b/src/init.cpp index cd568b4f05..35eec08390 100644 --- a/src/init.cpp +++ b/src/init.cpp @@ -489,6 +489,7 @@ void SetupServerArgs() gArgs.AddArg("-amkheight", "AMK fork activation height (regtest only)", ArgsManager::ALLOW_ANY | ArgsManager::DEBUG_ONLY, OptionsCategory::CHAINPARAMS); gArgs.AddArg("-bayfrontheight", "Bayfront fork activation height (regtest only)", ArgsManager::ALLOW_ANY | ArgsManager::DEBUG_ONLY, OptionsCategory::CHAINPARAMS); gArgs.AddArg("-bayfrontgardensheight", "Bayfront Gardens fork activation height (regtest only)", ArgsManager::ALLOW_ANY | ArgsManager::DEBUG_ONLY, OptionsCategory::CHAINPARAMS); + gArgs.AddArg("-apertureheight", "Aperture fork activation height (regtest only)", ArgsManager::ALLOW_ANY | ArgsManager::DEBUG_ONLY, OptionsCategory::CHAINPARAMS); gArgs.AddArg("-clarkequayheight", "ClarkeQuay fork activation height (regtest only)", ArgsManager::ALLOW_ANY | ArgsManager::DEBUG_ONLY, OptionsCategory::CHAINPARAMS); gArgs.AddArg("-dakotaheight", "Dakota fork activation height (regtest only)", ArgsManager::ALLOW_ANY | ArgsManager::DEBUG_ONLY, OptionsCategory::CHAINPARAMS); gArgs.AddArg("-dakotacrescentheight", "DakotaCrescent fork activation height (regtest only)", ArgsManager::ALLOW_ANY | ArgsManager::DEBUG_ONLY, OptionsCategory::CHAINPARAMS); diff --git a/src/masternodes/mn_checks.cpp b/src/masternodes/mn_checks.cpp index a0b8025aa5..f2d500d1eb 100644 --- a/src/masternodes/mn_checks.cpp +++ b/src/masternodes/mn_checks.cpp @@ -44,6 +44,7 @@ std::string ToString(CustomTxType type) { case CustomTxType::PoolSwapV2: return "PoolSwap"; case CustomTxType::AddPoolLiquidity: return "AddPoolLiquidity"; case CustomTxType::RemovePoolLiquidity: return "RemovePoolLiquidity"; + case CustomTxType::ZapPoolLiquidity: return "ZapPoolLiquidity"; case CustomTxType::UtxosToAccount: return "UtxosToAccount"; case CustomTxType::AccountToUtxos: return "AccountToUtxos"; case CustomTxType::AccountToAccount: return "AccountToAccount"; @@ -143,6 +144,7 @@ CCustomTxMessage customTypeToMessage(CustomTxType txType) { case CustomTxType::PoolSwapV2: return CPoolSwapMessageV2{}; case CustomTxType::AddPoolLiquidity: return CLiquidityMessage{}; case CustomTxType::RemovePoolLiquidity: return CRemoveLiquidityMessage{}; + case CustomTxType::ZapPoolLiquidity: return CZapLiquidityMessage{}; case CustomTxType::UtxosToAccount: return CUtxosToAccountMessage{}; case CustomTxType::AccountToUtxos: return CAccountToUtxosMessage{}; case CustomTxType::AccountToAccount: return CAccountToAccountMessage{}; @@ -202,6 +204,13 @@ class CCustomMetadataParseVisitor return Res::Ok(); } + Res isPostApertureFork() const { + if(static_cast(height) < consensus.ApertureHeight) { + return Res::Err("called before Aperture height"); + } + return Res::Ok(); + } + Res isPostBayfrontFork() const { if(static_cast(height) < consensus.BayfrontHeight) { return Res::Err("called before Bayfront height"); @@ -350,6 +359,11 @@ class CCustomMetadataParseVisitor return !res ? res : serialize(obj); } + Res operator()(CZapLiquidityMessage& obj) const { + auto res = isPostApertureFork(); + return !res ? res : serialize(obj); + } + Res operator()(CUtxosToAccountMessage& obj) const { auto res = isPostAMKFork(); return !res ? res : serialize(obj); @@ -1321,6 +1335,128 @@ class CCustomTxApplyVisitor : public CCustomTxVisitor return !res ? res : mnview.SetPoolPair(amount.nTokenId, height, pool); } + Res operator()(const CZapLiquidityMessage& obj) const { + CBalances sumTx = SumAllTransfers(obj.from); + if (sumTx.balances.size() != 1) { + return Res::Err("liquidity zapping requires a single input token"); + } + std::pair inputTokenAmount = *sumTx.balances.begin(); + + static const DCT_ID DFI_TOKEN_ID = {0}; + if (inputTokenAmount.first != DFI_TOKEN_ID || inputTokenAmount.second <= 0) { + return Res::Err("liquidity zapping requires a positive amount of DFI token as input"); + } + auto dusdTokenLookupResult = mnview.GetToken("DUSD"); + if (!dusdTokenLookupResult) + return Res::Err("Cannot find token DUSD"); + const DCT_ID DUSD_TOKEN_ID = dusdTokenLookupResult->first; + + std::optional pair = mnview.GetPoolPair(obj.poolId); + if (!pair) { + return Res::Err("invalid poolId"); + } + DCT_ID nonDUSDToken; + CAmount reserveDUSD, reserveNonDUSD; + if (pair->idTokenA == DUSD_TOKEN_ID) { + nonDUSDToken = pair->idTokenB; + reserveDUSD = pair->reserveA; + reserveNonDUSD = pair->reserveB; + } else if (pair->idTokenB == DUSD_TOKEN_ID) { + nonDUSDToken = pair->idTokenA; + reserveDUSD = pair->reserveB; + reserveNonDUSD = pair->reserveA; + } else { + return Res::Err("liquidity zapping only supports pools where one token is DUSD"); + } + + // Swap DFI for DUSD. + CAmount dusdAmount{0}; + for (const auto& [fromAccount, balances]: obj.from) { + if (!HasAuth(fromAccount)) { + return Res::Err("missing authorization by source account"); + } + for (const auto& [dct_id, amount]: balances.balances) { + if (dct_id != DFI_TOKEN_ID || amount <= 0) continue; + auto pool_swap = CPoolSwap(CPoolSwapMessage { + .from = fromAccount, + .to = obj.shareAddress, + .idTokenFrom = DFI_TOKEN_ID, + .idTokenTo = DUSD_TOKEN_ID, + .amountFrom = inputTokenAmount.second, + .maxPrice = POOLPRICE_MAX, + }, height); + Res dfiToDUSDSwapRes = pool_swap.ExecuteSwap(mnview, {}); + if (!dfiToDUSDSwapRes) return dfiToDUSDSwapRes; + dusdAmount += pool_swap.GetResult().nValue; + } + } + + // Binary search for the optimal DUSD amount to swap for the other token and provide liquidity. + CAmount dusdSwapAmount{0}; + for (CAmount a = 0, b = dusdAmount; a + 1 < b; ) { + dusdSwapAmount = (a + b) >> 1; + + // Simulate swap on a dummy view. + CCustomCSView dummy(mnview); + auto pool_swap = CPoolSwap(CPoolSwapMessage { + .from = obj.shareAddress, + .to = obj.shareAddress, + .idTokenFrom = DUSD_TOKEN_ID, + .idTokenTo = nonDUSDToken, + .amountFrom = dusdSwapAmount, + .maxPrice = POOLPRICE_MAX, + }, height); + Res simulatedSwapRes = pool_swap.ExecuteSwap(dummy, {}); + if (!simulatedSwapRes) return simulatedSwapRes; + CAmount nonDUSDLiquidityAmount = pool_swap.GetResult().nValue; + CAmount dusdLiquidityAmount = dusdAmount - nonDUSDLiquidityAmount; + + // Simulate adding liquidity. + CAmount liqNonDUSD = (arith_uint256(nonDUSDLiquidityAmount) * arith_uint256(pair->totalLiquidity) / reserveNonDUSD).GetLow64(); + CAmount liqDUSD = (arith_uint256(dusdLiquidityAmount) * arith_uint256(pair->totalLiquidity) / reserveDUSD).GetLow64(); + if (liqNonDUSD < liqDUSD) a = dusdSwapAmount; + else b = dusdSwapAmount; + } + + // Swap & add liquidity using the calculated amount. + auto pool_swap = CPoolSwap(CPoolSwapMessage { + .from = obj.shareAddress, + .to = obj.shareAddress, + .idTokenFrom = DUSD_TOKEN_ID, + .idTokenTo = nonDUSDToken, + .amountFrom = dusdSwapAmount, + .maxPrice = POOLPRICE_MAX, + }, height); + Res swapRes = pool_swap.ExecuteSwap(mnview, {}); + if (!swapRes) return swapRes; + CAmount nonDUSDLiquidityAmount = pool_swap.GetResult().nValue; + CAmount dusdLiquidityAmount = dusdAmount - nonDUSDLiquidityAmount; + + // Subtract DUSD and nonDUSD token balances. + mnview.SubBalances(obj.shareAddress, { + .balances = { + {DUSD_TOKEN_ID, dusdLiquidityAmount}, + {nonDUSDToken, nonDUSDLiquidityAmount} + } + }); + + // Add liquidity. + CAmount amountA, amountB; + if (pair->idTokenA == DUSD_TOKEN_ID) { + amountA = dusdLiquidityAmount; + amountB = nonDUSDLiquidityAmount; + } else { + amountA = nonDUSDLiquidityAmount; + amountB = dusdLiquidityAmount; + } + bool slippageProtection = static_cast(height) >= consensus.BayfrontMarinaHeight; + auto res = pair->AddLiquidity(amountA, amountB, [&] /*onMint*/(CAmount liqAmount) { + CBalances balance{TAmounts{{obj.poolId, liqAmount}}}; + return addBalanceSetShares(obj.shareAddress, balance); + }, slippageProtection); + return !res ? res : mnview.SetPoolPair(obj.poolId, height, *pair); + } + Res operator()(const CUtxosToAccountMessage& obj) const { // check enough tokens are "burnt" const auto burnt = BurntTokens(tx); diff --git a/src/masternodes/mn_checks.h b/src/masternodes/mn_checks.h index 979a0b54ec..c4e5fc0ef4 100644 --- a/src/masternodes/mn_checks.h +++ b/src/masternodes/mn_checks.h @@ -59,6 +59,7 @@ enum class CustomTxType : uint8_t PoolSwapV2 = 'i', AddPoolLiquidity = 'l', RemovePoolLiquidity = 'r', + ZapPoolLiquidity = 'Z', // accounts UtxosToAccount = 'U', AccountToUtxos = 'b', @@ -124,6 +125,7 @@ inline CustomTxType CustomTxCodeToType(uint8_t ch) { case CustomTxType::PoolSwapV2: case CustomTxType::AddPoolLiquidity: case CustomTxType::RemovePoolLiquidity: + case CustomTxType::ZapPoolLiquidity: case CustomTxType::UtxosToAccount: case CustomTxType::AccountToUtxos: case CustomTxType::AccountToAccount: @@ -347,6 +349,7 @@ using CCustomTxMessage = std::variant< CPoolSwapMessageV2, CLiquidityMessage, CRemoveLiquidityMessage, + CZapLiquidityMessage, CUtxosToAccountMessage, CAccountToUtxosMessage, CAccountToAccountMessage, diff --git a/src/masternodes/poolpairs.h b/src/masternodes/poolpairs.h index 1d78411174..b35ab3d393 100644 --- a/src/masternodes/poolpairs.h +++ b/src/masternodes/poolpairs.h @@ -284,6 +284,21 @@ struct CRemoveLiquidityMessage { } }; +struct CZapLiquidityMessage { + CAccounts from; + DCT_ID poolId; + CScript shareAddress; + + ADD_SERIALIZE_METHODS; + + template + inline void SerializationOp(Stream& s, Operation ser_action) { + READWRITE(from); + READWRITE(poolId); + READWRITE(shareAddress); + } +}; + bool poolInFee(const bool forward, const std::pair& asymmetricFee); bool poolOutFee(const bool forward, const std::pair& asymmetricFee); diff --git a/src/masternodes/rpc_customtx.cpp b/src/masternodes/rpc_customtx.cpp index 25563724f3..56f0f5413a 100644 --- a/src/masternodes/rpc_customtx.cpp +++ b/src/masternodes/rpc_customtx.cpp @@ -134,6 +134,16 @@ class CCustomTxRpcVisitor rpcInfo.pushKV("amount", obj.amount.ToString()); } + void operator()(const CZapLiquidityMessage& obj) const { + CBalances sumTx = SumAllTransfers(obj.from); + if (sumTx.balances.size() == 1) { + auto amount = *sumTx.balances.begin(); + rpcInfo.pushKV(amount.first.ToString(), ValueFromAmount(amount.second)); + rpcInfo.pushKV("poolid", obj.poolId.ToString()); + rpcInfo.pushKV("shareaddress", ScriptToString(obj.shareAddress)); + } + } + void operator()(const CUtxosToAccountMessage& obj) const { rpcInfo.pushKVs(accountsInfo(obj.to)); } diff --git a/src/masternodes/rpc_poolpair.cpp b/src/masternodes/rpc_poolpair.cpp index e32655dce0..fc2f4628e7 100644 --- a/src/masternodes/rpc_poolpair.cpp +++ b/src/masternodes/rpc_poolpair.cpp @@ -484,6 +484,123 @@ UniValue removepoolliquidity(const JSONRPCRequest& request) { return signsend(rawTx, pwallet, optAuthTx)->GetHash().GetHex(); } +UniValue zappoolliquidity(const JSONRPCRequest& request) { + auto pwallet = GetWallet(request); + + RPCHelpMan{"zappoolliquidity", + "\nCreates (and submits to local node and network) a zap pool liquidity transaction.\n" + "The last optional argument (may be empty array) is an array of specific UTXOs to spend." + + HelpRequiringPassphrase(pwallet) + "\n", + { + {"from", RPCArg::Type::OBJ, RPCArg::Optional::NO, "", + { + {"address", RPCArg::Type::STR, RPCArg::Optional::NO, "The defi address(es) is the key(s), the value(s) is amount in amount@token format. " + "You should provide exectly two types of tokens for pool's 'token A' and 'token B' in any combinations." + "If multiple tokens from one address are to be transferred, specify an array [\"amount1@t1\", \"amount2@t2\"]" + "If \"from\" obj contain only one amount entry with address-key: \"*\" (star), it's means auto-selection accounts from wallet."}, + }, + }, + {"pool", RPCArg::Type::STR, RPCArg::Optional::NO, + "The liquidity pool to zap liquidity into. One of the liquidity pool keys may be specified (id/symbol/creationTx)"}, + {"shareAddress", RPCArg::Type::STR, RPCArg::Optional::NO, "The defi address for crediting tokens."}, + {"inputs", RPCArg::Type::ARR, RPCArg::Optional::OMITTED_NAMED_ARG, + "A json array of json objects", + { + {"", RPCArg::Type::OBJ, RPCArg::Optional::OMITTED, "", + { + {"txid", RPCArg::Type::STR_HEX, RPCArg::Optional::NO, "The transaction id"}, + {"vout", RPCArg::Type::NUM, RPCArg::Optional::NO, "The output number"}, + }, + }, + }, + }, + }, + RPCResult{ + "\"hash\" (string) The hex-encoded hash of broadcasted transaction\n" + }, + RPCExamples{ + HelpExampleCli("zappoolliquidity", + "'{\"address1\":\"1.0@DFI\",\"address2\":\"1.0@DFI\"}' " + "pool_id share_address '[]'") + }, + }.Check(request); + + if (pwallet->chain().isInitialBlockDownload()) { + throw JSONRPCError(RPC_CLIENT_IN_INITIAL_DOWNLOAD, "Cannot create transactions while still in Initial Block Download"); + } + pwallet->BlockUntilSyncedToCurrentChain(); + + RPCTypeCheck(request.params, { UniValue::VOBJ, UniValue::VSTR, UniValue::VSTR, UniValue::VARR }, true); + + // decode + CZapLiquidityMessage msg{}; + if (request.params[0].get_obj().getKeys().size() == 1 && + request.params[0].get_obj().getKeys()[0] == "*") { // auto-selection accounts from wallet + + CAccounts foundMineAccounts = GetAllMineAccounts(pwallet); + + CBalances sumTransfers = DecodeAmounts(pwallet->chain(), request.params[0].get_obj()["*"], "*"); + + msg.from = SelectAccountsByTargetBalances(foundMineAccounts, sumTransfers, SelectionPie); + + if (msg.from.empty()) { + throw JSONRPCError(RPC_INVALID_REQUEST, + "Not enough balance on wallet accounts, call utxostoaccount to increase it.\n"); + } + } + else { + msg.from = DecodeRecipients(pwallet->chain(), request.params[0].get_obj()); + } + + auto token = pcustomcsview->GetTokenGuessId(request.params[1].getValStr(), msg.poolId); + if (!token || !pcustomcsview->GetPoolPair(msg.poolId)) { + throw JSONRPCError(RPC_INVALID_ADDRESS_OR_KEY, "Pool not found"); + } + + msg.shareAddress = DecodeScript(request.params[2].get_str()); + + // encode + CDataStream markedMetadata(DfTxMarker, SER_NETWORK, PROTOCOL_VERSION); + markedMetadata << static_cast(CustomTxType::ZapPoolLiquidity) + << msg; + CScript scriptMeta; + scriptMeta << OP_RETURN << ToByteVector(markedMetadata); + + int targetHeight = chainHeight(*pwallet->chain().lock()) + 1; + + const auto txVersion = GetTransactionVersion(targetHeight); + CMutableTransaction rawTx(txVersion); + rawTx.vout.push_back(CTxOut(0, scriptMeta)); + + // auth + std::set auths; + for (const auto& kv : msg.from) { + auths.emplace(kv.first); + } + UniValue const & txInputs = request.params[3]; + CTransactionRef optAuthTx; + rawTx.vin = GetAuthInputsSmart(pwallet, rawTx.nVersion, auths, false /*needFoundersAuth*/, optAuthTx, txInputs); + + CCoinControl coinControl; + + // Set change to from address if there's only one auth address + if (auths.size() == 1) { + CTxDestination dest; + ExtractDestination(*auths.cbegin(), dest); + if (IsValidDestination(dest)) { + coinControl.destChange = dest; + } + } + + // fund + fund(rawTx, pwallet, optAuthTx, &coinControl); + + // check execution + execTestTx(CTransaction(rawTx), targetHeight, optAuthTx); + + return signsend(rawTx, pwallet, optAuthTx)->GetHash().GetHex(); +} + UniValue createpoolpair(const JSONRPCRequest& request) { auto pwallet = GetWallet(request); @@ -1293,6 +1410,7 @@ static const CRPCCommand commands[] = {"poolpair", "getpoolpair", &getpoolpair, {"key", "verbose" }}, {"poolpair", "addpoolliquidity", &addpoolliquidity, {"from", "shareAddress", "inputs"}}, {"poolpair", "removepoolliquidity", &removepoolliquidity, {"from", "amount", "inputs"}}, + {"poolpair", "zappoolliquidity", &zappoolliquidity, {"from", "poolId", "shareAddress", "inputs"}}, {"poolpair", "createpoolpair", &createpoolpair, {"metadata", "inputs"}}, {"poolpair", "updatepoolpair", &updatepoolpair, {"metadata", "inputs"}}, {"poolpair", "poolswap", &poolswap, {"metadata", "inputs"}}, diff --git a/test/functional/feature_poolpair_liquidity.py b/test/functional/feature_poolpair_liquidity.py index d7a13a1534..644128ad87 100755 --- a/test/functional/feature_poolpair_liquidity.py +++ b/test/functional/feature_poolpair_liquidity.py @@ -29,10 +29,10 @@ def set_test_params(self): # node2: revert create (all) self.setup_clean_chain = True self.extra_args = [ - ['-txnotokens=0', '-amkheight=50', '-bayfrontheight=50'], - ['-txnotokens=0', '-amkheight=50', '-bayfrontheight=50'], - ['-txnotokens=0', '-amkheight=50', '-bayfrontheight=50'], - ['-txnotokens=0', '-amkheight=50', '-bayfrontheight=50']] + ['-txnotokens=0', '-amkheight=50', '-bayfrontheight=50', '-apertureheight=50'], + ['-txnotokens=0', '-amkheight=50', '-bayfrontheight=50', '-apertureheight=50'], + ['-txnotokens=0', '-amkheight=50', '-bayfrontheight=50', '-apertureheight=50'], + ['-txnotokens=0', '-amkheight=50', '-bayfrontheight=50', '-apertureheight=50']] def run_test(self): assert_equal(len(self.nodes[0].listtokens()), 1) # only one token == DFI @@ -188,6 +188,21 @@ def run_test(self): assert_equal(pool['1']['reserveB'], 500) assert_equal(pool['1']['totalLiquidity'], 150) + # Zap liquidity + #======================== + print("Testing zappoolliquidity") + + # more than one token. + try: + self.nodes[0].zappoolliquidity({ + accountGold: ["2@" + symbolGOLD, "3@" + symbolSILVER] + }, "1", accountGold, []) + except JSONRPCException as e: + errorString = e.error['message'] + assert("liquidity zapping requires a single input token" in errorString) + + # TODO(Aperture): Add more test cases. + # Remove liquidity #========================