diff --git a/.env.develop b/.env.develop index c90c091b80d..997131a3a57 100644 --- a/.env.develop +++ b/.env.develop @@ -1,6 +1,7 @@ # feature flags REACT_APP_FEATURE_CHATWOOT=true REACT_APP_FEATURE_RFOX=true +REACT_APP_FEATURE_THORCHAINSWAP_L1_TO_LONGTAIL=true REACT_APP_FEATURE_ARBITRUM_BRIDGE=true # mixpanel diff --git a/src/lib/swapper/swappers/ThorchainSwapper/constants.ts b/src/lib/swapper/swappers/ThorchainSwapper/constants.ts index 2a256efeb71..a61057c3125 100644 --- a/src/lib/swapper/swappers/ThorchainSwapper/constants.ts +++ b/src/lib/swapper/swappers/ThorchainSwapper/constants.ts @@ -1,8 +1,9 @@ -import type { ChainId } from '@shapeshiftoss/caip' +import { bchChainId, type ChainId } from '@shapeshiftoss/caip' import type { SwapSource } from '@shapeshiftoss/swapper' import { SwapperName } from '@shapeshiftoss/swapper' import { KnownChainIds } from '@shapeshiftoss/types' import type { SupportedChainIds } from 'lib/swapper/types' +import { isUtxoChainId } from 'lib/utils/utxo' export const sellSupportedChainIds: Record = { [KnownChainIds.EthereumMainnet]: true, @@ -47,5 +48,11 @@ export const UNI_V3_ETHEREUM_POOL_FACTORY_CONTRACT_ADDRESS = '0x1F98431c8aD98523631AE4a59f267346ea31F984' export const ALLOWANCE_CONTRACT = '0xF892Fef9dA200d9E84c9b0647ecFF0F34633aBe8' // TSAggregatorTokenTransferProxy -export const UTXO_MAXIMUM_BYTES_LENGTH = 80 +export const BTC_MAXIMUM_BYTES_LENGTH = 80 export const BCH_MAXIMUM_BYTES_LENGTH = 220 + +export const getMaxBytesLengthByChainId = (chainId: ChainId) => { + if (chainId === bchChainId) return BCH_MAXIMUM_BYTES_LENGTH + if (isUtxoChainId(chainId)) return BTC_MAXIMUM_BYTES_LENGTH + return Infinity +} diff --git a/src/lib/swapper/swappers/ThorchainSwapper/utils/addAggregatorAndDestinationToMemo.test.ts b/src/lib/swapper/swappers/ThorchainSwapper/utils/addAggregatorAndDestinationToMemo.test.ts deleted file mode 100644 index 925595da15b..00000000000 --- a/src/lib/swapper/swappers/ThorchainSwapper/utils/addAggregatorAndDestinationToMemo.test.ts +++ /dev/null @@ -1,130 +0,0 @@ -import { bchChainId, CHAIN_NAMESPACE, dogeChainId, ethChainId } from '@shapeshiftoss/caip' -import { describe, expect, it } from 'vitest' - -import { addAggregatorAndDestinationToMemo } from './addAggregatorAndDestinationToMemo' -import { MEMO_PART_DELIMITER } from './constants' - -const RECEIVE_ADDRESS = '0x32DBc9Cf9E8FbCebE1e0a2ecF05Ed86Ca3096Cb6' -const AGGREGATOR_ADDRESS = '0xd31f7e39afECEc4855fecc51b693F9A0Cec49fd2' -const FINAL_ASSET_ADDRESS = '0x8a65ac0E23F31979db06Ec62Af62b132a6dF4741' -const AGGREGATOR_TWO_LAST_CHARS = 'd2' - -describe('addAggregatorAndDestinationToMemo', () => { - it('should add aggregator address, destination address and minAmountOut correctly', () => { - const minAmountOut = '9508759019' - const affiliateBps = '100' - const expectedL1AmountOut = '42' // we don't care about this for the purpose of tests - const quotedMemo = `=:ETH.ETH:${RECEIVE_ADDRESS}:${expectedL1AmountOut}:ss:${affiliateBps}:${AGGREGATOR_TWO_LAST_CHARS}:${FINAL_ASSET_ADDRESS}:10` - - const slippageBps = 100 // 1% - - const modifiedMemo = addAggregatorAndDestinationToMemo({ - sellChainId: ethChainId, - quotedMemo, - aggregator: AGGREGATOR_ADDRESS, - finalAssetAddress: FINAL_ASSET_ADDRESS, - minAmountOut, - slippageBps, - finalAssetPrecision: 9, - chainNamespace: CHAIN_NAMESPACE.Evm, - }) - - expect(modifiedMemo).toBe( - `=${MEMO_PART_DELIMITER}e${MEMO_PART_DELIMITER}${RECEIVE_ADDRESS}${MEMO_PART_DELIMITER}${expectedL1AmountOut}${MEMO_PART_DELIMITER}ss${MEMO_PART_DELIMITER}${affiliateBps}${MEMO_PART_DELIMITER}${AGGREGATOR_TWO_LAST_CHARS}${MEMO_PART_DELIMITER}${FINAL_ASSET_ADDRESS}${MEMO_PART_DELIMITER}941367103`, - ) - }) - - it('should add aggregator address, destination address and minAmountOut correctly with a bigger precision', () => { - const minAmountOut = '2083854765519275828179229' - const affiliateBps = '100' - const expectedL1AmountOut = '42' // we don't care about this for the purpose of tests - const quotedMemo = `=:ETH.ETH:${RECEIVE_ADDRESS}:${expectedL1AmountOut}:ss:${affiliateBps}:${AGGREGATOR_TWO_LAST_CHARS}:${FINAL_ASSET_ADDRESS}:10` - - const slippageBps = 100 // 1% - - const modifiedMemo = addAggregatorAndDestinationToMemo({ - sellChainId: ethChainId, - quotedMemo, - aggregator: AGGREGATOR_ADDRESS, - finalAssetAddress: FINAL_ASSET_ADDRESS, - minAmountOut, - slippageBps, - finalAssetPrecision: 18, - chainNamespace: CHAIN_NAMESPACE.Evm, - }) - - expect(modifiedMemo).toBe( - `=${MEMO_PART_DELIMITER}e${MEMO_PART_DELIMITER}${RECEIVE_ADDRESS}${MEMO_PART_DELIMITER}${expectedL1AmountOut}${MEMO_PART_DELIMITER}ss${MEMO_PART_DELIMITER}${affiliateBps}${MEMO_PART_DELIMITER}${AGGREGATOR_TWO_LAST_CHARS}${MEMO_PART_DELIMITER}${FINAL_ASSET_ADDRESS}${MEMO_PART_DELIMITER}206301621786412`, - ) - }) - - it('should throw if chainNamespace is UTXO and chainId is not BCH', () => { - const minAmountOut = '2083854765519275828179229' - const affiliateBps = '100' - const expectedL1AmountOut = '42' // we don't care about this for the purpose of tests - const quotedMemo = `=:ETH.ETH:${RECEIVE_ADDRESS}:${expectedL1AmountOut}:ss:${affiliateBps}:${AGGREGATOR_TWO_LAST_CHARS}:${FINAL_ASSET_ADDRESS}:10` - - const slippageBps = 100 // 1% - - expect(() => - addAggregatorAndDestinationToMemo({ - sellChainId: dogeChainId, - quotedMemo, - aggregator: AGGREGATOR_ADDRESS, - finalAssetAddress: FINAL_ASSET_ADDRESS, - minAmountOut, - slippageBps, - finalAssetPrecision: 18, - chainNamespace: CHAIN_NAMESPACE.Utxo, - }), - ).toThrow() - }) - - it('should throw if chainId is BCH and bytes length is > 220', () => { - const minAmountOut = '2083854765519275828179229' - const affiliateBps = '100' - const expectedL1AmountOut = '42' // we don't care about this for the purpose of tests - const memoOver220Bytes = `=:ETH.ETH:${RECEIVE_ADDRESS}:${expectedL1AmountOut}:ss:${affiliateBps}:${AGGREGATOR_TWO_LAST_CHARS}:${FINAL_ASSET_ADDRESS}:${[ - ...Array(220), - ].map((_, counter) => counter)}` - - const slippageBps = 100 // 1% - - expect(() => - addAggregatorAndDestinationToMemo({ - sellChainId: dogeChainId, - quotedMemo: memoOver220Bytes, - aggregator: AGGREGATOR_ADDRESS, - finalAssetAddress: FINAL_ASSET_ADDRESS, - minAmountOut, - slippageBps, - finalAssetPrecision: 18, - chainNamespace: CHAIN_NAMESPACE.Utxo, - }), - ).toThrow() - }) - - it('should not throw if chainNamespace is UTXO and chainId is BCH', () => { - const minAmountOut = '2083854765519275828179229' - const affiliateBps = '100' - const expectedL1AmountOut = '42' // we don't care about this for the purpose of tests - const quotedMemo = `=:ETH.ETH:${RECEIVE_ADDRESS}:${expectedL1AmountOut}:ss:${affiliateBps}:${AGGREGATOR_TWO_LAST_CHARS}:${FINAL_ASSET_ADDRESS}:10` - - const slippageBps = 100 // 1% - - const modifiedMemo = addAggregatorAndDestinationToMemo({ - sellChainId: bchChainId, - quotedMemo, - aggregator: AGGREGATOR_ADDRESS, - finalAssetAddress: FINAL_ASSET_ADDRESS, - minAmountOut, - slippageBps, - finalAssetPrecision: 18, - chainNamespace: CHAIN_NAMESPACE.Utxo, - }) - - expect(modifiedMemo).toBe( - `=${MEMO_PART_DELIMITER}e${MEMO_PART_DELIMITER}${RECEIVE_ADDRESS}${MEMO_PART_DELIMITER}${expectedL1AmountOut}${MEMO_PART_DELIMITER}ss${MEMO_PART_DELIMITER}${affiliateBps}${MEMO_PART_DELIMITER}${AGGREGATOR_TWO_LAST_CHARS}${MEMO_PART_DELIMITER}${FINAL_ASSET_ADDRESS}${MEMO_PART_DELIMITER}206301621786412`, - ) - }) -}) diff --git a/src/lib/swapper/swappers/ThorchainSwapper/utils/addAggregatorAndDestinationToMemo.ts b/src/lib/swapper/swappers/ThorchainSwapper/utils/addAggregatorAndDestinationToMemo.ts deleted file mode 100644 index f801da9aadd..00000000000 --- a/src/lib/swapper/swappers/ThorchainSwapper/utils/addAggregatorAndDestinationToMemo.ts +++ /dev/null @@ -1,115 +0,0 @@ -import type { ChainId } from '@shapeshiftoss/caip' -import { bchChainId, CHAIN_NAMESPACE, type ChainNamespace } from '@shapeshiftoss/caip' -import { bn } from '@shapeshiftoss/chain-adapters' -import assert from 'assert' -import BigNumber from 'bignumber.js' -import type { Address } from 'viem' -import { fromBaseUnit } from 'lib/math' -import { subtractBasisPointAmount } from 'state/slices/tradeQuoteSlice/utils' - -import { BCH_MAXIMUM_BYTES_LENGTH, UTXO_MAXIMUM_BYTES_LENGTH } from '../constants' -import { MEMO_PART_DELIMITER } from './constants' -import { shortenedNativeAssetNameByNativeAssetName } from './longTailHelpers' - -export const addAggregatorAndDestinationToMemo = ({ - sellChainId, - quotedMemo, - aggregator, - finalAssetAddress, - minAmountOut, - slippageBps, - finalAssetPrecision, - chainNamespace, -}: { - sellChainId: ChainId - slippageBps: BigNumber.Value - quotedMemo: string | undefined - aggregator: Address - finalAssetAddress: Address - minAmountOut: string - finalAssetPrecision: number - chainNamespace: ChainNamespace -}) => { - if (!quotedMemo) throw new Error('no memo provided') - - const [ - prefix, - nativeAssetName, - address, - nativeAssetLimitWithManualSlippage, - affiliate, - affiliateBps, - ] = quotedMemo.split(MEMO_PART_DELIMITER) - - const finalAssetLimitWithManualSlippage = subtractBasisPointAmount( - bn(minAmountOut).toFixed(0, BigNumber.ROUND_DOWN), - slippageBps, - BigNumber.ROUND_DOWN, - ) - - const maximumPrecision = 6 - const endingExponential = finalAssetPrecision - maximumPrecision - const finalAssetLimitCryptoPrecision = fromBaseUnit( - finalAssetLimitWithManualSlippage, - finalAssetPrecision, - maximumPrecision, - ) - const shouldPrependZero = endingExponential < 10 - const thorAggregatorExponential = shouldPrependZero - ? `0${endingExponential > 0 ? endingExponential : '1'}` - : endingExponential - - // The THORChain aggregators expects this amount to be an exponent, we need to add two numbers at the end which are used at exponents in the contract - // We trim 10 of precisions to make sure the THORChain parser can handle the amount without precisions and rounding issues - // If the finalAssetPrecision is under 5, the THORChain parser won't fail and we add one exponent at the end so the aggregator contract won't multiply the amount - const finalAssetLimitWithTwoLastNumbersAsExponent = `${ - finalAssetPrecision < maximumPrecision - ? finalAssetLimitWithManualSlippage - : finalAssetLimitCryptoPrecision.replace('.', '') - }${thorAggregatorExponential}` - - // Paranoia assertion - expectedAmountOut should never be 0 as it would likely lead to a loss of funds. - assert( - BigInt(finalAssetLimitWithTwoLastNumbersAsExponent) > 0n, - 'expected finalAssetLimitWithManualSlippage to be a positive amount', - ) - - const aggregatorLastTwoChars = aggregator.slice(aggregator.length - 2, aggregator.length) - - const shortenedNativeAssetName = - shortenedNativeAssetNameByNativeAssetName[ - nativeAssetName as keyof typeof shortenedNativeAssetNameByNativeAssetName - ] - - assert(shortenedNativeAssetName, 'cannot find shortened native asset name') - - // Thorchain memo format: - // SWAP:ASSET:DESTADDR:LIM:AFFILIATE:FEE:DEX Aggregator Addr:Final Asset Addr:MinAmountOut - // see https://gitlab.com/thorchain/thornode/-/merge_requests/2218 for reference - const memo = [ - prefix, - shortenedNativeAssetName, - address, - nativeAssetLimitWithManualSlippage, - affiliate, - affiliateBps, - aggregatorLastTwoChars, - finalAssetAddress, - finalAssetLimitWithTwoLastNumbersAsExponent, - ].join(MEMO_PART_DELIMITER) - - const memoBytesLength = new Blob([memo]).size - - // Dogecoin, BTC and LTC only supports 80 bytes memo and we don't want to lose more precision for now - // We already check it in the L1ToLongtail handler but paranoia double security as this function is reusable. - if (chainNamespace === CHAIN_NAMESPACE.Utxo && sellChainId !== bchChainId) { - assert(memoBytesLength < UTXO_MAXIMUM_BYTES_LENGTH, 'memo is too long') - } - - // BCH supports 220 bytes of data - if (sellChainId === bchChainId) { - assert(memoBytesLength < BCH_MAXIMUM_BYTES_LENGTH, 'memo is too long') - } - - return memo -} diff --git a/src/lib/swapper/swappers/ThorchainSwapper/utils/addL1ToLongtailPartsToMemo.test.ts b/src/lib/swapper/swappers/ThorchainSwapper/utils/addL1ToLongtailPartsToMemo.test.ts new file mode 100644 index 00000000000..6dfac13cb2a --- /dev/null +++ b/src/lib/swapper/swappers/ThorchainSwapper/utils/addL1ToLongtailPartsToMemo.test.ts @@ -0,0 +1,144 @@ +import { btcChainId, dogeChainId, ethChainId, toAssetId } from '@shapeshiftoss/caip' +import { describe, expect, it } from 'vitest' + +import { getMaxBytesLengthByChainId } from '../constants' +import { addL1ToLongtailPartsToMemo } from './addL1ToLongtailPartsToMemo' + +const AGGREGATOR_ADDRESS = '0xd31f7e39afECEc4855fecc51b693F9A0Cec49fd2' +const BIG_ADDRESS = '0x32DBc9Cf9E8FbCebE1e0a2ecF05Ed86Ca3096Cb632DBc9Cf9E8FbCebE1e' +const REALLY_BIG_ADDRESS = + '0x32DBc9Cf9E8FbCebE1e0a2ecF05Ed86Ca3096Cb632DBc9Cf9E8FbCebE1e0a2ecF05Ed86Ca3096Cb632DBc9Cf9E8Fb1bE1e0a2ecF05Ed86Ca3096Cb632DBc9Cf9E8FbCebE1e0a2ecF05Ed86Ca3096Cb632DBc9Cf9E8FbCebE1e0a2ecF05Ed86Ca3096Cb6' + +const COLLIDING_FINAL_ASSETID = toAssetId({ + chainId: ethChainId, + assetReference: '0xa0b86991c6218b36c1d19d4a2e9eb0ce36dF4741', + assetNamespace: 'erc20', +}) + +const FINAL_ASSET_ASSETID = toAssetId({ + chainId: ethChainId, + assetReference: '0x8a65ac0E23F31979db06Ec62Af62b432a6dF4741', + assetNamespace: 'erc20', +}) + +const THORCHAIN_ASSETIDS_ONE_COLLISION = [FINAL_ASSET_ASSETID, COLLIDING_FINAL_ASSETID] + +const slippageBps = 100 // 1% + +describe('addL1ToLongtailPartsToMemo', () => { + it('should add aggregator address, shortened destination address and finalAssetAmountOut correctly', () => { + const finalAssetAmountOut = '9508759019' + const quotedMemo = `=:ETH.ETH:0x32DBc9Cf9E8FbCebE1e0a2ecF05Ed86Ca3096Cb6:42:ss:100` + + const modifiedMemo = addL1ToLongtailPartsToMemo({ + sellAssetChainId: ethChainId, + quotedMemo, + aggregator: AGGREGATOR_ADDRESS, + finalAssetAssetId: FINAL_ASSET_ASSETID, + finalAssetAmountOut, + slippageBps, + longtailTokens: THORCHAIN_ASSETIDS_ONE_COLLISION, + }) + + expect(modifiedMemo).toBe( + // The aggregator will turn the finalAssetAmountOut from 94136714201 to 9413671420 using the last 2 bytes as exponents + `=:e:0x32DBc9Cf9E8FbCebE1e0a2ecF05Ed86Ca3096Cb6:42:ss:100:d2:a6df4741:94136714201`, + ) + }) + + it('should throw if chainId is BCH and initial memo length is > 220', () => { + const finalAssetAmountOut = '2083854765519275828179229' + const memoOver220Bytes = `=:ETH.ETH:${REALLY_BIG_ADDRESS}:42:ss:100` + + expect(memoOver220Bytes.length).toBe(221) + + expect(() => + addL1ToLongtailPartsToMemo({ + sellAssetChainId: dogeChainId, + quotedMemo: memoOver220Bytes, + aggregator: AGGREGATOR_ADDRESS, + finalAssetAssetId: FINAL_ASSET_ASSETID, + finalAssetAmountOut, + slippageBps, + longtailTokens: THORCHAIN_ASSETIDS_ONE_COLLISION, + }), + ).toThrow('memo is too long') + }) + + it('should throw if chainId is BTC and initial memo length is > 80', () => { + const finalAssetAmountOut = '2083854765519275828179229' + const memoOver80Bytes = `=:ETH.ETH:${BIG_ADDRESS}:42:ss:100` + + expect(memoOver80Bytes.length).toBe(81) + + expect(() => + addL1ToLongtailPartsToMemo({ + sellAssetChainId: btcChainId, + quotedMemo: memoOver80Bytes, + aggregator: AGGREGATOR_ADDRESS, + finalAssetAssetId: FINAL_ASSET_ASSETID, + finalAssetAmountOut, + slippageBps, + longtailTokens: THORCHAIN_ASSETIDS_ONE_COLLISION, + }), + ).toThrow('memo is too long') + }) + + it('should throw if chainId is DOGE and initial memo length is > 80', () => { + const finalAssetAmountOut = '2083854765519275828179229' + const memoOver80Bytes = `=:ETH.ETH:${BIG_ADDRESS}:42:ss:100` + + expect(memoOver80Bytes.length).toBe(81) + expect(() => + addL1ToLongtailPartsToMemo({ + sellAssetChainId: dogeChainId, + quotedMemo: memoOver80Bytes, + aggregator: AGGREGATOR_ADDRESS, + finalAssetAssetId: FINAL_ASSET_ASSETID, + finalAssetAmountOut, + slippageBps, + longtailTokens: THORCHAIN_ASSETIDS_ONE_COLLISION, + }), + ).toThrow('memo is too long') + }) + + it('should throw if chainId is DOGE and initial memo length is under 80 but aggregator addition is making it over 80', () => { + const finalAssetAmountOut = '2083854765519275828179229' + const memoOver80Bytes = `=:ETH.ETH:${BIG_ADDRESS}:42:ss:1` + + expect(memoOver80Bytes.length).toBe(79) + expect(() => + addL1ToLongtailPartsToMemo({ + sellAssetChainId: dogeChainId, + quotedMemo: memoOver80Bytes, + aggregator: AGGREGATOR_ADDRESS, + finalAssetAssetId: FINAL_ASSET_ASSETID, + finalAssetAmountOut, + slippageBps, + longtailTokens: THORCHAIN_ASSETIDS_ONE_COLLISION, + }), + ).toThrow('memo is too long') + }) + + it('should be successful if chainId is DOGE and memo length is < 80', () => { + const finalAssetAmountOut = '2083854765519275828179229' + const quotedMemo = `=:ETH.ETH:0x32DBc9Cf9E8FbCebE1e0a2ecF05Ed86Ca3096Cb6:42:ss:100` + + const modifiedMemo = addL1ToLongtailPartsToMemo({ + sellAssetChainId: dogeChainId, + quotedMemo, + aggregator: AGGREGATOR_ADDRESS, + finalAssetAssetId: FINAL_ASSET_ASSETID, + finalAssetAmountOut, + slippageBps, + longtailTokens: THORCHAIN_ASSETIDS_ONE_COLLISION, + }) + + expect(modifiedMemo.length).toBeLessThanOrEqual(getMaxBytesLengthByChainId(dogeChainId)) + + expect(modifiedMemo).toBe( + // The aggregator will turn the finalAssetAmountOut from 20630162116 to 2063016210000000000000000 using the last 2 bytes as exponents + `=:e:0x32DBc9Cf9E8FbCebE1e0a2ecF05Ed86Ca3096Cb6:42:ss:100:d2:a6df4741:20630162116`, + ) + }) +}) diff --git a/src/lib/swapper/swappers/ThorchainSwapper/utils/addL1ToLongtailPartsToMemo.ts b/src/lib/swapper/swappers/ThorchainSwapper/utils/addL1ToLongtailPartsToMemo.ts new file mode 100644 index 00000000000..cbb4a19db30 --- /dev/null +++ b/src/lib/swapper/swappers/ThorchainSwapper/utils/addL1ToLongtailPartsToMemo.ts @@ -0,0 +1,92 @@ +import { type AssetId, type ChainId } from '@shapeshiftoss/caip' +import { bn } from '@shapeshiftoss/chain-adapters' +import assert from 'assert' +import BigNumber from 'bignumber.js' +import type { Address } from 'viem' +import { assertAndProcessMemo } from 'lib/utils/thorchain/memo' +import { subtractBasisPointAmount } from 'state/slices/tradeQuoteSlice/utils' + +import { getMaxBytesLengthByChainId } from '../constants' +import { MEMO_PART_DELIMITER } from './constants' +import { getUniqueAddressSubstring } from './getUniqueAddressSubstring' +import { shortenedNativeAssetNameByNativeAssetName } from './longTailHelpers' +import { makeMemoWithShortenedFinalAssetAmount } from './makeMemoWithShortenedFinalAssetAmount' + +export const addL1ToLongtailPartsToMemo = ({ + sellAssetChainId, + quotedMemo, + aggregator, + finalAssetAssetId, + finalAssetAmountOut, + slippageBps, + longtailTokens, +}: { + sellAssetChainId: ChainId + slippageBps: BigNumber.Value + quotedMemo: string + aggregator: Address + finalAssetAssetId: AssetId + finalAssetAmountOut: string + longtailTokens: AssetId[] +}) => { + if (!quotedMemo) throw new Error('no memo provided') + + const [ + prefix, + nativeAssetName, + address, + nativeAssetLimitWithManualSlippage, + affiliate, + affiliateBps, + ] = quotedMemo.split(MEMO_PART_DELIMITER) + + const maxMemoSize = getMaxBytesLengthByChainId(sellAssetChainId) + + // Paranonia - If memo without final asset amount out and aggregator is already too long, we can't do anything + assert(quotedMemo.length <= maxMemoSize, 'memo is too long') + + const finalAssetLimitWithManualSlippage = subtractBasisPointAmount( + bn(finalAssetAmountOut).toFixed(0, BigNumber.ROUND_DOWN), + slippageBps, + BigNumber.ROUND_DOWN, + ) + + const finalAssetContractAddressShortened = getUniqueAddressSubstring( + finalAssetAssetId, + longtailTokens, + ) + + // THORChain themselves use 2 characters but it might collide at some point in the future (https://gitlab.com/thorchain/thornode/-/blob/develop/x/thorchain/aggregators/dex_mainnet_current.go) + const aggregatorLastTwoChars = aggregator.slice(aggregator.length - 2, aggregator.length) + + const shortenedNativeAssetName = + shortenedNativeAssetNameByNativeAssetName[ + nativeAssetName as keyof typeof shortenedNativeAssetNameByNativeAssetName + ] + + assert(shortenedNativeAssetName, 'cannot find shortened native asset name') + + const memoWithoutFinalAssetAmountOut = [ + prefix, + shortenedNativeAssetName, + address, + nativeAssetLimitWithManualSlippage, + affiliate, + affiliateBps, + aggregatorLastTwoChars, + finalAssetContractAddressShortened, + ].join(MEMO_PART_DELIMITER) + + // Paranonia - If memo without final asset amount out is already too long, we can't do anything + assert(memoWithoutFinalAssetAmountOut.length <= maxMemoSize, 'memo is too long') + + const memoWithShortenedFinalAssetAmountOut = makeMemoWithShortenedFinalAssetAmount({ + maxMemoSize, + memoWithoutFinalAssetAmountOut, + finalAssetLimitWithManualSlippage, + }) + + assert(memoWithShortenedFinalAssetAmountOut.length <= maxMemoSize, 'memo is too long') + + return assertAndProcessMemo(memoWithShortenedFinalAssetAmountOut) +} diff --git a/src/lib/swapper/swappers/ThorchainSwapper/utils/getL1ToLongtailQuote.ts b/src/lib/swapper/swappers/ThorchainSwapper/utils/getL1ToLongtailQuote.ts index c6378834c6e..13083fd7b55 100644 --- a/src/lib/swapper/swappers/ThorchainSwapper/utils/getL1ToLongtailQuote.ts +++ b/src/lib/swapper/swappers/ThorchainSwapper/utils/getL1ToLongtailQuote.ts @@ -1,4 +1,5 @@ -import { bchChainId, CHAIN_NAMESPACE, ethChainId, fromAssetId } from '@shapeshiftoss/caip' +import type { AssetId } from '@shapeshiftoss/caip' +import { ethChainId } from '@shapeshiftoss/caip' import type { GetTradeQuoteInput, MultiHopTradeQuoteSteps } from '@shapeshiftoss/swapper' import { makeSwapErrorRight, @@ -10,7 +11,6 @@ import type { AssetsByIdPartial } from '@shapeshiftoss/types' import type { Result } from '@sniptt/monads' import { Err, Ok } from '@sniptt/monads' import { getDefaultSlippageDecimalPercentageForSwapper } from 'constants/constants' -import type { Address } from 'viem' import { getChainAdapterManager } from 'context/PluginProvider/chainAdapterSingleton' import { isFulfilled, isRejected, isResolvedErr } from 'lib/utils' import { getHopByIndex } from 'state/slices/tradeQuoteSlice/helpers' @@ -18,7 +18,7 @@ import { convertDecimalPercentageToBasisPoints } from 'state/slices/tradeQuoteSl import { ALLOWANCE_CONTRACT } from '../constants' import type { ThorTradeQuote } from '../getThorTradeQuote/getTradeQuote' -import { addAggregatorAndDestinationToMemo } from './addAggregatorAndDestinationToMemo' +import { addL1ToLongtailPartsToMemo } from './addL1ToLongtailPartsToMemo' import { getBestAggregator } from './getBestAggregator' import { getL1quote } from './getL1quote' import type { AggregatorContract } from './longTailHelpers' @@ -37,7 +37,18 @@ export const getL1ToLongtailQuote = async ( slippageTolerancePercentageDecimal, } = input - const { chainNamespace } = fromAssetId(sellAsset.assetId) + const longtailTokensJson = await import('../generated/generatedThorLongtailTokens.json') + const longtailTokens: AssetId[] = longtailTokensJson.default + + if (!longtailTokens.includes(buyAsset.assetId)) { + return Err( + makeSwapErrorRight({ + message: `[getThorTradeQuote] - Unsupported buyAssetId ${buyAsset.assetId}.`, + code: TradeQuoteError.UnsupportedTradePair, + details: { buyAsset, sellAsset }, + }), + ) + } /* We only support L1 -> ethereum longtail swaps for now. @@ -53,30 +64,21 @@ export const getL1ToLongtailQuote = async ( } const chainAdapterManager = getChainAdapterManager() - const sellChainId = sellAsset.chainId - const buyChainId = buyAsset.chainId - - if (sellChainId !== bchChainId && chainNamespace === CHAIN_NAMESPACE.Utxo) { - return Err( - makeSwapErrorRight({ - message: `[getThorTradeQuote] - DOGE, BTC and LTC to ERC20 is not supported.`, - code: TradeQuoteError.InternalError, - }), - ) - } + const sellAssetChainId = sellAsset.chainId + const buyAssetChainId = buyAsset.chainId - const sellAssetFeeAssetId = chainAdapterManager.get(sellChainId)?.getFeeAssetId() + const sellAssetFeeAssetId = chainAdapterManager.get(sellAssetChainId)?.getFeeAssetId() const sellAssetFeeAsset = sellAssetFeeAssetId ? assetsById[sellAssetFeeAssetId] : undefined - const buyAssetFeeAssetId = chainAdapterManager.get(buyChainId)?.getFeeAssetId() + const buyAssetFeeAssetId = chainAdapterManager.get(buyAssetChainId)?.getFeeAssetId() const buyAssetFeeAsset = buyAssetFeeAssetId ? assetsById[buyAssetFeeAssetId] : undefined if (!buyAssetFeeAsset) { return Err( makeSwapErrorRight({ - message: `[getThorTradeQuote] - No native buy asset found for ${buyChainId}.`, + message: `[getThorTradeQuote] - No native buy asset found for ${buyAssetChainId}.`, code: TradeQuoteError.InternalError, - details: { buyAssetChainId: buyChainId }, + details: { buyAssetChainId }, }), ) } @@ -84,9 +86,9 @@ export const getL1ToLongtailQuote = async ( if (!sellAssetFeeAsset) { return Err( makeSwapErrorRight({ - message: `[getThorTradeQuote] - No native buy asset found for ${sellChainId}.`, + message: `[getThorTradeQuote] - No native buy asset found for ${sellAssetChainId}.`, code: TradeQuoteError.InternalError, - details: { sellAssetChainId: sellChainId }, + details: { sellAssetChainId }, }), ) } @@ -130,18 +132,17 @@ export const getL1ToLongtailQuote = async ( bestAggregator = unwrappedResult.bestAggregator quotedAmountOut = unwrappedResult.quotedAmountOut - const updatedMemo = addAggregatorAndDestinationToMemo({ - sellChainId, + const updatedMemo = addL1ToLongtailPartsToMemo({ + sellAssetChainId, aggregator: bestAggregator, - finalAssetAddress: fromAssetId(buyAsset.assetId).assetReference as Address, - minAmountOut: quotedAmountOut.toString(), + finalAssetAssetId: buyAsset.assetId, + finalAssetAmountOut: quotedAmountOut.toString(), slippageBps: convertDecimalPercentageToBasisPoints( slippageTolerancePercentageDecimal ?? getDefaultSlippageDecimalPercentageForSwapper(SwapperName.Thorchain), ).toString(), quotedMemo: quote.memo, - finalAssetPrecision: buyAsset.precision, - chainNamespace, + longtailTokens, }) return Ok({ diff --git a/src/lib/swapper/swappers/ThorchainSwapper/utils/getUniqueAddressSubstring.test.ts b/src/lib/swapper/swappers/ThorchainSwapper/utils/getUniqueAddressSubstring.test.ts new file mode 100644 index 00000000000..1d592112b27 --- /dev/null +++ b/src/lib/swapper/swappers/ThorchainSwapper/utils/getUniqueAddressSubstring.test.ts @@ -0,0 +1,53 @@ +import { ethChainId, toAssetId } from '@shapeshiftoss/caip' +import { describe, expect, it } from 'vitest' + +import { getUniqueAddressSubstring } from './getUniqueAddressSubstring' + +const FINAL_ASSET_ASSETID = toAssetId({ + chainId: ethChainId, + assetReference: '0x8a65ac0E23F31979db06Ec62Af62b432a6dF4741', + assetNamespace: 'erc20', +}) + +const COLLIDING_FINAL_ASSETID = toAssetId({ + chainId: ethChainId, + assetReference: '0xa0b86991c6218b36c1d19d4a2e9eb0ce36dF4741', + assetNamespace: 'erc20', +}) + +const BIGGER_COLLIDING_FINAL_ASSETID = toAssetId({ + chainId: ethChainId, + assetReference: '0xa0b86991c6218b36c1d19d4a2e9eb032a6dF4741', + assetNamespace: 'erc20', +}) + +const SHORTENED_FINAL_ASSET_ADDRESS = 'a6df4741' + +const BIGGER_SHORTENED_FINAL_ASSET_ADDRESS = '432a6df4741' + +const THORCHAIN_ASSETIDS_ONE_COLLISION = [FINAL_ASSET_ASSETID, COLLIDING_FINAL_ASSETID] +const THORCHAIN_ASSETIDS_TWO_COLLISION = [ + FINAL_ASSET_ASSETID, + COLLIDING_FINAL_ASSETID, + BIGGER_COLLIDING_FINAL_ASSETID, +] + +describe('getUniqueAddressSubstring', () => { + it('should get the shorter unique address substring', () => { + const substring = getUniqueAddressSubstring( + FINAL_ASSET_ASSETID, + THORCHAIN_ASSETIDS_ONE_COLLISION, + ) + + expect(substring).toBe(SHORTENED_FINAL_ASSET_ADDRESS) + }) + + it('should get the shorter unique address substring with a bigger colliding address', () => { + const substring = getUniqueAddressSubstring( + FINAL_ASSET_ASSETID, + THORCHAIN_ASSETIDS_TWO_COLLISION, + ) + + expect(substring).toBe(BIGGER_SHORTENED_FINAL_ASSET_ADDRESS) + }) +}) diff --git a/src/lib/swapper/swappers/ThorchainSwapper/utils/getUniqueAddressSubstring.ts b/src/lib/swapper/swappers/ThorchainSwapper/utils/getUniqueAddressSubstring.ts new file mode 100644 index 00000000000..9d2444eb9fc --- /dev/null +++ b/src/lib/swapper/swappers/ThorchainSwapper/utils/getUniqueAddressSubstring.ts @@ -0,0 +1,33 @@ +import { type AssetId } from '@shapeshiftoss/caip' + +export const getUniqueAddressSubstring = ( + destinationAssetId: AssetId, + longTailAssetIds: AssetId[], +) => { + const MINIMUM_UNIQUE_SUBSTRING = 2 + let maybeShortenedDestinationAddress = destinationAssetId + + const substringsCount: Record = {} + + for (let length = MINIMUM_UNIQUE_SUBSTRING; length <= destinationAssetId.length - 2; length++) { + const currentSubstring = destinationAssetId.slice(-length) + substringsCount[currentSubstring] = 0 + } + + longTailAssetIds.forEach(assetId => { + Object.keys(substringsCount).forEach(substring => { + if (assetId.includes(substring)) { + substringsCount[substring] += 1 + } + }) + }) + + for (const [substring, count] of Object.entries(substringsCount)) { + if (count === 1) { + maybeShortenedDestinationAddress = substring + break + } + } + + return maybeShortenedDestinationAddress +} diff --git a/src/lib/swapper/swappers/ThorchainSwapper/utils/makeMemoWithShortenedFinalAssetAmount.test.ts b/src/lib/swapper/swappers/ThorchainSwapper/utils/makeMemoWithShortenedFinalAssetAmount.test.ts new file mode 100644 index 00000000000..eec8ee6d139 --- /dev/null +++ b/src/lib/swapper/swappers/ThorchainSwapper/utils/makeMemoWithShortenedFinalAssetAmount.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, it } from 'vitest' + +import { makeMemoWithShortenedFinalAssetAmount } from './makeMemoWithShortenedFinalAssetAmount' + +const RECEIVE_ADDRESS = '0x32DBc9Cf9E8FbCebE1e0a2ecF05Ed86Ca3096Cb6' +// 178 bytes +const BIG_ADDRESS = + '0x32DBc9Cf9E8FbCebE1e0a2ecF05Ed86Ca3096Cb632DBc9Cf9E8FbC2ecF05Ed86Ca3096cF05Ed86Ca3096Cb60x32DBc9Cf9E8FbCebE1e0a2a3096C2ecF05Ed86Ca3096Cb632DBc9Cf9E8FbCebE1e0a2ecF05Ed86Ca3096Cb6' +// 354 bytes +const REALLY_BIG_ADDRESS = + '0x32DBc9Cf9E8FbCebE1e0a2ecF05Ed86Ca3096Cb632DBc9Cf9E8FbC2ecF05Ed86Ca3096cF05Ed86Ca3096Cb60x32DBc9Cf9E8FbCebE1e0a2a3096C2ecF05Ed86Ca3096Cb632DBc9Cf9E8FbCebE1e0a2ecF05Ed86Ca3096Cb632DBc9Cf9E8FbCebE1e0a2ecF05Ed86Ca3096Cb632DBc9Cf9E8FbC2ecF05Ed86Ca3096cF05Ed86Ca3096Cb60x32DBc9Cf9E8FbCebE1e0a2a3096C2ecF05Ed86Ca3096Cb632DBc9Cf9E8FbCebE1e0a2ecF05Ed86Ca3096Cb6' +const EXPECTED_AGGREGATOR_TWO_LAST_CHARS = 'd2' + +const SHORTENED_FINAL_ASSET_ADDRESS = 'a6df4741' + +const affiliateBps = '100' +const expectedL1AmountOut = '42' + +describe('makeMemoWithShortenedFinalAssetAmount', () => { + it('should be 80 bytes if maxMemoSize is 80', () => { + const quotedMemo = `=:e:${RECEIVE_ADDRESS}:${expectedL1AmountOut}:ss:${affiliateBps}:${EXPECTED_AGGREGATOR_TWO_LAST_CHARS}:${SHORTENED_FINAL_ASSET_ADDRESS}` + + const modifiedMemo = makeMemoWithShortenedFinalAssetAmount({ + maxMemoSize: 80, + memoWithoutFinalAssetAmountOut: quotedMemo, + finalAssetLimitWithManualSlippage: '950875902132134123', + }) + + expect(modifiedMemo.length).toBe(80) + expect(modifiedMemo).toBe( + // 95087590209 will be turned to 950875902000000000 using the last 2 bytes as exponents by the aggregator + `=:e:${RECEIVE_ADDRESS}:${expectedL1AmountOut}:ss:${affiliateBps}:${EXPECTED_AGGREGATOR_TWO_LAST_CHARS}:${SHORTENED_FINAL_ASSET_ADDRESS}:95087590209`, + ) + }) + + it('shortened final asset amount length should be shortened by one if maxMemoSize is Infinity and final asset amount length is under or equal to 17', () => { + // Equals to 987,234,879,539,239,282.982983248234982348 ETH + const reallyL1BigAmount = '987234879539239282982983248234982348' + const quotedMemo = `=:e:${RECEIVE_ADDRESS}:${reallyL1BigAmount}:ss:${affiliateBps}:${EXPECTED_AGGREGATOR_TWO_LAST_CHARS}:${SHORTENED_FINAL_ASSET_ADDRESS}` + + const modifiedMemo = makeMemoWithShortenedFinalAssetAmount({ + maxMemoSize: Infinity, + memoWithoutFinalAssetAmountOut: quotedMemo, + finalAssetLimitWithManualSlippage: '95087590213213412', + }) + + // 950875902132134101 will be turned to 95087590213213410 using the last 2 bytes as exponents by the aggregator + const expectedMemo = `=:e:${RECEIVE_ADDRESS}:${reallyL1BigAmount}:ss:${affiliateBps}:${EXPECTED_AGGREGATOR_TWO_LAST_CHARS}:${SHORTENED_FINAL_ASSET_ADDRESS}:950875902132134101` + + expect(modifiedMemo).toBe(expectedMemo) + }) + + it('shortened final asset amount should be shortened by 1 if maxMemoSize is Infinity and final asset amount length equal to 18', () => { + // Equals to 987,234,879,539,239,282.982983248234982348 ETH + const reallyL1BigAmount = '987234879539239282982983248234982348' + const quotedMemo = `=:e:${RECEIVE_ADDRESS}:${reallyL1BigAmount}:ss:${affiliateBps}:${EXPECTED_AGGREGATOR_TWO_LAST_CHARS}:${SHORTENED_FINAL_ASSET_ADDRESS}` + + const modifiedMemo = makeMemoWithShortenedFinalAssetAmount({ + maxMemoSize: Infinity, + memoWithoutFinalAssetAmountOut: quotedMemo, + finalAssetLimitWithManualSlippage: '950875902132134122', + }) + + // 9508759021321341201 will be turned to 950875902132134120 using the last 2 bytes as exponents by the aggregator + const expectedMemo = `=:e:${RECEIVE_ADDRESS}:${reallyL1BigAmount}:ss:${affiliateBps}:${EXPECTED_AGGREGATOR_TWO_LAST_CHARS}:${SHORTENED_FINAL_ASSET_ADDRESS}:9508759021321341201` + + expect(modifiedMemo).toBe(expectedMemo) + }) + + it('shortened final asset amount should be shortened by 2 if maxMemoSize is Infinity and final asset amount length equal to 19', () => { + // Equals to 987,234,879,539,239,282.982983248234982348 ETH + const reallyL1BigAmount = '987234879539239282982983248234982348' + const quotedMemo = `=:e:${RECEIVE_ADDRESS}:${reallyL1BigAmount}:ss:${affiliateBps}:${EXPECTED_AGGREGATOR_TWO_LAST_CHARS}:${SHORTENED_FINAL_ASSET_ADDRESS}` + + const modifiedMemo = makeMemoWithShortenedFinalAssetAmount({ + maxMemoSize: Infinity, + memoWithoutFinalAssetAmountOut: quotedMemo, + finalAssetLimitWithManualSlippage: '9508759021321341223', + }) + + // 9508759021321341202 will be turned to 9508759021321341200 using the last 2 bytes as exponents by the aggregator + const expectedMemo = `=:e:${RECEIVE_ADDRESS}:${reallyL1BigAmount}:ss:${affiliateBps}:${EXPECTED_AGGREGATOR_TWO_LAST_CHARS}:${SHORTENED_FINAL_ASSET_ADDRESS}:9508759021321341202` + + expect(modifiedMemo).toBe(expectedMemo) + }) + + it('should be under or equal to 220 bytes if maxMemoSize is 220 and memo length is close to 220', () => { + // 205 bytes + const quotedMemo = `=:e:${BIG_ADDRESS}:${expectedL1AmountOut}:ss:${affiliateBps}:${EXPECTED_AGGREGATOR_TWO_LAST_CHARS}:${SHORTENED_FINAL_ASSET_ADDRESS}` + + const modifiedMemo = makeMemoWithShortenedFinalAssetAmount({ + maxMemoSize: 220, + memoWithoutFinalAssetAmountOut: quotedMemo, + finalAssetLimitWithManualSlippage: '950875902132134123', + }) + + expect(modifiedMemo.length).toBeLessThanOrEqual(220) + expect(modifiedMemo).toBe( + // 950875902132105 will be turned to 950875902132100000 using the last 2 bytes as exponents by the aggregator + `=:e:${BIG_ADDRESS}:${expectedL1AmountOut}:ss:${affiliateBps}:${EXPECTED_AGGREGATOR_TWO_LAST_CHARS}:${SHORTENED_FINAL_ASSET_ADDRESS}:950875902132105`, + ) + }) + + it('should be throwing if length cant be achieved because exponent will be bigger than expected', () => { + const quotedMemo = `=:e:${REALLY_BIG_ADDRESS}:${expectedL1AmountOut}:ss:${affiliateBps}:${EXPECTED_AGGREGATOR_TWO_LAST_CHARS}:${SHORTENED_FINAL_ASSET_ADDRESS}` + + expect(() => + makeMemoWithShortenedFinalAssetAmount({ + maxMemoSize: 220, + memoWithoutFinalAssetAmountOut: quotedMemo, + finalAssetLimitWithManualSlippage: '950875902132134123', + }), + ).toThrow('min amount chars length should be 3 or more') + }) +}) diff --git a/src/lib/swapper/swappers/ThorchainSwapper/utils/makeMemoWithShortenedFinalAssetAmount.ts b/src/lib/swapper/swappers/ThorchainSwapper/utils/makeMemoWithShortenedFinalAssetAmount.ts new file mode 100644 index 00000000000..82752ad958d --- /dev/null +++ b/src/lib/swapper/swappers/ThorchainSwapper/utils/makeMemoWithShortenedFinalAssetAmount.ts @@ -0,0 +1,98 @@ +import assert from 'assert' +import { assertAndProcessMemo } from 'lib/utils/thorchain/memo' + +import { MEMO_PART_DELIMITER } from './constants' + +// The THORChain aggregators expects two numbers at the end which are used at exponents in the contract +// see _parseAmountOutMin in https://dashboard.tenderly.co/tx/mainnet/0xe16c61c114c5815555ec4342af390a1695d1cce431a8ff479bcc7d63f3d0b46a?trace=0.2.10 +// 7759345496513 => 775934549650000000000000 +export const AGGREGATOR_EXPONENT_LENGTH = 2 + +// THORChain uses `big.ParseFloat` with `0` as a precision, resulting in a precision of 18 without the integer part +// https://gitlab.com/thorchain/thornode/-/blob/v1.131.0/x/thorchain/memo/memo_parser.go#L190 +export const THORCHAIN_PARSER_MAXIMUM_PRECISION = 18 + +export const makeMemoWithShortenedFinalAssetAmount = ({ + maxMemoSize, + memoWithoutFinalAssetAmountOut, + finalAssetLimitWithManualSlippage, +}: { + maxMemoSize: number + memoWithoutFinalAssetAmountOut: string + finalAssetLimitWithManualSlippage: string +}) => { + // The min amount out should be at least 3 characters long (1 for the number and 2 for the exponent) + const MINIMUM_AMOUNT_OUT_LENGTH = 3 + const HYPOTHETICAL_EXPONENT = '01' + + const memoArrayWithoutMinAmountOut = memoWithoutFinalAssetAmountOut.split(MEMO_PART_DELIMITER) + + // We need to construct the memo with the minAmountOut at the end so we can calculate his bytes size + const unshortenedMemo = `${memoWithoutFinalAssetAmountOut}:${finalAssetLimitWithManualSlippage}${HYPOTHETICAL_EXPONENT}` + + const memoSizeLeft = maxMemoSize - unshortenedMemo.length + + let excessBytesToTrim = 0 + + if (memoSizeLeft < 0) { + excessBytesToTrim = Math.abs(memoSizeLeft) + } + + const shortenedMinAmountOutLength = + finalAssetLimitWithManualSlippage.length + AGGREGATOR_EXPONENT_LENGTH - excessBytesToTrim + + // Paranoia check - can't shorten more than the initial min amount length + assert( + shortenedMinAmountOutLength >= MINIMUM_AMOUNT_OUT_LENGTH, + 'min amount chars length should be 3 or more', + ) + + // THORChain parser uses big.ParseFloat with `0` as a precision, resulting in a precision of 18 + the integer part, so we can't have more than 19 characters + // https://gitlab.com/thorchain/thornode/-/blob/v1.131.0/x/thorchain/memo/memo_parser.go#L190 + if (shortenedMinAmountOutLength > THORCHAIN_PARSER_MAXIMUM_PRECISION + 1) { + excessBytesToTrim += shortenedMinAmountOutLength - (THORCHAIN_PARSER_MAXIMUM_PRECISION + 1) + } else if ( + shortenedMinAmountOutLength <= THORCHAIN_PARSER_MAXIMUM_PRECISION + 1 && + // Only add one byte if we don't trim for another reason before + !excessBytesToTrim + ) { + // If the length is less than the maximum precision, we need to remove one byte to the final asset amount because we absolutely need to add an exponent + // or the aggregator will multiply the number by 10 and then it will revert due to an higher expected amount than we can receive + excessBytesToTrim += 1 + } + + const shortenedAmountOut = finalAssetLimitWithManualSlippage.substring( + 0, + finalAssetLimitWithManualSlippage.length - excessBytesToTrim, + ) + + // Paranoia check - we should never have a 0 amount out + assert(shortenedAmountOut !== '0', 'expected final amount limit to be different than 0') + + // The THORChain aggregators expects two numbers at the end which are used at exponents in the contract + const shouldPrependZero = excessBytesToTrim < 10 + + const thorAggregatorExponential = shouldPrependZero + ? `0${excessBytesToTrim > 0 ? excessBytesToTrim : '1'}` + : excessBytesToTrim.toString() + + assert(thorAggregatorExponential.length === 2, 'expected exponent to be 2 digits') + + // The THORChain aggregators expects this amount to be an exponent, we need to add two numbers at the end which are used at exponents in the contract + const shortenedAmountOutWithTwoLastNumbersAsExponent = `${shortenedAmountOut}${thorAggregatorExponential}` + + // Thorchain memo format: + // SWAP:ASSET:DESTADDR:LIM:AFFILIATE:FEE:DEX Aggregator Addr:Final Asset Addr:MinAmountOut + // see https://gitlab.com/thorchain/thornode/-/merge_requests/2218 for reference + const potentialMemo = [ + ...memoArrayWithoutMinAmountOut, + shortenedAmountOutWithTwoLastNumbersAsExponent, + ].join(MEMO_PART_DELIMITER) + + assert( + shortenedAmountOutWithTwoLastNumbersAsExponent.length <= THORCHAIN_PARSER_MAXIMUM_PRECISION + 1, + 'expected shortenedAmountOut length to be less than thorchain maximum precision', + ) + + return assertAndProcessMemo(potentialMemo) +} diff --git a/src/lib/utils/thorchain/memo/assertAndProcessMemo.test.ts b/src/lib/utils/thorchain/memo/assertAndProcessMemo.test.ts index 3b0fba2adfe..4464d7df632 100644 --- a/src/lib/utils/thorchain/memo/assertAndProcessMemo.test.ts +++ b/src/lib/utils/thorchain/memo/assertAndProcessMemo.test.ts @@ -40,6 +40,31 @@ describe('assertAndProcessMemo', () => { expect(assertAndProcessMemo(memo)).toBe(expected) }) + it('processes with affiliate name and no fee bps and swapOut parameters', () => { + let memo = '=:ETH.ETH:0x32DBc9Cf9E8FbCebE1e0a2ecF05Ed86Ca3096Cb6:9786345:ss::ae:kd:12345602' + let expected = + '=:ETH.ETH:0x32DBc9Cf9E8FbCebE1e0a2ecF05Ed86Ca3096Cb6:9786345:ss:0:ae:kd:12345602' + expect(assertAndProcessMemo(memo)).toBe(expected) + + memo = '=:ETH.ETH:0x32DBc9Cf9E8FbCebE1e0a2ecF05Ed86Ca3096Cb6:9786345:ss:' + expected = '=:ETH.ETH:0x32DBc9Cf9E8FbCebE1e0a2ecF05Ed86Ca3096Cb6:9786345:ss:0' + expect(assertAndProcessMemo(memo)).toBe(expected) + }) + + it('processes with swapOut parameters and no affiliate name', () => { + const memo = '=:ETH.ETH:0x32DBc9Cf9E8FbCebE1e0a2ecF05Ed86Ca3096Cb6:9786345::50:ae:kd:12345602' + const expected = + '=:ETH.ETH:0x32DBc9Cf9E8FbCebE1e0a2ecF05Ed86Ca3096Cb6:9786345:ss:50:ae:kd:12345602' + expect(assertAndProcessMemo(memo)).toBe(expected) + }) + + it('processes with no affiliate name and no fee bps and swapOut parameters', () => { + let memo = '=:ETH.ETH:0x32DBc9Cf9E8FbCebE1e0a2ecF05Ed86Ca3096Cb6:9786345:::ae:kd:12345602' + let expected = + '=:ETH.ETH:0x32DBc9Cf9E8FbCebE1e0a2ecF05Ed86Ca3096Cb6:9786345:ss:0:ae:kd:12345602' + expect(assertAndProcessMemo(memo)).toBe(expected) + }) + it('processes with no affiliate name and no fee bps', () => { let memo = '=:ETH.ETH:0x32DBc9Cf9E8FbCebE1e0a2ecF05Ed86Ca3096Cb6:9786345' let expected = '=:ETH.ETH:0x32DBc9Cf9E8FbCebE1e0a2ecF05Ed86Ca3096Cb6:9786345:ss:0' diff --git a/src/lib/utils/thorchain/memo/assertAndProcessMemo.ts b/src/lib/utils/thorchain/memo/assertAndProcessMemo.ts index 1e69f2ec1e1..1d8cf13b227 100644 --- a/src/lib/utils/thorchain/memo/assertAndProcessMemo.ts +++ b/src/lib/utils/thorchain/memo/assertAndProcessMemo.ts @@ -18,6 +18,21 @@ function assertMemoHasDestAddr( if (!destAddr) throw new Error(`destination address is required in memo: ${memo}`) } +function assertMemoHasAggregatorAddress( + aggregatorAddr: string | undefined, + memo: string, +): asserts aggregatorAddr is string { + if (!aggregatorAddr) throw new Error(`aggregator address is required in memo: ${memo}`) +} + +function assertMemoHasFinalAssetContractAddress( + finalAssetContractAddress: string | undefined, + memo: string, +): asserts finalAssetContractAddress is string { + if (!finalAssetContractAddress) + throw new Error(`final asset contract address is required in memo: ${memo}`) +} + function assertMemoHasPairedAddr( pairedAddr: string | undefined, memo: string, @@ -29,6 +44,13 @@ function assertMemoHasLimit(limit: string | undefined, memo: string): asserts li if (!limit) throw new Error(`limit is required in memo: ${memo}`) } +function assertMemoHasFinalAssetLimit( + finalAssetLimit: string | undefined, + memo: string, +): asserts finalAssetLimit is string { + if (!finalAssetLimit) throw new Error(`final asset limit is required in memo: ${memo}`) +} + function assertMemoHasBasisPoints( basisPoints: string | undefined, memo: string, @@ -57,6 +79,17 @@ const assertIsValidLimit = (limit: string | undefined, memo: string) => { if (!bn(limit).gt(0)) throw new Error(`positive limit is required in memo: ${memo}`) } +const assertIsValidFinalAssetLimit = (finalAssetLimit: string | undefined, memo: string) => { + assertMemoHasFinalAssetLimit(finalAssetLimit, memo) + + if (!bn(finalAssetLimit).gt(0)) + throw new Error(`positive final asset limit is required in memo: ${memo}`) + if (finalAssetLimit.length < 3) + throw new Error(`positive final asset limit length should be at least 3 in memo: ${memo}`) + if (finalAssetLimit.length > 19) + throw new Error(`positive final asset limit length should be maximum 19 in memo: ${memo}`) +} + function assertIsValidBasisPoints( basisPoints: string | undefined, memo: string, @@ -82,13 +115,38 @@ export const assertAndProcessMemo = (memo: string): string => { case '=': case 's': { // SWAP:ASSET:DESTADDR:LIM/INTERVAL/QUANTITY:AFFILIATE:FEE - const [_action, asset, destAddr, limit, , fee] = memo.split(':') + const [ + _action, + asset, + destAddr, + limit, + , + fee, + aggregatorAddress, + finalAssetAddress, + finalAssetAmountOut, + ] = memo.split(':') assertMemoHasAsset(asset, memo) assertMemoHasDestAddr(destAddr, memo) assertIsValidLimit(limit, memo) - return `${_action}:${asset}:${destAddr}:${limit}:${THORCHAIN_AFFILIATE_NAME}:${fee || 0}` + // SWAP:ASSET:DESTADDR:LIM:AFFILIATE:FEE:DEXAggregatorAddr:FinalTokenAddr:MinAmountOut| + const maybeSwapOutParts = (() => { + if (aggregatorAddress || finalAssetAddress || finalAssetAmountOut) { + assertMemoHasAggregatorAddress(aggregatorAddress, memo) + assertMemoHasFinalAssetContractAddress(finalAssetAddress, memo) + assertIsValidFinalAssetLimit(finalAssetAmountOut, memo) + + return `:${aggregatorAddress}:${finalAssetAddress}:${finalAssetAmountOut}` + } + + return '' + })() + + return `${_action}:${asset}:${destAddr}:${limit}:${THORCHAIN_AFFILIATE_NAME}:${ + fee || 0 + }${maybeSwapOutParts}` } case 'add': case '+':