diff --git a/apps/swap-service/src/utils/affiliateFeeAsset.ts b/apps/swap-service/src/utils/affiliateFeeAsset.ts index a7f7bbf..1a58d35 100644 --- a/apps/swap-service/src/utils/affiliateFeeAsset.ts +++ b/apps/swap-service/src/utils/affiliateFeeAsset.ts @@ -1,11 +1,13 @@ +import type { AssetId } from '@shapeshiftoss/caip' +import { thorchainAssetId } from '@shapeshiftoss/caip' import { SwapperName } from '@shapeshiftoss/swapper' import type { Asset } from '@shapeshiftoss/types' -type FeeAssetStrategy = 'buy_asset' | 'sell_asset' | 'none' +type FeeAssetStrategy = 'buy_asset' | 'sell_asset' | AssetId | null const SWAPPER_FEE_STRATEGY: Record = { [SwapperName.Across]: 'buy_asset', - [SwapperName.ArbitrumBridge]: 'none', + [SwapperName.ArbitrumBridge]: null, [SwapperName.Avnu]: 'sell_asset', [SwapperName.Bebop]: 'buy_asset', [SwapperName.ButterSwap]: 'buy_asset', @@ -16,12 +18,12 @@ const SWAPPER_FEE_STRATEGY: Record = { [SwapperName.Mayachain]: 'sell_asset', [SwapperName.NearIntents]: 'sell_asset', [SwapperName.Portals]: 'sell_asset', - [SwapperName.Relay]: 'none', + [SwapperName.Relay]: null, [SwapperName.Stonfi]: 'sell_asset', [SwapperName.Sunio]: 'buy_asset', - [SwapperName.Thorchain]: 'sell_asset', + [SwapperName.Thorchain]: thorchainAssetId, [SwapperName.Zrx]: 'buy_asset', - [SwapperName.Test]: 'none', + [SwapperName.Test]: null, } export function resolveAffiliateFeeAssetId(swapperName: SwapperName, sellAsset: Asset, buyAsset: Asset): string | null { @@ -34,6 +36,6 @@ export function resolveAffiliateFeeAssetId(swapperName: SwapperName, sellAsset: case 'sell_asset': return sellAsset.assetId default: - return null + return strategy } } diff --git a/apps/swap-service/src/verification/__tests__/fixtures/thorchain/response.json b/apps/swap-service/src/verification/__tests__/fixtures/thorchain/response.json new file mode 100644 index 0000000..27a3f43 --- /dev/null +++ b/apps/swap-service/src/verification/__tests__/fixtures/thorchain/response.json @@ -0,0 +1,76 @@ +{ + "actions": [ + { + "date": "1778278960278752371", + "height": "26094014", + "in": [ + { + "address": "0xa44c286ba83bb771cd0107b2c1df678435bd1535", + "coins": [ + { + "amount": "300000", + "asset": "ETH.ETH" + } + ], + "txID": "4A4CE957A047378C3F3AC57CA3CC3AB03E61814A7B415F493704D84A9E42D0EB" + } + ], + "metadata": { + "swap": { + "affiliateAddress": "ss", + "affiliateFee": "60", + "inPriceUSD": "2316.00252129584", + "isStreamingSwap": true, + "liquidityFee": "2327555", + "memo": "=:ETH.USDC:0xA44C286BA83Bb771cd0107B2c1Df678435Bd1535:660984846:ss:60", + "networkFees": [ + { + "amount": "25005800", + "asset": "ETH.USDC-0XA0B86991C6218B36C1D19D4A2E9EB0CE3606EB48" + } + ], + "outPriceUSD": "0.9998584435447455", + "swapSlip": "20", + "swapTarget": "660984846", + "txType": "swap" + } + }, + "out": [ + { + "address": "thor1dl7un46w7l7f3ewrnrm6nq58nerjtp0dradjtd", + "affiliate": true, + "coins": [ + { + "amount": "6944500", + "asset": "THOR.RUNE" + } + ], + "height": "26094015", + "txID": "" + }, + { + "address": "0xa44c286ba83bb771cd0107b2c1df678435bd1535", + "coins": [ + { + "amount": "664373800", + "asset": "ETH.USDC-0XA0B86991C6218B36C1D19D4A2E9EB0CE3606EB48" + } + ], + "height": "26094019", + "txID": "E6CECF7727DA23DDC52605D728775E7E022935987548FF5E9E922FDFBC55F9FC" + } + ], + "pools": [ + "ETH.ETH", + "ETH.USDC-0XA0B86991C6218B36C1D19D4A2E9EB0CE3606EB48" + ], + "status": "success", + "type": "swap" + } + ], + "count": "1", + "meta": { + "nextPageToken": "260940149000000079", + "prevPageToken": "260940149000000079" + } +} diff --git a/apps/swap-service/src/verification/__tests__/fixtures/thorchain/swap.ts b/apps/swap-service/src/verification/__tests__/fixtures/thorchain/swap.ts new file mode 100644 index 0000000..54fceb8 --- /dev/null +++ b/apps/swap-service/src/verification/__tests__/fixtures/thorchain/swap.ts @@ -0,0 +1,82 @@ +import { SwapperName } from '@shapeshiftoss/swapper' + +import type { Swap } from '../../../../swaps/types' + +export default { + swapId: 'ca88e6ef-ca09-4848-98a6-1bd18e36fc81', + sellAsset: { + icon: 'https://rawcdn.githack.com/trustwallet/assets/32e51d582a890b3dd3135fe3ee7c20c2fd699a6d/blockchains/ethereum/info/logo.png', + name: 'Ethereum', + color: '#5C6BC0', + symbol: 'ETH', + assetId: 'eip155:1/slip44:60', + chainId: 'eip155:1', + explorer: 'https://etherscan.io', + precision: 18, + networkIcon: + 'https://rawcdn.githack.com/trustwallet/assets/32e51d582a890b3dd3135fe3ee7c20c2fd699a6d/blockchains/ethereum/info/logo.png', + networkName: 'Ethereum', + networkColor: '#5C6BC0', + explorerTxLink: 'https://etherscan.io/tx/', + relatedAssetKey: 'eip155:1/slip44:60', + explorerAddressLink: 'https://etherscan.io/address/', + }, + buyAsset: { + icon: 'https://assets.coingecko.com/coins/images/6319/large/USDC.png?1769615602', + name: 'USDC', + color: '#2373CB', + symbol: 'USDC', + assetId: 'eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + chainId: 'eip155:1', + explorer: 'https://etherscan.io', + precision: 6, + networkIcon: + 'https://rawcdn.githack.com/trustwallet/assets/32e51d582a890b3dd3135fe3ee7c20c2fd699a6d/blockchains/ethereum/info/logo.png', + networkName: 'Ethereum', + networkColor: '#5C6BC0', + explorerTxLink: 'https://etherscan.io/tx/', + relatedAssetKey: 'eip155:1/erc20:0xa0b86991c6218b36c1d19d4a2e9eb0ce3606eb48', + explorerAddressLink: 'https://etherscan.io/address/', + }, + sellAmountCryptoBaseUnit: '3000000000000000', + expectedBuyAmountCryptoBaseUnit: '6643063.78', + actualBuyAmountCryptoBaseUnit: null, + status: 'SUCCESS', + source: 'THORChain', + swapperName: SwapperName.Thorchain, + sellAccountId: '3c70e97c6f86a5b5cfdf82dfd3380ac4ed3d8b89dabf86f631bdf739372926be', + buyAccountId: null, + receiveAddress: '0xA44C286BA83Bb771cd0107B2c1Df678435Bd1535', + sellTxHash: '0x4a4ce957a047378c3f3ac57ca3cc3ab03e61814a7b415f493704d84a9e42d0eb', + buyTxHash: '0xE6CECF7727DA23DDC52605D728775E7E022935987548FF5E9E922FDFBC55F9FC', + txLink: null, + statusMessage: '', + isStreaming: false, + createdAt: new Date('2026-05-08T22:21:53.060Z'), + updatedAt: new Date('2026-05-08T22:23:25.138Z'), + metadata: { + quoteId: 'ca88e6ef-ca09-4848-98a6-1bd18e36fc81', + stepIndex: 0, + acrossTransactionMetadata: undefined, + chainflipSwapId: undefined, + debridgeTransactionMetadata: undefined, + relayerExplorerTxLink: undefined, + relayerTxHash: undefined, + relayTransactionMetadata: undefined, + streamingSwapMetadata: undefined, + }, + userId: 'api', + referralCode: null, + sellAssetUsd: '2315.29', + buyAssetUsd: '0.999934', + affiliateAssetUsd: '2315.29', + isAffiliateVerified: null, + affiliateVerificationDetails: null, + affiliateAddress: '0xA44C286BA83Bb771cd0107B2c1Df678435Bd1535', + affiliateBps: 60, + origin: 'api', + affiliateFeeAssetId: 'eip155:1/slip44:60', + actualAffiliateFeeAmountCryptoBaseUnit: null, + shapeshiftBps: 10, + verificationStatus: 'PENDING', +} satisfies Swap diff --git a/apps/swap-service/src/verification/__tests__/setup.ts b/apps/swap-service/src/verification/__tests__/setup.ts index e6bf8d4..e083d37 100644 --- a/apps/swap-service/src/verification/__tests__/setup.ts +++ b/apps/swap-service/src/verification/__tests__/setup.ts @@ -1,4 +1,5 @@ import { Logger } from '@nestjs/common' +import type BigNumberJs from 'bignumber.js' Logger.overrideLogger(false) @@ -8,6 +9,7 @@ jest.mock('../../env', () => ({ VITE_CHAINFLIP_API_KEY: 'x', VITE_NEAR_INTENTS_API_KEY: 'x', VITE_THORCHAIN_NODE_URL: 'https://thornode.test', + VITE_THORCHAIN_MIDGARD_URL: 'https://midgard.test', VITE_MAYACHAIN_NODE_URL: 'https://mayanode.test', VITE_ACROSS_API_URL: 'https://across.test', VITE_BEBOP_API_URL: 'https://bebop.test', @@ -23,6 +25,18 @@ jest.mock('../../utils/pricing', () => ({ getAssetPriceUsd: jest.fn(), })) +// chain-adapters transitively imports p-queue (ESM-only) which Jest's CJS loader can't parse. +// We only need bnOrZero in tests, so stub it via bignumber.js directly. +jest.mock('@shapeshiftoss/chain-adapters', () => { + const BigNumber = jest.requireActual('bignumber.js') + return { + bnOrZero: (x: unknown) => { + const bn = new BigNumber(x as BigNumberJs.Value) + return bn.isFinite() ? bn : new BigNumber(0) + }, + } +}) + jest.mock('@shapeshiftoss/swapper', () => ({ SwapperName: { Thorchain: 'THORChain', diff --git a/apps/swap-service/src/verification/__tests__/thorchain.test.ts b/apps/swap-service/src/verification/__tests__/thorchain.test.ts new file mode 100644 index 0000000..8a64d85 --- /dev/null +++ b/apps/swap-service/src/verification/__tests__/thorchain.test.ts @@ -0,0 +1,201 @@ +import type { HttpService } from '@nestjs/axios' +import { of, throwError } from 'rxjs' + +import type { Swap } from '../../swaps/types' +import { SwapVerificationService } from '../swap-verification.service' + +import thorchainResponse from './fixtures/thorchain/response.json' +import thorchainSwap from './fixtures/thorchain/swap' + +const swap = thorchainSwap as unknown as Swap + +const makeHttpMock = (response: unknown): HttpService => { + const get = jest.fn().mockReturnValue(of({ data: response })) + return { get } as unknown as HttpService +} + +describe('verifyThorchain', () => { + let service: SwapVerificationService + + beforeEach(() => { + jest.restoreAllMocks() + }) + + it('verifies a successful swap with shapeshift affiliate', async () => { + service = new SwapVerificationService(makeHttpMock(thorchainResponse)) + + const result = await service.verifySwap(swap) + + expect(result).toMatchObject({ + verificationStatus: 'SUCCESS', + hasAffiliate: true, + affiliateBps: 60, + affiliateAddress: 'ss', + verifiedSellAmountCryptoBaseUnit: '3000000000000000', + actualBuyAmountCryptoBaseUnit: '6643738', + actualAffiliateFeeAmountCryptoBaseUnit: '6944500', + }) + }) + + it('strips 0x prefix from sellTxHash before calling Midgard', async () => { + const get = jest.fn().mockReturnValue(of({ data: thorchainResponse })) + service = new SwapVerificationService({ get } as unknown as HttpService) + + await service.verifySwap(swap) + + const url = get.mock.calls[0][0] + expect(url).toMatch(/\/actions\?txid=[0-9a-f]+$/i) + expect(url).not.toMatch(/=0x/i) + }) + + it('does not attribute affiliate fields when the action affiliate is not ss', async () => { + const response = structuredClone(thorchainResponse) + response.actions[0].metadata.swap.affiliateAddress = 'other' + + service = new SwapVerificationService(makeHttpMock(response)) + + const result = await service.verifySwap(swap) + + expect(result.verificationStatus).toBe('SUCCESS') + expect(result.hasAffiliate).toBe(false) + expect(result.affiliateAddress).toBeUndefined() + expect(result.affiliateBps).toBeUndefined() + expect(result.actualAffiliateFeeAmountCryptoBaseUnit).toBeUndefined() + }) + + it('returns hasAffiliate=false when affiliateAddress is ss but no fee was paid out', async () => { + const response = structuredClone(thorchainResponse) + response.actions[0].out = response.actions[0].out.filter((out) => !out.affiliate) + + service = new SwapVerificationService(makeHttpMock(response)) + + const result = await service.verifySwap(swap) + + expect(result.hasAffiliate).toBe(false) + expect(result.affiliateAddress).toBeUndefined() + expect(result.affiliateBps).toBeUndefined() + expect(result.actualAffiliateFeeAmountCryptoBaseUnit).toBeUndefined() + }) + + it('returns FAILED when sellTxHash is missing', async () => { + service = new SwapVerificationService(makeHttpMock(thorchainResponse)) + + const result = await service.verifySwap({ ...swap, sellTxHash: null } as Swap) + + expect(result).toMatchObject({ + verificationStatus: 'FAILED', + hasAffiliate: false, + noAffiliateReason: 'Missing txHash for Thorchain verification', + }) + }) + + it('returns PENDING when Midgard returns no actions', async () => { + service = new SwapVerificationService(makeHttpMock({ actions: [] })) + + const result = await service.verifySwap(swap) + + expect(result.verificationStatus).toBe('PENDING') + expect(result.noAffiliateReason).toBe('No action found in Midgard') + }) + + it('returns PENDING when the action is still pending', async () => { + const response = structuredClone(thorchainResponse) + response.actions[0].status = 'pending' + + service = new SwapVerificationService(makeHttpMock(response)) + + const result = await service.verifySwap(swap) + + expect(result.verificationStatus).toBe('PENDING') + expect(result.noAffiliateReason).toBe('Swap action still pending') + }) + + it('returns FAILED when the action type is not swap', async () => { + const response = structuredClone(thorchainResponse) + response.actions[0].type = 'addLiquidity' + + service = new SwapVerificationService(makeHttpMock(response)) + + const result = await service.verifySwap(swap) + + expect(result.verificationStatus).toBe('FAILED') + expect(result.noAffiliateReason).toBe('Invalid swap action type') + }) + + it('returns FAILED when swap metadata is missing', async () => { + const response = structuredClone(thorchainResponse) as { + actions: Array<{ metadata: { swap?: unknown } }> + } + delete response.actions[0].metadata.swap + + service = new SwapVerificationService(makeHttpMock(response)) + + const result = await service.verifySwap(swap) + + expect(result.verificationStatus).toBe('FAILED') + expect(result.noAffiliateReason).toBe('No swap metadata found') + }) + + it('selects the buy out by memo destination rather than array position', async () => { + const response = structuredClone(thorchainResponse) + // Move the destination out to the front so position-based selection would return the wrong entry. + response.actions[0].out.reverse() + + service = new SwapVerificationService(makeHttpMock(response)) + + const result = await service.verifySwap(swap) + + expect(result.actualBuyAmountCryptoBaseUnit).toBe('6643738') + }) + + it('returns FAILED when no out matches the memo destination', async () => { + const response = structuredClone(thorchainResponse) + response.actions[0].out = response.actions[0].out.map((out) => + out.affiliate ? out : { ...out, address: '0xdeadbeef' }, + ) + + service = new SwapVerificationService(makeHttpMock(response)) + + const result = await service.verifySwap(swap) + + expect(result.verificationStatus).toBe('FAILED') + expect(result.noAffiliateReason).toBe('No outbound matching memo destination') + }) + + it('returns FAILED when the action status is failed (refund)', async () => { + const response = structuredClone(thorchainResponse) + response.actions[0].status = 'failed' + + service = new SwapVerificationService(makeHttpMock(response)) + + const result = await service.verifySwap(swap) + + expect(result.verificationStatus).toBe('FAILED') + expect(result.noAffiliateReason).toBe('Swap action failed on Thorchain') + }) + + it('returns FAILED when the memo has no destination address', async () => { + const response = structuredClone(thorchainResponse) + response.actions[0].metadata.swap.memo = '' + + service = new SwapVerificationService(makeHttpMock(response)) + + const result = await service.verifySwap(swap) + + expect(result.verificationStatus).toBe('FAILED') + expect(result.noAffiliateReason).toBe('Could not parse destination address from memo') + }) + + it('returns PENDING when the HTTP call fails (transient — retry next tick)', async () => { + const httpMock = { + get: jest.fn().mockReturnValue(throwError(() => new Error('upstream 500'))), + } as unknown as HttpService + + service = new SwapVerificationService(httpMock) + + const result = await service.verifySwap(swap) + + expect(result.verificationStatus).toBe('PENDING') + expect(result.noAffiliateReason).toBe('upstream 500') + }) +}) diff --git a/apps/swap-service/src/verification/swap-verification.service.ts b/apps/swap-service/src/verification/swap-verification.service.ts index 4602fde..0554b2d 100644 --- a/apps/swap-service/src/verification/swap-verification.service.ts +++ b/apps/swap-service/src/verification/swap-verification.service.ts @@ -19,6 +19,7 @@ import { CowSwapAppDataResponse, CowSwapDecodedAppData, CowSwapOrderResponse, + MidgardActionsResponse, PortalsOrderResponse, RelayRequestsResponse, StonfiQuoteMetadata, @@ -38,12 +39,10 @@ export class SwapVerificationService { private readonly shapeshiftChainflipAffiliate = 'shapeshift' private readonly shapeshiftCowswapAppCode = 'shapeshift' private readonly shapeshiftMayaAffiliate = 'ssmaya' - private readonly shapeshiftThorchainAffiliate = 'ss' private readonly bebopApiKey = env.VITE_BEBOP_API_KEY private readonly chainflipApiKey = env.VITE_CHAINFLIP_API_KEY - private readonly thorchainNodeUrl = env.VITE_THORCHAIN_NODE_URL private readonly mayachainNodeUrl = env.VITE_MAYACHAIN_NODE_URL private readonly acrossApiUrl = env.VITE_ACROSS_API_URL @@ -330,48 +329,49 @@ export class SwapVerificationService { } private async verifyThorchain(swap: Swap): Promise { - const txHash = swap.sellTxHash || undefined - + const txHash = swap.sellTxHash?.replace(/^0x/, '') if (!txHash) return noAffiliateResult('FAILED', 'Missing txHash for Thorchain verification') - // SECURITY: Query Thorchain node API to verify memo contains affiliate info - const txUrl = `${this.thorchainNodeUrl}/thorchain/tx/${txHash}` + const { data } = await firstValueFrom( + this.httpService.get(`${env.VITE_THORCHAIN_MIDGARD_URL}/actions?txid=${txHash}`), + ) - this.logger.log(`Thorchain - Fetching tx from node API: ${txUrl}`) + const action = data.actions[0] + if (!action) return noAffiliateResult('PENDING', 'No action found in Midgard') - const response = await firstValueFrom(this.httpService.get(txUrl)) + if (action.type !== 'swap') return noAffiliateResult('FAILED', 'Invalid swap action type') + if (action.status === 'pending') return noAffiliateResult('PENDING', 'Swap action still pending') + if (action.status === 'failed') return noAffiliateResult('FAILED', 'Swap action failed on Thorchain') - const observedTx = response.data?.observed_tx + const swapMetadata = action.metadata.swap + if (!swapMetadata) return noAffiliateResult('FAILED', 'No swap metadata found') - if (!observedTx || !observedTx.tx) return noAffiliateResult('PENDING', 'No observed transaction found') + const affiliateAddress = swapMetadata.affiliateAddress - const memo: string | undefined = observedTx.tx.memo - // Observed tx's memo is immutable on chain — absence is definitive, not transient. - if (!memo) return noAffiliateResult('SUCCESS', 'No memo found in transaction') + // Memo format: =:ASSET:DESTADDR:LIM/INTERVAL/QUANTITY:AFFILIATE:FEE + // The destination is what THORChain observed on-chain, so it's the trusted source for matching the buy out. + const destinationAddress = swapMetadata.memo.split(':')[2] + if (!destinationAddress) return noAffiliateResult('FAILED', 'Could not parse destination address from memo') - // Parse memo format: =:r:thor1dz68dtlzrxnjflha9vvs7yt7p77mqdnf5yugww:131082237:ss:0 - // The affiliate code is after the 4th colon, followed by fee in bps - const memoPattern = new RegExp(`:${this.shapeshiftThorchainAffiliate}:(\\d+)`, 'i') - const memoMatch = memo.match(memoPattern) - - const hasShapeshiftAffiliate = !!memoMatch - const affiliateBps = memoMatch ? parseInt(memoMatch[1]) : undefined + const buyOut = action.out.find( + (out) => !out.affiliate && out.address.toLowerCase() === destinationAddress.toLowerCase(), + ) + if (!buyOut) return noAffiliateResult('FAILED', 'No outbound matching memo destination') - const coins = observedTx.tx.coins - const sellAssetPrecision = swap.sellAsset.precision ?? THORCHAIN_PRECISION - const firstCoinAmount = coins?.[0]?.amount - const verifiedSellAmountCryptoBaseUnit = firstCoinAmount - ? thorchainToNativePrecision(firstCoinAmount, sellAssetPrecision) - : undefined + const feeOut = action.out.find((out) => out.affiliate) + const hasAffiliate = affiliateAddress === 'ss' && !!feeOut return { verificationStatus: 'SUCCESS', - hasAffiliate: hasShapeshiftAffiliate, - affiliateBps: hasShapeshiftAffiliate && affiliateBps ? affiliateBps : undefined, - affiliateAddress: hasShapeshiftAffiliate ? this.shapeshiftThorchainAffiliate : undefined, - verifiedSellAmountCryptoBaseUnit, - actualBuyAmountCryptoBaseUnit: undefined, - actualAffiliateFeeAmountCryptoBaseUnit: undefined, + hasAffiliate, + affiliateBps: hasAffiliate ? parseInt(swapMetadata.affiliateFee) : undefined, + affiliateAddress: hasAffiliate ? affiliateAddress : undefined, + verifiedSellAmountCryptoBaseUnit: thorchainToNativePrecision( + action.in[0].coins[0].amount, + swap.sellAsset.precision, + ), + actualBuyAmountCryptoBaseUnit: thorchainToNativePrecision(buyOut.coins[0].amount, swap.buyAsset.precision), + actualAffiliateFeeAmountCryptoBaseUnit: hasAffiliate ? feeOut?.coins[0].amount : undefined, } } diff --git a/apps/swap-service/src/verification/types.ts b/apps/swap-service/src/verification/types.ts index 75e3f52..fb6c58a 100644 --- a/apps/swap-service/src/verification/types.ts +++ b/apps/swap-service/src/verification/types.ts @@ -7,6 +7,56 @@ export interface ThorchainMayaTxResponse { } } +export type MidgardCoin = { + amount: string + asset: string +} + +export type MidgardInTransaction = { + address: string + coins: MidgardCoin[] + txID: string +} + +export type MidgardOutTransaction = { + address: string + affiliate?: boolean + coins: MidgardCoin[] + height: string + txID: string +} + +export type MidgardSwapMetadata = { + affiliateAddress: string + affiliateFee: string + inPriceUSD: string + isStreamingSwap: boolean + liquidityFee: string + memo: string + networkFees: MidgardCoin[] + outPriceUSD: string + swapSlip: string + swapTarget: string + txType: string +} + +export type MidgardAction = { + date: string + height: string + in: MidgardInTransaction[] + metadata: { + swap?: MidgardSwapMetadata + } + out: MidgardOutTransaction[] + pools: string[] + status: 'pending' | 'success' | 'failed' + type: string +} + +export type MidgardActionsResponse = { + actions: MidgardAction[] +} + interface RelayAppFee { recipient?: string bps?: string diff --git a/apps/swap-service/src/verification/utils.ts b/apps/swap-service/src/verification/utils.ts index e1f0e91..aeca92b 100644 --- a/apps/swap-service/src/verification/utils.ts +++ b/apps/swap-service/src/verification/utils.ts @@ -1,15 +1,13 @@ import type { SwapVerificationResult } from '@shapeshift/shared-types' +import { bnOrZero } from '@shapeshiftoss/chain-adapters' export const BPS_DENOMINATOR = 10000n export const THORCHAIN_PRECISION = 8 -export const thorchainToNativePrecision = (thorchainAmount: string, nativePrecision: number): string => { - const diff = nativePrecision - THORCHAIN_PRECISION - if (diff === 0) return thorchainAmount - if (diff > 0) return thorchainAmount + '0'.repeat(diff) - const trimmed = thorchainAmount.slice(0, diff) - return trimmed || '0' -} +export const thorchainToNativePrecision = (thorchainAmount: string, nativePrecision: number): string => + bnOrZero(thorchainAmount) + .shiftedBy(nativePrecision - THORCHAIN_PRECISION) + .toFixed(0, 1) export const noAffiliateResult = ( verificationStatus: SwapVerificationResult['verificationStatus'],