From 4c2d251ec3cbdfb0d3f0c7d11e19dedafef1a4f5 Mon Sep 17 00:00:00 2001 From: jeremy lee Date: Wed, 12 Jun 2024 21:09:24 +0200 Subject: [PATCH] fix: qa route endpoint feat: enable withdrawals. also small fixes Bump version cleanup suppression remove squid integrator id check from skip method address detekt complaint cleanup --- .../processor/router/skip/SkipProcessor.kt | 6 +- .../router/skip/SkipRoutePayloadProcessor.kt | 40 +++- .../state/v2/supervisor/AccountSupervisor.kt | 73 ++++++++ .../exchange.dydx.abacus/utils/Map+Utils.kt | 12 ++ .../utils/String+Utils.kt | 6 + .../router/skip/SkipRouteProcessorTests.kt | 94 +++++++++- .../tests/payloads/SkipRouteMock.kt | 177 ++++++++++++++++++ 7 files changed, 405 insertions(+), 3 deletions(-) diff --git a/src/commonMain/kotlin/exchange.dydx.abacus/processor/router/skip/SkipProcessor.kt b/src/commonMain/kotlin/exchange.dydx.abacus/processor/router/skip/SkipProcessor.kt index 167391b35..c3978e996 100644 --- a/src/commonMain/kotlin/exchange.dydx.abacus/processor/router/skip/SkipProcessor.kt +++ b/src/commonMain/kotlin/exchange.dydx.abacus/processor/router/skip/SkipProcessor.kt @@ -6,6 +6,7 @@ import exchange.dydx.abacus.output.input.TransferInputTokenResource import exchange.dydx.abacus.processor.base.BaseProcessor import exchange.dydx.abacus.processor.router.IRouterProcessor import exchange.dydx.abacus.processor.router.SharedRouterProcessor +import exchange.dydx.abacus.processor.router.squid.SquidStatusProcessor import exchange.dydx.abacus.protocols.ParserProtocol import exchange.dydx.abacus.state.internalstate.InternalTransferInputState import exchange.dydx.abacus.state.manager.CctpConfig.cctpChainIds @@ -131,7 +132,10 @@ internal class SkipProcessor( payload: Map, transactionId: String?, ): Map? { - throw NotImplementedError("receivedStatus is not implemented in SkipProcessor!") +// using squid status processor until we implement it +// this lets us track our tx so we can more easily tell if tx are succeeding or not in QA + val processor = SquidStatusProcessor(parser, transactionId) + return processor.received(existing, payload) } override fun updateTokensDefaults(modified: MutableMap, selectedChainId: String?) { diff --git a/src/commonMain/kotlin/exchange.dydx.abacus/processor/router/skip/SkipRoutePayloadProcessor.kt b/src/commonMain/kotlin/exchange.dydx.abacus/processor/router/skip/SkipRoutePayloadProcessor.kt index d0777267f..cd053eca2 100644 --- a/src/commonMain/kotlin/exchange.dydx.abacus/processor/router/skip/SkipRoutePayloadProcessor.kt +++ b/src/commonMain/kotlin/exchange.dydx.abacus/processor/router/skip/SkipRoutePayloadProcessor.kt @@ -2,8 +2,13 @@ package exchange.dydx.abacus.processor.router.skip import exchange.dydx.abacus.processor.base.BaseProcessor import exchange.dydx.abacus.protocols.ParserProtocol +import exchange.dydx.abacus.utils.JsonEncoder import exchange.dydx.abacus.utils.safeSet +import exchange.dydx.abacus.utils.toCamelCaseKeys +@Suppress("ForbiddenComment") +// TODO: decide if we want to split this into one processor per network +// For now we're not since it's just two, but at 3 we will internal class SkipRoutePayloadProcessor(parser: ParserProtocol) : BaseProcessor(parser) { private val keyMap = mapOf( "string" to mapOf( @@ -25,17 +30,50 @@ internal class SkipRoutePayloadProcessor(parser: ParserProtocol) : BaseProcessor ), ) + enum class TxType { + EVM, + COSMOS + } + +// Create custom exceptions for better error handling specificity and expressiveness + @Suppress("TooGenericExceptionThrown") + internal fun getTxType(payload: Map): TxType { + val evm = parser.value(payload, "txs.0.evm_tx") + val cosmos = parser.value(payload, "txs.0.cosmos_tx") + if (evm != null) return TxType.EVM + if (cosmos != null) return TxType.COSMOS + throw Error("SkipRoutePayloadProcessor: txType is not evm or cosmos") + } + override fun received( existing: Map?, payload: Map ): Map { + val txType = getTxType(payload) val modified = transform(existing, payload, keyMap) val data = modified.get("data") - if (data != null) { + if (data != null && txType == TxType.EVM) { // skip does not provide the 0x prefix. it's not required but is good for clarity // and keeps our typing honest (we typecast this value to evmAddress in web) modified.safeSet("data", "0x$data") } + if (txType == TxType.COSMOS) { + val jsonEncoder = JsonEncoder() + val msg = parser.asString(parser.value(payload, "txs.0.cosmos_tx.msgs.0.msg")) + val msgMap = parser.decodeJsonObject(msg) +// tendermint client rejects msgs that aren't camelcased + val camelCasedMsgMap = msgMap?.toCamelCaseKeys() + val msgTypeUrl = parser.value(payload, "txs.0.cosmos_tx.msgs.0.msg_type_url") + val fullMessage = mapOf( + "msg" to camelCasedMsgMap, +// Squid returns the msg payload under the "value" key for noble transfers + "value" to camelCasedMsgMap, + "msgTypeUrl" to msgTypeUrl, +// Squid sometimes returns typeUrl or msgTypeUrl depending on the route version + "typeUrl" to msgTypeUrl, + ) + modified.safeSet("data", jsonEncoder.encode(fullMessage)) + } return modified } } diff --git a/src/commonMain/kotlin/exchange.dydx.abacus/state/v2/supervisor/AccountSupervisor.kt b/src/commonMain/kotlin/exchange.dydx.abacus/state/v2/supervisor/AccountSupervisor.kt index 90e5ab7c0..80dad36c4 100644 --- a/src/commonMain/kotlin/exchange.dydx.abacus/state/v2/supervisor/AccountSupervisor.kt +++ b/src/commonMain/kotlin/exchange.dydx.abacus/state/v2/supervisor/AccountSupervisor.kt @@ -8,6 +8,7 @@ import exchange.dydx.abacus.output.Notification import exchange.dydx.abacus.output.PerpetualState import exchange.dydx.abacus.output.Restriction import exchange.dydx.abacus.output.UsageRestriction +import exchange.dydx.abacus.processor.router.skip.SkipRoutePayloadProcessor import exchange.dydx.abacus.protocols.LocalTimerProtocol import exchange.dydx.abacus.protocols.QueryType import exchange.dydx.abacus.protocols.ThreadingType @@ -49,6 +50,7 @@ import exchange.dydx.abacus.utils.AnalyticsUtils import exchange.dydx.abacus.utils.CoroutineTimer import exchange.dydx.abacus.utils.IMap import exchange.dydx.abacus.utils.Logger +import exchange.dydx.abacus.utils.SLIPPAGE_PERCENT import exchange.dydx.abacus.utils.iMapOf import exchange.dydx.abacus.utils.mutable import exchange.dydx.abacus.utils.toJsonPrettyPrint @@ -459,6 +461,7 @@ internal open class AccountSupervisor( if (processingCctpWithdraw) { return@getOnChain } +// if pending withdrawal, perform CCTP Withdrawal pendingCctpWithdraw?.let { walletState -> processingCctpWithdraw = true val callback = walletState.callback @@ -477,6 +480,7 @@ internal open class AccountSupervisor( processingCctpWithdraw = false } } +// else, transfer noble balance back to dydx ?: run { transferNobleBalance(amount) } } else if (balance["error"] != null) { Logger.e { "Error checking noble balance: $response" } @@ -582,7 +586,19 @@ internal open class AccountSupervisor( } } + @Suppress("ForbiddenComment") +// TODO: rename to transferNobleToDydx or something more descriptive. +// This function is a unidirection transfer function that sweeps funds +// from a noble account into a user's given subaccount private fun transferNobleBalance(amount: BigDecimal) { + if (stateMachine.useSkip) { + transferNobleBalanceSkip(amount = amount) + } else { + transferNobleBalanceSquid(amount = amount) + } + } + + private fun transferNobleBalanceSquid(amount: BigDecimal) { val url = helper.configs.squidRoute() val fromChain = helper.configs.nobleChainId() val fromToken = helper.configs.nobleDenom() @@ -638,6 +654,63 @@ internal open class AccountSupervisor( } } } + private fun transferNobleBalanceSkip(amount: BigDecimal) { + val url = helper.configs.skipV2MsgsDirect() + val fromChain = helper.configs.nobleChainId() + val fromToken = helper.configs.nobleDenom() + val nobleAddress = accountAddress.toNobleAddress() + val chainId = helper.environment.dydxChainId + val dydxTokenDemon = helper.environment.tokens["usdc"]?.denom + if ( + fromChain != null && + fromToken != null && + nobleAddress != null && + chainId != null && + dydxTokenDemon != null + ) { + val body: Map = mapOf( + "amount_in" to amount.toPlainString(), +// from noble denom and chain + "source_asset_denom" to fromToken, + "source_asset_chain_id" to fromChain, +// to dydx denom and chain + "dest_asset_denom" to dydxTokenDemon, + "dest_asset_chain_id" to chainId, + "chain_ids_to_addresses" to mapOf( + fromChain to nobleAddress, + chainId to accountAddress.toString(), + ), + "slippage_tolerance_percent" to SLIPPAGE_PERCENT, + ) + val header = + iMapOf( + "Content-Type" to "application/json", + ) + helper.post(url, header, body.toJsonPrettyPrint()) { _, response, code, _ -> + if (response == null) { + val json = helper.parser.decodeJsonObject(response) + if (json != null) { + val skipRoutePayloadProcessor = SkipRoutePayloadProcessor(parser = helper.parser) + val processedPayload = skipRoutePayloadProcessor.received(existing = mapOf(), payload = json) + val ibcPayload = + helper.parser.asString( + processedPayload.get("data"), + ) + if (ibcPayload != null) { + helper.transaction(TransactionType.SendNobleIBC, ibcPayload) { + val error = helper.parseTransactionResponse(it) + if (error != null) { + Logger.e { "transferNobleBalanceSkip error: $error" } + } + } + } + } + } else { + Logger.e { "transferNobleBalanceSkip error, code: $code" } + } + } + } + } private fun handleComplianceResponse(response: String?, httpCode: Int): ComplianceStatus { var complianceStatus = ComplianceStatus.UNKNOWN diff --git a/src/commonMain/kotlin/exchange.dydx.abacus/utils/Map+Utils.kt b/src/commonMain/kotlin/exchange.dydx.abacus/utils/Map+Utils.kt index 132d5fdae..8075df3b8 100644 --- a/src/commonMain/kotlin/exchange.dydx.abacus/utils/Map+Utils.kt +++ b/src/commonMain/kotlin/exchange.dydx.abacus/utils/Map+Utils.kt @@ -141,3 +141,15 @@ fun IMap.toUrlParams(): String = entries.joinToString("&") { it.key + "=" + it.value } + +fun Map.toCamelCaseKeys(): Map { + return this.mapKeys { it.key.toCamelCase() }.mapValues { (_, value) -> + when (value) { + is Map<*, *> -> (value as Map).toCamelCaseKeys() + is List<*> -> value.map { + if (it is Map<*, *>) (it as Map).toCamelCaseKeys() else it + } + else -> value + } + } +} diff --git a/src/commonMain/kotlin/exchange.dydx.abacus/utils/String+Utils.kt b/src/commonMain/kotlin/exchange.dydx.abacus/utils/String+Utils.kt index 0ff749000..ac468c258 100644 --- a/src/commonMain/kotlin/exchange.dydx.abacus/utils/String+Utils.kt +++ b/src/commonMain/kotlin/exchange.dydx.abacus/utils/String+Utils.kt @@ -35,3 +35,9 @@ fun String.toDydxAddress(): String? { return null } } + +fun String.toCamelCase(): String { + return this.split("_", "-") + .mapIndexed { index, s -> if (index == 0) s.lowercase() else s.replaceFirstChar { if (it.isLowerCase()) it.titlecase() else it.toString() } } + .joinToString("") +} diff --git a/src/commonTest/kotlin/exchange.dydx.abacus/processor/router/skip/SkipRouteProcessorTests.kt b/src/commonTest/kotlin/exchange.dydx.abacus/processor/router/skip/SkipRouteProcessorTests.kt index 9e4b9c757..5d3f214b7 100644 --- a/src/commonTest/kotlin/exchange.dydx.abacus/processor/router/skip/SkipRouteProcessorTests.kt +++ b/src/commonTest/kotlin/exchange.dydx.abacus/processor/router/skip/SkipRouteProcessorTests.kt @@ -1,6 +1,7 @@ package exchange.dydx.abacus.processor.router.skip import exchange.dydx.abacus.tests.payloads.SkipRouteMock +import exchange.dydx.abacus.utils.JsonEncoder import exchange.dydx.abacus.utils.Parser import kotlin.test.Test import kotlin.test.assertEquals @@ -12,7 +13,8 @@ class SkipRouteProcessorTests { internal val skipRouteProcessor = SkipRouteProcessor(parser = parser) /** - * Tests a CCTP deposit. + * Tests an EVM CCTP deposit. + * This processes an EVM -> Noble USDC transaction (we only support deposits from EVM chains) */ @Test fun testReceivedCCTPDeposit() { @@ -36,6 +38,96 @@ class SkipRouteProcessorTests { assertTrue(expected == result) } + /** + * Tests a CCTP withdrawal initiated from the cctpToNobleSkip method + * This payload is used by the chain transaction method WithdrawToNobleIBC + * This processes a Dydx -> Noble CCTP transaction + */ + @Test + fun testReceivedCCTPDydxToNoble() { + val payload = skipRouteMock.payloadCCTPDydxToNoble + val result = skipRouteProcessor.received(existing = mapOf(), payload = templateToJson(payload), decimals = 6.0) + val jsonEncoder = JsonEncoder() + val expectedMsg = mapOf( + "sourcePort" to "transfer", + "sourceChannel" to "channel-0", + "token" to mapOf( + "denom" to "ibc/8E27BA2D5493AF5636760E354E46004562C46AB7EC0CC4C1CA14E9E20E2545B5", + "amount" to "10996029", + ), + "sender" to "dydx1nhzuazjhyfu474er6v4ey8zn6wa5fy6g2dgp7s", + "receiver" to "noble1nhzuazjhyfu474er6v4ey8zn6wa5fy6gthndxf", + "timeoutHeight" to mapOf(), + "timeoutTimestamp" to 1718308711061386287, + ) + val expectedData = jsonEncoder.encode( + mapOf( + "msg" to expectedMsg, + "value" to expectedMsg, + "msgTypeUrl" to "/ibc.applications.transfer.v1.MsgTransfer", + "typeUrl" to "/ibc.applications.transfer.v1.MsgTransfer", + ), + ) + val expected = mapOf( + "toAmountUSD" to 11.01, + "toAmount" to 10.996029, + "slippage" to "1", + "requestPayload" to mapOf( + "fromChainId" to "dydx-mainnet-1", + "fromAddress" to "ibc/8E27BA2D5493AF5636760E354E46004562C46AB7EC0CC4C1CA14E9E20E2545B5", + "toChainId" to "noble-1", + "toAddress" to "uusdc", + "data" to expectedData, + ), + ) + assertEquals(expected, result) + } + + /** + * Tests a CCTP autosweep from the transferNobleBalance method + * This payload is used by the chain transaction method sendNobleIBC + * This processes a Noble -> Dydx CCTP transaction + */ + @Test + fun testReceivedCCTPNobleToDydx() { + val payload = skipRouteMock.payloadCCTPNobleToDydx + val result = skipRouteProcessor.received(existing = mapOf(), payload = templateToJson(payload), decimals = 6.0) + val jsonEncoder = JsonEncoder() + val expectedMsg = mapOf( + "sourcePort" to "transfer", + "sourceChannel" to "channel-33", + "token" to mapOf( + "denom" to "uusdc", + "amount" to "5884", + ), + "sender" to "noble1nhzuazjhyfu474er6v4ey8zn6wa5fy6gthndxf", + "receiver" to "dydx1nhzuazjhyfu474er6v4ey8zn6wa5fy6g2dgp7s", + "timeoutHeight" to mapOf(), + "timeoutTimestamp" to 1718318348813666048, + ) + val expectedData = jsonEncoder.encode( + mapOf( + "msg" to expectedMsg, + "value" to expectedMsg, + "msgTypeUrl" to "/ibc.applications.transfer.v1.MsgTransfer", + "typeUrl" to "/ibc.applications.transfer.v1.MsgTransfer", + ), + ) + val expected = mapOf( + "toAmountUSD" to 0.01, + "toAmount" to 0.005884, + "slippage" to "1", + "requestPayload" to mapOf( + "fromChainId" to "noble-1", + "fromAddress" to "uusdc", + "toChainId" to "dydx-mainnet-1", + "toAddress" to "ibc/8E27BA2D5493AF5636760E354E46004562C46AB7EC0CC4C1CA14E9E20E2545B5", + "data" to expectedData, + ), + ) + assertEquals(expected, result) + } + @Test fun testReceivedError() { val payload = skipRouteMock.payloadError diff --git a/src/commonTest/kotlin/exchange.dydx.abacus/tests/payloads/SkipRouteMock.kt b/src/commonTest/kotlin/exchange.dydx.abacus/tests/payloads/SkipRouteMock.kt index 9b834ec81..3ea3ec290 100644 --- a/src/commonTest/kotlin/exchange.dydx.abacus/tests/payloads/SkipRouteMock.kt +++ b/src/commonTest/kotlin/exchange.dydx.abacus/tests/payloads/SkipRouteMock.kt @@ -107,6 +107,183 @@ internal class SkipRouteMock { ] } }""" + internal val payloadCCTPDydxToNoble = """ + { + "msgs": [ + { + "multi_chain_msg": { + "chain_id": "dydx-mainnet-1", + "path": [ + "dydx-mainnet-1", + "noble-1" + ], + "msg": "{\"source_port\":\"transfer\",\"source_channel\":\"channel-0\",\"token\":{\"denom\":\"ibc/8E27BA2D5493AF5636760E354E46004562C46AB7EC0CC4C1CA14E9E20E2545B5\",\"amount\":\"10996029\"},\"sender\":\"dydx1nhzuazjhyfu474er6v4ey8zn6wa5fy6g2dgp7s\",\"receiver\":\"noble1nhzuazjhyfu474er6v4ey8zn6wa5fy6gthndxf\",\"timeout_height\":{},\"timeout_timestamp\":1718308711061386287}", + "msg_type_url": "/ibc.applications.transfer.v1.MsgTransfer" + } + } + ], + "txs": [ + { + "cosmos_tx": { + "chain_id": "dydx-mainnet-1", + "path": [ + "dydx-mainnet-1", + "noble-1" + ], + "msgs": [ + { + "msg": "{\"source_port\":\"transfer\",\"source_channel\":\"channel-0\",\"token\":{\"denom\":\"ibc/8E27BA2D5493AF5636760E354E46004562C46AB7EC0CC4C1CA14E9E20E2545B5\",\"amount\":\"10996029\"},\"sender\":\"dydx1nhzuazjhyfu474er6v4ey8zn6wa5fy6g2dgp7s\",\"receiver\":\"noble1nhzuazjhyfu474er6v4ey8zn6wa5fy6gthndxf\",\"timeout_height\":{},\"timeout_timestamp\":1718308711061386287}", + "msg_type_url": "/ibc.applications.transfer.v1.MsgTransfer" + } + ], + "signer_address": "dydx1nhzuazjhyfu474er6v4ey8zn6wa5fy6g2dgp7s" + }, + "operations_indices": [ + 0 + ] + } + ], + "route": { + "source_asset_denom": "ibc/8E27BA2D5493AF5636760E354E46004562C46AB7EC0CC4C1CA14E9E20E2545B5", + "source_asset_chain_id": "dydx-mainnet-1", + "dest_asset_denom": "uusdc", + "dest_asset_chain_id": "noble-1", + "amount_in": "10996029", + "amount_out": "10996029", + "operations": [ + { + "transfer": { + "port": "transfer", + "channel": "channel-0", + "from_chain_id": "dydx-mainnet-1", + "to_chain_id": "noble-1", + "pfm_enabled": false, + "supports_memo": true, + "denom_in": "ibc/8E27BA2D5493AF5636760E354E46004562C46AB7EC0CC4C1CA14E9E20E2545B5", + "denom_out": "uusdc", + "bridge_id": "IBC", + "smart_relay": false, + "chain_id": "dydx-mainnet-1", + "dest_denom": "uusdc" + }, + "tx_index": 0, + "amount_in": "10996029", + "amount_out": "10996029" + } + ], + "chain_ids": [ + "dydx-mainnet-1", + "noble-1" + ], + "does_swap": false, + "estimated_amount_out": "10996029", + "swap_venues": [], + "txs_required": 1, + "usd_amount_in": "11.01", + "usd_amount_out": "11.01", + "estimated_fees": [], + "required_chain_addresses": [ + "dydx-mainnet-1", + "noble-1" + ] + } +} + """.trimIndent() + + internal val payloadCCTPNobleToDydx = """ + { + "msgs": [ + { + "multi_chain_msg": { + "chain_id": "noble-1", + "path": [ + "noble-1", + "dydx-mainnet-1" + ], + "msg": "{\"source_port\":\"transfer\",\"source_channel\":\"channel-33\",\"token\":{\"denom\":\"uusdc\",\"amount\":\"5884\"},\"sender\":\"noble1nhzuazjhyfu474er6v4ey8zn6wa5fy6gthndxf\",\"receiver\":\"dydx1nhzuazjhyfu474er6v4ey8zn6wa5fy6g2dgp7s\",\"timeout_height\":{},\"timeout_timestamp\":1718318348813666048}", + "msg_type_url": "/ibc.applications.transfer.v1.MsgTransfer" + } + } + ], + "txs": [ + { + "cosmos_tx": { + "chain_id": "noble-1", + "path": [ + "noble-1", + "dydx-mainnet-1" + ], + "msgs": [ + { + "msg": "{\"source_port\":\"transfer\",\"source_channel\":\"channel-33\",\"token\":{\"denom\":\"uusdc\",\"amount\":\"5884\"},\"sender\":\"noble1nhzuazjhyfu474er6v4ey8zn6wa5fy6gthndxf\",\"receiver\":\"dydx1nhzuazjhyfu474er6v4ey8zn6wa5fy6g2dgp7s\",\"timeout_height\":{},\"timeout_timestamp\":1718318348813666048}", + "msg_type_url": "/ibc.applications.transfer.v1.MsgTransfer" + } + ], + "signer_address": "noble1nhzuazjhyfu474er6v4ey8zn6wa5fy6gthndxf" + }, + "operations_indices": [ + 0 + ] + } + ], + "route": { + "source_asset_denom": "uusdc", + "source_asset_chain_id": "noble-1", + "dest_asset_denom": "ibc/8E27BA2D5493AF5636760E354E46004562C46AB7EC0CC4C1CA14E9E20E2545B5", + "dest_asset_chain_id": "dydx-mainnet-1", + "amount_in": "5884", + "amount_out": "5884", + "operations": [ + { + "transfer": { + "port": "transfer", + "channel": "channel-33", + "from_chain_id": "noble-1", + "to_chain_id": "dydx-mainnet-1", + "pfm_enabled": true, + "supports_memo": true, + "denom_in": "uusdc", + "denom_out": "ibc/8E27BA2D5493AF5636760E354E46004562C46AB7EC0CC4C1CA14E9E20E2545B5", + "fee_amount": "0", + "usd_fee_amount": "0.0000", + "fee_asset": { + "denom": "uusdc", + "chain_id": "noble-1", + "origin_denom": "uusdc", + "origin_chain_id": "noble-1", + "trace": "", + "is_cw20": false, + "is_evm": false, + "is_svm": false + }, + "bridge_id": "IBC", + "smart_relay": false, + "chain_id": "noble-1", + "dest_denom": "ibc/8E27BA2D5493AF5636760E354E46004562C46AB7EC0CC4C1CA14E9E20E2545B5" + }, + "tx_index": 0, + "amount_in": "5884", + "amount_out": "5884" + } + ], + "chain_ids": [ + "noble-1", + "dydx-mainnet-1" + ], + "does_swap": false, + "estimated_amount_out": "5884", + "swap_venues": [], + "txs_required": 1, + "usd_amount_in": "0.01", + "usd_amount_out": "0.01", + "estimated_fees": [], + "required_chain_addresses": [ + "noble-1", + "dydx-mainnet-1" + ] + } + } + """.trimIndent() internal val payloadError = """ { "code": 3,