From 9b2ffcba343aa4087d3dc872485bb2482e44d59b Mon Sep 17 00:00:00 2001 From: kaladinlight <35275952+kaladinlight@users.noreply.github.com> Date: Tue, 5 May 2026 17:54:59 -0600 Subject: [PATCH 1/6] feat(swap-verification): WIP thorchain swap verification WIP commit moved to its own branch so the swap verification status refactor (verificationStatus column + decoupled polling) can land independently on develop. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../fixtures/thorchain/status-response.json | 78 +++++ .../__tests__/fixtures/thorchain/swap.ts | 76 +++++ .../fixtures/thorchain/tx-response.json | 34 +++ .../verification/__tests__/thorchain.test.ts | 200 ++++++++++++ .../verification/swap-verification.service.ts | 288 ++++++++---------- apps/swap-service/src/verification/types.ts | 53 +++- apps/swap-service/src/verification/utils.ts | 12 +- 7 files changed, 565 insertions(+), 176 deletions(-) create mode 100644 apps/swap-service/src/verification/__tests__/fixtures/thorchain/status-response.json create mode 100644 apps/swap-service/src/verification/__tests__/fixtures/thorchain/swap.ts create mode 100644 apps/swap-service/src/verification/__tests__/fixtures/thorchain/tx-response.json create mode 100644 apps/swap-service/src/verification/__tests__/thorchain.test.ts diff --git a/apps/swap-service/src/verification/__tests__/fixtures/thorchain/status-response.json b/apps/swap-service/src/verification/__tests__/fixtures/thorchain/status-response.json new file mode 100644 index 0000000..44d468f --- /dev/null +++ b/apps/swap-service/src/verification/__tests__/fixtures/thorchain/status-response.json @@ -0,0 +1,78 @@ +{ + "tx": { + "id": "0000000000000000000000000000000000000000000000000000000000000000", + "memo": "=:BTC.BTC:bc1qd5w0nndwnefa9uel2c2pqtj7tg6c4d2tlvyuvw:0:ss:30", + "chain": "ETH", + "from_address": "0xA44C286BA83Bb771cd0107B2c1Df678435Bd1535", + "to_address": "0x0000000000000000000000000000000000000000" + }, + "stages": { + "inbound_observed": { + "started": true, + "final_count": 2, + "pre_confirmation_count": 0, + "completed": true + }, + "inbound_confirmation_counted": { + "completed": true + }, + "inbound_finalised": { + "completed": true + }, + "swap_status": { + "pending": false + }, + "swap_finalised": { + "completed": true + }, + "outbound_signed": { + "completed": true + } + }, + "out_txs": [ + { + "id": "1111111111111111111111111111111111111111111111111111111111111111", + "chain": "BTC", + "from_address": "bc1qvault00000000000000000000000000000000", + "to_address": "bc1qd5w0nndwnefa9uel2c2pqtj7tg6c4d2tlvyuvw", + "coins": [ + { + "asset": "BTC.BTC", + "amount": "2000" + } + ], + "gas": [ + { + "asset": "BTC.BTC", + "amount": "100" + } + ], + "memo": "OUT:0000000000000000000000000000000000000000000000000000000000000000" + }, + { + "id": "2222222222222222222222222222222222222222222222222222222222222222", + "chain": "THOR", + "from_address": "thor1vault0000000000000000000000000000000000", + "to_address": "thor1ss0000000000000000000000000000000000000", + "coins": [ + { + "asset": "THOR.RUNE", + "amount": "1500" + } + ], + "gas": [], + "memo": "OUT:0000000000000000000000000000000000000000000000000000000000000000" + } + ], + "planned_out_txs": [ + { + "chain": "BTC", + "to_address": "bc1qd5w0nndwnefa9uel2c2pqtj7tg6c4d2tlvyuvw", + "coin": { + "asset": "BTC.BTC", + "amount": "2000" + }, + "refund": false + } + ] +} 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..ff356a9 --- /dev/null +++ b/apps/swap-service/src/verification/__tests__/fixtures/thorchain/swap.ts @@ -0,0 +1,76 @@ +import { SwapperName } from '@shapeshiftoss/swapper' + +import type { Swap } from '../../../../swaps/types' + +export default { + swapId: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee', + 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, + networkName: 'Ethereum', + explorerTxLink: 'https://etherscan.io/tx/', + relatedAssetKey: 'eip155:1/slip44:60', + explorerAddressLink: 'https://etherscan.io/address/', + }, + buyAsset: { + icon: 'https://rawcdn.githack.com/trustwallet/assets/master/blockchains/bitcoin/info/logo.png', + name: 'Bitcoin', + color: '#FF9800', + symbol: 'BTC', + assetId: 'bip122:000000000019d6689c085ae165831e93/slip44:0', + chainId: 'bip122:000000000019d6689c085ae165831e93', + explorer: 'https://blockstream.info', + precision: 8, + networkName: 'Bitcoin', + explorerTxLink: 'https://blockstream.info/tx/', + relatedAssetKey: 'bip122:000000000019d6689c085ae165831e93/slip44:0', + explorerAddressLink: 'https://blockstream.info/address/', + }, + sellAmountCryptoBaseUnit: '1000000000000000', + expectedBuyAmountCryptoBaseUnit: '2000', + actualBuyAmountCryptoBaseUnit: null, + status: 'PENDING', + source: 'THORChain', + swapperName: SwapperName.Thorchain, + sellAccountId: '3c70e97c6f86a5b5cfdf82dfd3380ac4ed3d8b89dabf86f631bdf739372926be', + buyAccountId: null, + receiveAddress: 'bc1qd5w0nndwnefa9uel2c2pqtj7tg6c4d2tlvyuvw', + sellTxHash: '0x0000000000000000000000000000000000000000000000000000000000000000', + buyTxHash: null, + txLink: null, + statusMessage: null, + isStreaming: false, + createdAt: new Date('2026-04-30T12:00:00.000Z'), + updatedAt: new Date('2026-04-30T12:00:00.000Z'), + metadata: { + quoteId: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee', + stepIndex: 0, + acrossTransactionMetadata: undefined, + chainflipSwapId: undefined, + debridgeTransactionMetadata: undefined, + relayerExplorerTxLink: undefined, + relayerTxHash: undefined, + relayTransactionMetadata: undefined, + streamingSwapMetadata: undefined, + nearIntentsSpecific: undefined, + }, + userId: 'api', + referralCode: null, + sellAssetUsd: '2290', + buyAssetUsd: '95000', + affiliateAssetUsd: '2290', + isAffiliateVerified: null, + affiliateVerificationDetails: null, + affiliateAddress: null, + affiliateBps: 30, + origin: 'api', + affiliateFeeAssetId: 'eip155:1/slip44:60', + actualAffiliateFeeAmountCryptoBaseUnit: null, + shapeshiftBps: 10, +} satisfies Swap diff --git a/apps/swap-service/src/verification/__tests__/fixtures/thorchain/tx-response.json b/apps/swap-service/src/verification/__tests__/fixtures/thorchain/tx-response.json new file mode 100644 index 0000000..3692465 --- /dev/null +++ b/apps/swap-service/src/verification/__tests__/fixtures/thorchain/tx-response.json @@ -0,0 +1,34 @@ +{ + "observed_tx": { + "tx": { + "id": "0000000000000000000000000000000000000000000000000000000000000000", + "chain": "ETH", + "from_address": "0xA44C286BA83Bb771cd0107B2c1Df678435Bd1535", + "to_address": "0x0000000000000000000000000000000000000000", + "coins": [ + { + "asset": "ETH.ETH", + "amount": "100000", + "decimals": 8 + } + ], + "gas": [ + { + "asset": "ETH.ETH", + "amount": "120000" + } + ], + "memo": "=:BTC.BTC:bc1qd5w0nndwnefa9uel2c2pqtj7tg6c4d2tlvyuvw:0:ss:30" + }, + "external_observed_height": 24980390, + "external_confirmation_delay_height": 24980392, + "signers": [], + "out_hashes": [ + "1111111111111111111111111111111111111111111111111111111111111111" + ], + "status": "done" + }, + "consensus_height": 100000000, + "finalised_height": 100000010, + "outbound_height": 100000020 +} 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..a1fc4f1 --- /dev/null +++ b/apps/swap-service/src/verification/__tests__/thorchain.test.ts @@ -0,0 +1,200 @@ +import type { HttpService } from '@nestjs/axios' +import { of, throwError } from 'rxjs' + +import type { Swap } from '../../swaps/types' +import { SwapVerificationService } from '../swap-verification.service' + +import thorchainStatusResponse from './fixtures/thorchain/status-response.json' +import thorchainSwap from './fixtures/thorchain/swap' +import thorchainTxResponse from './fixtures/thorchain/tx-response.json' + +const swap = thorchainSwap as unknown as Swap + +const makeHttpMock = (txResponse: unknown, statusResponse: unknown): HttpService => { + const get = jest.fn().mockImplementation((url: string) => { + if (url.includes('/tx/status/')) return of({ data: statusResponse }) + return of({ data: txResponse }) + }) + return { get } as unknown as HttpService +} + +describe('verifyThorchain', () => { + let service: SwapVerificationService + + beforeEach(() => { + jest.restoreAllMocks() + }) + + it('verifies a successful swap with shapeshift affiliate memo', async () => { + service = new SwapVerificationService(makeHttpMock(thorchainTxResponse, thorchainStatusResponse)) + + const result = await service.verifySwap(swap) + + expect(result).toMatchObject({ + isVerified: true, + hasAffiliate: true, + affiliateBps: 30, + affiliateAddress: 'ss', + verifiedSellAmountCryptoBaseUnit: '1000000000000000', + actualBuyAmountCryptoBaseUnit: '2000', + actualAffiliateFeeAmountCryptoBaseUnit: '3000000000000', + }) + }) + + it('strips 0x prefix from sellTxHash before calling thornode', async () => { + const httpMock = makeHttpMock(thorchainTxResponse, thorchainStatusResponse) + service = new SwapVerificationService(httpMock) + + await service.verifySwap(swap) + + const get = httpMock.get as unknown as jest.Mock + const urls = get.mock.calls.map(([url]: [string]) => url) + expect(urls.every((url: string) => !/\/(0x)/i.test(url))).toBe(true) + expect(urls.some((url: string) => url.includes('/thorchain/tx/'))).toBe(true) + expect(urls.some((url: string) => url.includes('/thorchain/tx/status/'))).toBe(true) + }) + + it('returns hasAffiliate=false when memo lacks the ss affiliate code', async () => { + const txResponse = structuredClone(thorchainTxResponse) + txResponse.observed_tx.tx.memo = '=:BTC.BTC:bc1qd5w0nndwnefa9uel2c2pqtj7tg6c4d2tlvyuvw:0:t:30' + + service = new SwapVerificationService(makeHttpMock(txResponse, thorchainStatusResponse)) + + const result = await service.verifySwap(swap) + + expect(result.isVerified).toBe(true) + expect(result.hasAffiliate).toBe(false) + expect(result.affiliateBps).toBeUndefined() + expect(result.affiliateAddress).toBeUndefined() + expect(result.actualAffiliateFeeAmountCryptoBaseUnit).toBeUndefined() + }) + + it('parses bps when memo uses a streaming-swap limit (LIM/INTERVAL/QUANTITY)', async () => { + const txResponse = structuredClone(thorchainTxResponse) + txResponse.observed_tx.tx.memo = + '=:BTC.BTC:bc1qd5w0nndwnefa9uel2c2pqtj7tg6c4d2tlvyuvw:0/1/0:ss:30' + + service = new SwapVerificationService(makeHttpMock(txResponse, thorchainStatusResponse)) + + const result = await service.verifySwap(swap) + + expect(result.hasAffiliate).toBe(true) + expect(result.affiliateBps).toBe(30) + }) + + it('sums multiple out_txs to the receiveAddress (streaming-swap split outbounds)', async () => { + const statusResponse = structuredClone(thorchainStatusResponse) + statusResponse.out_txs = [ + { + id: 'a', + chain: 'BTC', + from_address: 'bc1qvault00000000000000000000000000000000', + to_address: 'bc1qd5w0nndwnefa9uel2c2pqtj7tg6c4d2tlvyuvw', + coins: [{ asset: 'BTC.BTC', amount: '1200' }], + gas: [], + memo: 'OUT:...', + }, + { + id: 'b', + chain: 'BTC', + from_address: 'bc1qvault00000000000000000000000000000000', + to_address: 'bc1qd5w0nndwnefa9uel2c2pqtj7tg6c4d2tlvyuvw', + coins: [{ asset: 'BTC.BTC', amount: '800' }], + gas: [], + memo: 'OUT:...', + }, + ] + + service = new SwapVerificationService(makeHttpMock(thorchainTxResponse, statusResponse)) + + const result = await service.verifySwap(swap) + + expect(result.actualBuyAmountCryptoBaseUnit).toBe('2000') + }) + + it('matches the receiveAddress case-insensitively', async () => { + const statusResponse = structuredClone(thorchainStatusResponse) + statusResponse.out_txs[0].to_address = 'BC1QD5W0NNDWNEFA9UEL2C2PQTJ7TG6C4D2TLVYUVW' + + service = new SwapVerificationService(makeHttpMock(thorchainTxResponse, statusResponse)) + + const result = await service.verifySwap(swap) + + expect(result.actualBuyAmountCryptoBaseUnit).toBe('2000') + }) + + it('ignores out_txs that are not addressed to the user (e.g. RUNE affiliate outbound)', async () => { + const statusResponse = structuredClone(thorchainStatusResponse) + // Drop the BTC outbound, leave only the RUNE outbound to the affiliate. + statusResponse.out_txs = statusResponse.out_txs.filter( + (out: { chain?: string }) => out.chain !== 'BTC', + ) + + service = new SwapVerificationService(makeHttpMock(thorchainTxResponse, statusResponse)) + + const result = await service.verifySwap(swap) + + expect(result.actualBuyAmountCryptoBaseUnit).toBeUndefined() + }) + + it('leaves actualBuyAmountCryptoBaseUnit undefined when out_txs is missing', async () => { + const statusResponse = structuredClone(thorchainStatusResponse) as Partial< + typeof thorchainStatusResponse + > + delete statusResponse.out_txs + + service = new SwapVerificationService(makeHttpMock(thorchainTxResponse, statusResponse)) + + const result = await service.verifySwap(swap) + + expect(result.actualBuyAmountCryptoBaseUnit).toBeUndefined() + }) + + it('returns unverified when sellTxHash is missing', async () => { + service = new SwapVerificationService(makeHttpMock(thorchainTxResponse, thorchainStatusResponse)) + + const result = await service.verifySwap({ ...swap, sellTxHash: null } as Swap) + + expect(result).toMatchObject({ + isVerified: false, + hasAffiliate: false, + error: 'Missing txHash for Thorchain verification', + }) + }) + + it('returns unverified when observed_tx is missing', async () => { + service = new SwapVerificationService(makeHttpMock({}, thorchainStatusResponse)) + + const result = await service.verifySwap(swap) + + expect(result.isVerified).toBe(false) + expect(result.error).toBe('No observed transaction found') + }) + + it('returns unverified when memo is missing', async () => { + const txResponse = structuredClone(thorchainTxResponse) as { + observed_tx: { tx: Partial<(typeof thorchainTxResponse)['observed_tx']['tx']> } + } + delete txResponse.observed_tx.tx.memo + + service = new SwapVerificationService(makeHttpMock(txResponse, thorchainStatusResponse)) + + const result = await service.verifySwap(swap) + + expect(result.isVerified).toBe(false) + expect(result.error).toBe('No memo found in transaction') + }) + + it('returns unverified when the HTTP call fails', 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.isVerified).toBe(false) + expect(result.error).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 83508e2..3197022 100644 --- a/apps/swap-service/src/verification/swap-verification.service.ts +++ b/apps/swap-service/src/verification/swap-verification.service.ts @@ -19,14 +19,14 @@ import { CowSwapAppDataResponse, CowSwapDecodedAppData, CowSwapOrderResponse, + MidgardActionsResponse, PortalsOrderResponse, RelayRequestsResponse, StonfiQuoteMetadata, - ThorchainMayaTxResponse, ZrxApiResponse, ZrxTrade, } from './types' -import { applyBps, logVerification, THORCHAIN_PRECISION, thorchainToNativePrecision } from './utils' +import { applyBps, logVerification, thorchainToNativePrecision } from './utils' @Injectable() export class SwapVerificationService { @@ -37,15 +37,10 @@ export class SwapVerificationService { private readonly shapeshiftButterswapEntrance = 'shapeshift' 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 private readonly bebopApiUrl = env.VITE_BEBOP_API_URL private readonly chainflipApiUrl = env.VITE_CHAINFLIP_API_URL @@ -84,7 +79,8 @@ export class SwapVerificationService { case SwapperName.Thorchain: return await this.verifyThorchain(swap) case SwapperName.Mayachain: - return await this.verifyMaya(swap) + return {} as SwapVerificationResult + // return await this.verifyMaya(swap) case SwapperName.Chainflip: return await this.verifyChainflip(swap) case SwapperName.Zrx: @@ -411,175 +407,141 @@ export class SwapVerificationService { } private async verifyThorchain(swap: Swap): Promise { - const { swapId } = swap - const txHash = swap.sellTxHash || undefined - - if (!txHash) { - return { - isVerified: false, - hasAffiliate: false, - actualBuyAmountCryptoBaseUnit: undefined, - actualAffiliateFeeAmountCryptoBaseUnit: undefined, - error: 'Missing txHash for Thorchain verification', - } - } - - try { - // SECURITY: Query Thorchain node API to verify memo contains affiliate info - const txUrl = `${this.thorchainNodeUrl}/thorchain/tx/${txHash}` - - this.logger.log(`Thorchain - Fetching tx from node API: ${txUrl}`) - - const response = await firstValueFrom(this.httpService.get(txUrl)) - - const observedTx = response.data?.observed_tx - - if (!observedTx || !observedTx.tx) { - return { - isVerified: false, - hasAffiliate: false, - actualBuyAmountCryptoBaseUnit: undefined, - actualAffiliateFeeAmountCryptoBaseUnit: undefined, - error: 'No observed transaction found', - } - } - - const memo: string | undefined = observedTx.tx.memo - if (!memo) { - return { - isVerified: false, - hasAffiliate: false, - actualBuyAmountCryptoBaseUnit: undefined, - actualAffiliateFeeAmountCryptoBaseUnit: undefined, - error: 'No memo found in transaction', - } - } - - // 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 coins = observedTx.tx.coins - const sellAssetPrecision = swap.sellAsset.precision ?? THORCHAIN_PRECISION - const firstCoinAmount = coins?.[0]?.amount - const verifiedSellAmountCryptoBaseUnit = firstCoinAmount - ? thorchainToNativePrecision(firstCoinAmount, sellAssetPrecision) - : undefined - - const result: SwapVerificationResult = { - isVerified: true, - hasAffiliate: hasShapeshiftAffiliate, - affiliateBps: hasShapeshiftAffiliate && affiliateBps ? affiliateBps : undefined, - affiliateAddress: hasShapeshiftAffiliate ? this.shapeshiftThorchainAffiliate : undefined, - verifiedSellAmountCryptoBaseUnit, - actualBuyAmountCryptoBaseUnit: undefined, - actualAffiliateFeeAmountCryptoBaseUnit: undefined, - } - - logVerification(this.logger, SwapperName.Thorchain, swapId, result, { memo }) - - return result - } catch (error) { - this.logger.error(`Error verifying Thorchain for swap ${swapId}:`, error) - return { - isVerified: false, - hasAffiliate: false, - actualBuyAmountCryptoBaseUnit: undefined, - actualAffiliateFeeAmountCryptoBaseUnit: undefined, - error: error instanceof Error ? error.message : 'Failed to fetch Thorchain data from node', - } - } - } + console.log({ swap }) - private async verifyMaya(swap: Swap): Promise { const { swapId } = swap - const txHash = swap.sellTxHash || undefined - - if (!txHash) { - return { - isVerified: false, - hasAffiliate: false, - actualBuyAmountCryptoBaseUnit: undefined, - actualAffiliateFeeAmountCryptoBaseUnit: undefined, - error: 'Missing txHash for Maya verification', - } - } - - try { - // SECURITY: Query Maya node API to verify memo contains affiliate info - const txUrl = `${this.mayachainNodeUrl}/mayachain/tx/${txHash}` - this.logger.log(`Maya - Fetching tx from node API: ${txUrl}`) + const txHash = swap.sellTxHash?.replace(/^0x/, '') + if (!txHash) throw new Error('Missing txHash') - const response = await firstValueFrom(this.httpService.get(txUrl)) - - const observedTx = response.data?.observed_tx + const { data } = await firstValueFrom( + this.httpService.get(`${env.VITE_THORCHAIN_MIDGARD_URL}/actions?txid=${txHash}`), + ) - if (!observedTx || !observedTx.tx) { - return { - isVerified: false, - hasAffiliate: false, - actualBuyAmountCryptoBaseUnit: undefined, - actualAffiliateFeeAmountCryptoBaseUnit: undefined, - error: 'No observed transaction found', - } - } + const action = data.actions[0] + if (!action) throw new Error('No action found') - const memo: string | undefined = observedTx.tx.memo - if (!memo) { - return { - isVerified: false, - hasAffiliate: false, - actualBuyAmountCryptoBaseUnit: undefined, - actualAffiliateFeeAmountCryptoBaseUnit: undefined, - error: 'No memo found in transaction', - } - } + if (action.type !== 'swap') throw new Error('Invalid swap action') - // Parse memo format: =:r:maya1dz68dtlzrxnjflha9vvs7yt7p77mqdnf5yugww:131082237:ss:0 - // The affiliate code is after the 4th colon, followed by fee in bps - const memoPattern = new RegExp(`:${this.shapeshiftMayaAffiliate}:(\\d+)`, 'i') - const memoMatch = memo.match(memoPattern) + if (action.status === 'pending') { + // TODO: how to return here (we can't verify it yet, still pending) + } - const hasShapeshiftAffiliate = !!memoMatch - const affiliateBps = memoMatch ? parseInt(memoMatch[1]) : undefined + const swapMetadata = action.metadata.swap + if (!swapMetadata) throw new Error('No swap metadata found') - 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 affiliateAddress = swapMetadata.affiliateAddress + const affiliateBps = parseInt(swapMetadata.affiliateFee) + const memo = swapMetadata.memo + const hasAffiliate = affiliateAddress === 'ss' + const actualAffiliateFeeAmountCryptoBaseUnit = action.out.find((out) => out.affiliate)?.coins[0].amount - const result: SwapVerificationResult = { - isVerified: true, - hasAffiliate: hasShapeshiftAffiliate, - affiliateBps: hasShapeshiftAffiliate && affiliateBps ? affiliateBps : undefined, - affiliateAddress: hasShapeshiftAffiliate ? this.shapeshiftMayaAffiliate : undefined, - verifiedSellAmountCryptoBaseUnit, - actualBuyAmountCryptoBaseUnit: undefined, - actualAffiliateFeeAmountCryptoBaseUnit: undefined, - } + const result: SwapVerificationResult = { + isVerified: true, + hasAffiliate, + affiliateBps, + affiliateAddress, + verifiedSellAmountCryptoBaseUnit: thorchainToNativePrecision( + action.in[0].coins[0].amount, + swap.sellAsset.precision, + ), + actualBuyAmountCryptoBaseUnit: thorchainToNativePrecision( + action.out[action.out.length - 1].coins[0].amount, + swap.sellAsset.precision, + ), + actualAffiliateFeeAmountCryptoBaseUnit, + } - logVerification(this.logger, SwapperName.Mayachain, swapId, result, { memo }) + logVerification(this.logger, SwapperName.Thorchain, swapId, result, { memo }) - return result - } catch (error) { - this.logger.error(`Error verifying Maya for swap ${swapId}:`, error) - return { - isVerified: false, - hasAffiliate: false, - actualBuyAmountCryptoBaseUnit: undefined, - actualAffiliateFeeAmountCryptoBaseUnit: undefined, - error: error instanceof Error ? error.message : 'Failed to fetch Maya data from node', - } - } + return result } + //private async verifyMaya(swap: Swap): Promise { + // const { swapId } = swap + // const txHash = swap.sellTxHash || undefined + + // if (!txHash) { + // return { + // isVerified: false, + // hasAffiliate: false, + // actualBuyAmountCryptoBaseUnit: undefined, + // actualAffiliateFeeAmountCryptoBaseUnit: undefined, + // error: 'Missing txHash for Maya verification', + // } + // } + + // try { + // // SECURITY: Query Maya node API to verify memo contains affiliate info + // const txUrl = `${this.mayachainNodeUrl}/mayachain/tx/${txHash}` + + // this.logger.log(`Maya - Fetching tx from node API: ${txUrl}`) + + // const response = await firstValueFrom(this.httpService.get(txUrl)) + + // const observedTx = response.data?.observed_tx + + // if (!observedTx || !observedTx.tx) { + // return { + // isVerified: false, + // hasAffiliate: false, + // actualBuyAmountCryptoBaseUnit: undefined, + // actualAffiliateFeeAmountCryptoBaseUnit: undefined, + // error: 'No observed transaction found', + // } + // } + + // const memo: string | undefined = observedTx.tx.memo + // if (!memo) { + // return { + // isVerified: false, + // hasAffiliate: false, + // actualBuyAmountCryptoBaseUnit: undefined, + // actualAffiliateFeeAmountCryptoBaseUnit: undefined, + // error: 'No memo found in transaction', + // } + // } + + // // Parse memo format: =:r:maya1dz68dtlzrxnjflha9vvs7yt7p77mqdnf5yugww:131082237:ss:0 + // // The affiliate code is after the 4th colon, followed by fee in bps + // const memoPattern = new RegExp(`:${this.shapeshiftMayaAffiliate}:(\\d+)`, 'i') + // const memoMatch = memo.match(memoPattern) + + // const hasShapeshiftAffiliate = !!memoMatch + // const affiliateBps = memoMatch ? parseInt(memoMatch[1]) : undefined + + // 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 result: SwapVerificationResult = { + // isVerified: true, + // hasAffiliate: hasShapeshiftAffiliate, + // affiliateBps: hasShapeshiftAffiliate && affiliateBps ? affiliateBps : undefined, + // affiliateAddress: hasShapeshiftAffiliate ? this.shapeshiftMayaAffiliate : undefined, + // verifiedSellAmountCryptoBaseUnit, + // actualBuyAmountCryptoBaseUnit: undefined, + // actualAffiliateFeeAmountCryptoBaseUnit: undefined, + // } + + // logVerification(this.logger, SwapperName.Mayachain, swapId, result, { memo }) + + // return result + // } catch (error) { + // this.logger.error(`Error verifying Maya for swap ${swapId}:`, error) + // return { + // isVerified: false, + // hasAffiliate: false, + // actualBuyAmountCryptoBaseUnit: undefined, + // actualAffiliateFeeAmountCryptoBaseUnit: undefined, + // error: error instanceof Error ? error.message : 'Failed to fetch Maya data from node', + // } + // } + //} + private async verifyChainflip(swap: Swap): Promise { const { swapId } = swap const metadata = swap.metadata as Record diff --git a/apps/swap-service/src/verification/types.ts b/apps/swap-service/src/verification/types.ts index 75e3f52..69b5054 100644 --- a/apps/swap-service/src/verification/types.ts +++ b/apps/swap-service/src/verification/types.ts @@ -1,10 +1,51 @@ -export interface ThorchainMayaTxResponse { - observed_tx?: { - tx?: { - memo?: string - coins?: Array<{ amount?: string }> - } +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 { diff --git a/apps/swap-service/src/verification/utils.ts b/apps/swap-service/src/verification/utils.ts index b74d92d..acc53ba 100644 --- a/apps/swap-service/src/verification/utils.ts +++ b/apps/swap-service/src/verification/utils.ts @@ -1,17 +1,15 @@ import type { Logger } from '@nestjs/common' import type { SwapVerificationResult } from '@shapeshift/shared-types' +import { bnOrZero } from '@shapeshiftoss/chain-adapters' import type { SwapperName } from '@shapeshiftoss/swapper' 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) const BPS_DENOMINATOR = 10000n From 0e5eeef22bc31a23418e95b292b003a69fdcc290 Mon Sep 17 00:00:00 2001 From: kaladinlight <35275952+kaladinlight@users.noreply.github.com> Date: Fri, 8 May 2026 16:57:25 -0600 Subject: [PATCH 2/6] chore(swap-service): unblock jest from chain-adapters ESM transitive @shapeshiftoss/chain-adapters transitively imports p-queue (ESM-only), which Jest's CJS loader can't parse. The verification tests only need bnOrZero, so stub the package via bignumber.js. Also add the missing VITE_THORCHAIN_MIDGARD_URL env stub used by the Thorchain verifier. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/verification/__tests__/setup.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/apps/swap-service/src/verification/__tests__/setup.ts b/apps/swap-service/src/verification/__tests__/setup.ts index e6bf8d4..68dbc42 100644 --- a/apps/swap-service/src/verification/__tests__/setup.ts +++ b/apps/swap-service/src/verification/__tests__/setup.ts @@ -8,6 +8,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 +24,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 = require('bignumber.js') + return { + bnOrZero: (x: unknown) => { + const bn = new BigNumber(x as BigNumber.Value) + return bn.isFinite() ? bn : new BigNumber(0) + }, + } +}) + jest.mock('@shapeshiftoss/swapper', () => ({ SwapperName: { Thorchain: 'THORChain', From 076cc9652c9d25ead19ade0270ce5b27b3565ef5 Mon Sep 17 00:00:00 2001 From: kaladinlight <35275952+kaladinlight@users.noreply.github.com> Date: Fri, 8 May 2026 16:57:32 -0600 Subject: [PATCH 3/6] test(swap-service): use real Midgard data for Thorchain fixtures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the two synthetic Thornode fixtures (status/tx responses) with a single Midgard /actions response captured from a real ETH→USDC swap with ShapeShift affiliate, and update the matching Swap row fixture so verifier assertions reflect on-chain values rather than hand-rolled shapes. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../fixtures/thorchain/response.json | 76 ++++++++++++++++++ .../fixtures/thorchain/status-response.json | 78 ------------------- .../__tests__/fixtures/thorchain/swap.ts | 64 ++++++++------- .../fixtures/thorchain/tx-response.json | 34 -------- 4 files changed, 111 insertions(+), 141 deletions(-) create mode 100644 apps/swap-service/src/verification/__tests__/fixtures/thorchain/response.json delete mode 100644 apps/swap-service/src/verification/__tests__/fixtures/thorchain/status-response.json delete mode 100644 apps/swap-service/src/verification/__tests__/fixtures/thorchain/tx-response.json 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/status-response.json b/apps/swap-service/src/verification/__tests__/fixtures/thorchain/status-response.json deleted file mode 100644 index 44d468f..0000000 --- a/apps/swap-service/src/verification/__tests__/fixtures/thorchain/status-response.json +++ /dev/null @@ -1,78 +0,0 @@ -{ - "tx": { - "id": "0000000000000000000000000000000000000000000000000000000000000000", - "memo": "=:BTC.BTC:bc1qd5w0nndwnefa9uel2c2pqtj7tg6c4d2tlvyuvw:0:ss:30", - "chain": "ETH", - "from_address": "0xA44C286BA83Bb771cd0107B2c1Df678435Bd1535", - "to_address": "0x0000000000000000000000000000000000000000" - }, - "stages": { - "inbound_observed": { - "started": true, - "final_count": 2, - "pre_confirmation_count": 0, - "completed": true - }, - "inbound_confirmation_counted": { - "completed": true - }, - "inbound_finalised": { - "completed": true - }, - "swap_status": { - "pending": false - }, - "swap_finalised": { - "completed": true - }, - "outbound_signed": { - "completed": true - } - }, - "out_txs": [ - { - "id": "1111111111111111111111111111111111111111111111111111111111111111", - "chain": "BTC", - "from_address": "bc1qvault00000000000000000000000000000000", - "to_address": "bc1qd5w0nndwnefa9uel2c2pqtj7tg6c4d2tlvyuvw", - "coins": [ - { - "asset": "BTC.BTC", - "amount": "2000" - } - ], - "gas": [ - { - "asset": "BTC.BTC", - "amount": "100" - } - ], - "memo": "OUT:0000000000000000000000000000000000000000000000000000000000000000" - }, - { - "id": "2222222222222222222222222222222222222222222222222222222222222222", - "chain": "THOR", - "from_address": "thor1vault0000000000000000000000000000000000", - "to_address": "thor1ss0000000000000000000000000000000000000", - "coins": [ - { - "asset": "THOR.RUNE", - "amount": "1500" - } - ], - "gas": [], - "memo": "OUT:0000000000000000000000000000000000000000000000000000000000000000" - } - ], - "planned_out_txs": [ - { - "chain": "BTC", - "to_address": "bc1qd5w0nndwnefa9uel2c2pqtj7tg6c4d2tlvyuvw", - "coin": { - "asset": "BTC.BTC", - "amount": "2000" - }, - "refund": false - } - ] -} diff --git a/apps/swap-service/src/verification/__tests__/fixtures/thorchain/swap.ts b/apps/swap-service/src/verification/__tests__/fixtures/thorchain/swap.ts index ff356a9..54fceb8 100644 --- a/apps/swap-service/src/verification/__tests__/fixtures/thorchain/swap.ts +++ b/apps/swap-service/src/verification/__tests__/fixtures/thorchain/swap.ts @@ -3,7 +3,7 @@ import { SwapperName } from '@shapeshiftoss/swapper' import type { Swap } from '../../../../swaps/types' export default { - swapId: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee', + swapId: 'ca88e6ef-ca09-4848-98a6-1bd18e36fc81', sellAsset: { icon: 'https://rawcdn.githack.com/trustwallet/assets/32e51d582a890b3dd3135fe3ee7c20c2fd699a6d/blockchains/ethereum/info/logo.png', name: 'Ethereum', @@ -13,43 +13,49 @@ export default { 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://rawcdn.githack.com/trustwallet/assets/master/blockchains/bitcoin/info/logo.png', - name: 'Bitcoin', - color: '#FF9800', - symbol: 'BTC', - assetId: 'bip122:000000000019d6689c085ae165831e93/slip44:0', - chainId: 'bip122:000000000019d6689c085ae165831e93', - explorer: 'https://blockstream.info', - precision: 8, - networkName: 'Bitcoin', - explorerTxLink: 'https://blockstream.info/tx/', - relatedAssetKey: 'bip122:000000000019d6689c085ae165831e93/slip44:0', - explorerAddressLink: 'https://blockstream.info/address/', + 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: '1000000000000000', - expectedBuyAmountCryptoBaseUnit: '2000', + sellAmountCryptoBaseUnit: '3000000000000000', + expectedBuyAmountCryptoBaseUnit: '6643063.78', actualBuyAmountCryptoBaseUnit: null, - status: 'PENDING', + status: 'SUCCESS', source: 'THORChain', swapperName: SwapperName.Thorchain, sellAccountId: '3c70e97c6f86a5b5cfdf82dfd3380ac4ed3d8b89dabf86f631bdf739372926be', buyAccountId: null, - receiveAddress: 'bc1qd5w0nndwnefa9uel2c2pqtj7tg6c4d2tlvyuvw', - sellTxHash: '0x0000000000000000000000000000000000000000000000000000000000000000', - buyTxHash: null, + receiveAddress: '0xA44C286BA83Bb771cd0107B2c1Df678435Bd1535', + sellTxHash: '0x4a4ce957a047378c3f3ac57ca3cc3ab03e61814a7b415f493704d84a9e42d0eb', + buyTxHash: '0xE6CECF7727DA23DDC52605D728775E7E022935987548FF5E9E922FDFBC55F9FC', txLink: null, - statusMessage: null, + statusMessage: '', isStreaming: false, - createdAt: new Date('2026-04-30T12:00:00.000Z'), - updatedAt: new Date('2026-04-30T12:00:00.000Z'), + createdAt: new Date('2026-05-08T22:21:53.060Z'), + updatedAt: new Date('2026-05-08T22:23:25.138Z'), metadata: { - quoteId: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee', + quoteId: 'ca88e6ef-ca09-4848-98a6-1bd18e36fc81', stepIndex: 0, acrossTransactionMetadata: undefined, chainflipSwapId: undefined, @@ -58,19 +64,19 @@ export default { relayerTxHash: undefined, relayTransactionMetadata: undefined, streamingSwapMetadata: undefined, - nearIntentsSpecific: undefined, }, userId: 'api', referralCode: null, - sellAssetUsd: '2290', - buyAssetUsd: '95000', - affiliateAssetUsd: '2290', + sellAssetUsd: '2315.29', + buyAssetUsd: '0.999934', + affiliateAssetUsd: '2315.29', isAffiliateVerified: null, affiliateVerificationDetails: null, - affiliateAddress: null, - affiliateBps: 30, + 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__/fixtures/thorchain/tx-response.json b/apps/swap-service/src/verification/__tests__/fixtures/thorchain/tx-response.json deleted file mode 100644 index 3692465..0000000 --- a/apps/swap-service/src/verification/__tests__/fixtures/thorchain/tx-response.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "observed_tx": { - "tx": { - "id": "0000000000000000000000000000000000000000000000000000000000000000", - "chain": "ETH", - "from_address": "0xA44C286BA83Bb771cd0107B2c1Df678435Bd1535", - "to_address": "0x0000000000000000000000000000000000000000", - "coins": [ - { - "asset": "ETH.ETH", - "amount": "100000", - "decimals": 8 - } - ], - "gas": [ - { - "asset": "ETH.ETH", - "amount": "120000" - } - ], - "memo": "=:BTC.BTC:bc1qd5w0nndwnefa9uel2c2pqtj7tg6c4d2tlvyuvw:0:ss:30" - }, - "external_observed_height": 24980390, - "external_confirmation_delay_height": 24980392, - "signers": [], - "out_hashes": [ - "1111111111111111111111111111111111111111111111111111111111111111" - ], - "status": "done" - }, - "consensus_height": 100000000, - "finalised_height": 100000010, - "outbound_height": 100000020 -} From fce0ab4b00ad9435f3714546a539ad18b57f03a7 Mon Sep 17 00:00:00 2001 From: kaladinlight <35275952+kaladinlight@users.noreply.github.com> Date: Fri, 8 May 2026 16:57:41 -0600 Subject: [PATCH 4/6] feat(swap-service): harden Thorchain swap verification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Treat action.status === 'failed' as FAILED (was falling through to the success path and producing a bogus SUCCESS verification). - Select the buy out by matching the memo destination address (the on-chain trusted value) instead of relying on array position. - Require both affiliateAddress === 'ss' AND a real affiliate out for hasAffiliate=true; gate affiliateBps, affiliateAddress, and actualAffiliateFeeAmountCryptoBaseUnit on that flag so foreign affiliate data never lands in our settlement record. - Treat "no out matching memo destination" as FAILED rather than PENDING — by Midgard invariants, post-pending actions have fully populated outs, so a mismatch is definitive. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../verification/__tests__/thorchain.test.ts | 205 +++++++++--------- .../verification/swap-verification.service.ts | 27 ++- 2 files changed, 122 insertions(+), 110 deletions(-) diff --git a/apps/swap-service/src/verification/__tests__/thorchain.test.ts b/apps/swap-service/src/verification/__tests__/thorchain.test.ts index a1fc4f1..8a64d85 100644 --- a/apps/swap-service/src/verification/__tests__/thorchain.test.ts +++ b/apps/swap-service/src/verification/__tests__/thorchain.test.ts @@ -4,17 +4,13 @@ import { of, throwError } from 'rxjs' import type { Swap } from '../../swaps/types' import { SwapVerificationService } from '../swap-verification.service' -import thorchainStatusResponse from './fixtures/thorchain/status-response.json' +import thorchainResponse from './fixtures/thorchain/response.json' import thorchainSwap from './fixtures/thorchain/swap' -import thorchainTxResponse from './fixtures/thorchain/tx-response.json' const swap = thorchainSwap as unknown as Swap -const makeHttpMock = (txResponse: unknown, statusResponse: unknown): HttpService => { - const get = jest.fn().mockImplementation((url: string) => { - if (url.includes('/tx/status/')) return of({ data: statusResponse }) - return of({ data: txResponse }) - }) +const makeHttpMock = (response: unknown): HttpService => { + const get = jest.fn().mockReturnValue(of({ data: response })) return { get } as unknown as HttpService } @@ -25,167 +21,172 @@ describe('verifyThorchain', () => { jest.restoreAllMocks() }) - it('verifies a successful swap with shapeshift affiliate memo', async () => { - service = new SwapVerificationService(makeHttpMock(thorchainTxResponse, thorchainStatusResponse)) + it('verifies a successful swap with shapeshift affiliate', async () => { + service = new SwapVerificationService(makeHttpMock(thorchainResponse)) const result = await service.verifySwap(swap) expect(result).toMatchObject({ - isVerified: true, + verificationStatus: 'SUCCESS', hasAffiliate: true, - affiliateBps: 30, + affiliateBps: 60, affiliateAddress: 'ss', - verifiedSellAmountCryptoBaseUnit: '1000000000000000', - actualBuyAmountCryptoBaseUnit: '2000', - actualAffiliateFeeAmountCryptoBaseUnit: '3000000000000', + verifiedSellAmountCryptoBaseUnit: '3000000000000000', + actualBuyAmountCryptoBaseUnit: '6643738', + actualAffiliateFeeAmountCryptoBaseUnit: '6944500', }) }) - it('strips 0x prefix from sellTxHash before calling thornode', async () => { - const httpMock = makeHttpMock(thorchainTxResponse, thorchainStatusResponse) - service = new SwapVerificationService(httpMock) + 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 get = httpMock.get as unknown as jest.Mock - const urls = get.mock.calls.map(([url]: [string]) => url) - expect(urls.every((url: string) => !/\/(0x)/i.test(url))).toBe(true) - expect(urls.some((url: string) => url.includes('/thorchain/tx/'))).toBe(true) - expect(urls.some((url: string) => url.includes('/thorchain/tx/status/'))).toBe(true) + const url = get.mock.calls[0][0] + expect(url).toMatch(/\/actions\?txid=[0-9a-f]+$/i) + expect(url).not.toMatch(/=0x/i) }) - it('returns hasAffiliate=false when memo lacks the ss affiliate code', async () => { - const txResponse = structuredClone(thorchainTxResponse) - txResponse.observed_tx.tx.memo = '=:BTC.BTC:bc1qd5w0nndwnefa9uel2c2pqtj7tg6c4d2tlvyuvw:0:t:30' + 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(txResponse, thorchainStatusResponse)) + service = new SwapVerificationService(makeHttpMock(response)) const result = await service.verifySwap(swap) - expect(result.isVerified).toBe(true) + 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('parses bps when memo uses a streaming-swap limit (LIM/INTERVAL/QUANTITY)', async () => { - const txResponse = structuredClone(thorchainTxResponse) - txResponse.observed_tx.tx.memo = - '=:BTC.BTC:bc1qd5w0nndwnefa9uel2c2pqtj7tg6c4d2tlvyuvw:0/1/0:ss:30' + it('returns FAILED when sellTxHash is missing', async () => { + service = new SwapVerificationService(makeHttpMock(thorchainResponse)) - service = new SwapVerificationService(makeHttpMock(txResponse, thorchainStatusResponse)) + 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.hasAffiliate).toBe(true) - expect(result.affiliateBps).toBe(30) + expect(result.verificationStatus).toBe('PENDING') + expect(result.noAffiliateReason).toBe('No action found in Midgard') }) - it('sums multiple out_txs to the receiveAddress (streaming-swap split outbounds)', async () => { - const statusResponse = structuredClone(thorchainStatusResponse) - statusResponse.out_txs = [ - { - id: 'a', - chain: 'BTC', - from_address: 'bc1qvault00000000000000000000000000000000', - to_address: 'bc1qd5w0nndwnefa9uel2c2pqtj7tg6c4d2tlvyuvw', - coins: [{ asset: 'BTC.BTC', amount: '1200' }], - gas: [], - memo: 'OUT:...', - }, - { - id: 'b', - chain: 'BTC', - from_address: 'bc1qvault00000000000000000000000000000000', - to_address: 'bc1qd5w0nndwnefa9uel2c2pqtj7tg6c4d2tlvyuvw', - coins: [{ asset: 'BTC.BTC', amount: '800' }], - gas: [], - memo: 'OUT:...', - }, - ] - - service = new SwapVerificationService(makeHttpMock(thorchainTxResponse, statusResponse)) + 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.actualBuyAmountCryptoBaseUnit).toBe('2000') + expect(result.verificationStatus).toBe('PENDING') + expect(result.noAffiliateReason).toBe('Swap action still pending') }) - it('matches the receiveAddress case-insensitively', async () => { - const statusResponse = structuredClone(thorchainStatusResponse) - statusResponse.out_txs[0].to_address = 'BC1QD5W0NNDWNEFA9UEL2C2PQTJ7TG6C4D2TLVYUVW' + it('returns FAILED when the action type is not swap', async () => { + const response = structuredClone(thorchainResponse) + response.actions[0].type = 'addLiquidity' - service = new SwapVerificationService(makeHttpMock(thorchainTxResponse, statusResponse)) + service = new SwapVerificationService(makeHttpMock(response)) const result = await service.verifySwap(swap) - expect(result.actualBuyAmountCryptoBaseUnit).toBe('2000') + expect(result.verificationStatus).toBe('FAILED') + expect(result.noAffiliateReason).toBe('Invalid swap action type') }) - it('ignores out_txs that are not addressed to the user (e.g. RUNE affiliate outbound)', async () => { - const statusResponse = structuredClone(thorchainStatusResponse) - // Drop the BTC outbound, leave only the RUNE outbound to the affiliate. - statusResponse.out_txs = statusResponse.out_txs.filter( - (out: { chain?: string }) => out.chain !== 'BTC', - ) + 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(thorchainTxResponse, statusResponse)) + service = new SwapVerificationService(makeHttpMock(response)) const result = await service.verifySwap(swap) - expect(result.actualBuyAmountCryptoBaseUnit).toBeUndefined() + expect(result.verificationStatus).toBe('FAILED') + expect(result.noAffiliateReason).toBe('No swap metadata found') }) - it('leaves actualBuyAmountCryptoBaseUnit undefined when out_txs is missing', async () => { - const statusResponse = structuredClone(thorchainStatusResponse) as Partial< - typeof thorchainStatusResponse - > - delete statusResponse.out_txs + 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(thorchainTxResponse, statusResponse)) + service = new SwapVerificationService(makeHttpMock(response)) const result = await service.verifySwap(swap) - expect(result.actualBuyAmountCryptoBaseUnit).toBeUndefined() + expect(result.actualBuyAmountCryptoBaseUnit).toBe('6643738') }) - it('returns unverified when sellTxHash is missing', async () => { - service = new SwapVerificationService(makeHttpMock(thorchainTxResponse, thorchainStatusResponse)) + 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' }, + ) - const result = await service.verifySwap({ ...swap, sellTxHash: null } as Swap) + service = new SwapVerificationService(makeHttpMock(response)) - expect(result).toMatchObject({ - isVerified: false, - hasAffiliate: false, - error: 'Missing txHash for Thorchain verification', - }) + const result = await service.verifySwap(swap) + + expect(result.verificationStatus).toBe('FAILED') + expect(result.noAffiliateReason).toBe('No outbound matching memo destination') }) - it('returns unverified when observed_tx is missing', async () => { - service = new SwapVerificationService(makeHttpMock({}, thorchainStatusResponse)) + 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.isVerified).toBe(false) - expect(result.error).toBe('No observed transaction found') + expect(result.verificationStatus).toBe('FAILED') + expect(result.noAffiliateReason).toBe('Swap action failed on Thorchain') }) - it('returns unverified when memo is missing', async () => { - const txResponse = structuredClone(thorchainTxResponse) as { - observed_tx: { tx: Partial<(typeof thorchainTxResponse)['observed_tx']['tx']> } - } - delete txResponse.observed_tx.tx.memo + 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(txResponse, thorchainStatusResponse)) + service = new SwapVerificationService(makeHttpMock(response)) const result = await service.verifySwap(swap) - expect(result.isVerified).toBe(false) - expect(result.error).toBe('No memo found in transaction') + expect(result.verificationStatus).toBe('FAILED') + expect(result.noAffiliateReason).toBe('Could not parse destination address from memo') }) - it('returns unverified when the HTTP call fails', async () => { + 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 @@ -194,7 +195,7 @@ describe('verifyThorchain', () => { const result = await service.verifySwap(swap) - expect(result.isVerified).toBe(false) - expect(result.error).toBe('upstream 500') + 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 0d23175..0554b2d 100644 --- a/apps/swap-service/src/verification/swap-verification.service.ts +++ b/apps/swap-service/src/verification/swap-verification.service.ts @@ -341,26 +341,37 @@ export class SwapVerificationService { 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 swapMetadata = action.metadata.swap if (!swapMetadata) return noAffiliateResult('FAILED', 'No swap metadata found') const affiliateAddress = swapMetadata.affiliateAddress + // 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') + + const buyOut = action.out.find( + (out) => !out.affiliate && out.address.toLowerCase() === destinationAddress.toLowerCase(), + ) + if (!buyOut) return noAffiliateResult('FAILED', 'No outbound matching memo destination') + + const feeOut = action.out.find((out) => out.affiliate) + const hasAffiliate = affiliateAddress === 'ss' && !!feeOut + return { verificationStatus: 'SUCCESS', - hasAffiliate: affiliateAddress === 'ss', - affiliateBps: parseInt(swapMetadata.affiliateFee), - affiliateAddress, + hasAffiliate, + affiliateBps: hasAffiliate ? parseInt(swapMetadata.affiliateFee) : undefined, + affiliateAddress: hasAffiliate ? affiliateAddress : undefined, verifiedSellAmountCryptoBaseUnit: thorchainToNativePrecision( action.in[0].coins[0].amount, swap.sellAsset.precision, ), - actualBuyAmountCryptoBaseUnit: thorchainToNativePrecision( - action.out[action.out.length - 1].coins[0].amount, - swap.buyAsset.precision, - ), - actualAffiliateFeeAmountCryptoBaseUnit: action.out.find((out) => out.affiliate)?.coins[0].amount, + actualBuyAmountCryptoBaseUnit: thorchainToNativePrecision(buyOut.coins[0].amount, swap.buyAsset.precision), + actualAffiliateFeeAmountCryptoBaseUnit: hasAffiliate ? feeOut?.coins[0].amount : undefined, } } From 598a2e2358909e0bb795c4a8dc40b507c608922a Mon Sep 17 00:00:00 2001 From: kaladinlight <35275952+kaladinlight@users.noreply.github.com> Date: Fri, 8 May 2026 16:57:46 -0600 Subject: [PATCH 5/6] fix(swap-service): pay Thorchain affiliate fees in RUNE Thorchain settles affiliate fees in RUNE regardless of the swap legs, so the previous 'sell_asset' strategy was wrong. Generalize the fee strategy to allow a fixed AssetId (RUNE here) and use null in place of the sentinel string for swappers with no affiliate fee asset. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/swap-service/src/utils/affiliateFeeAsset.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) 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 } } From 678992696a56599b179a9d168d6b2b378b38816d Mon Sep 17 00:00:00 2001 From: kaladinlight <35275952+kaladinlight@users.noreply.github.com> Date: Fri, 8 May 2026 21:43:32 -0600 Subject: [PATCH 6/6] chore(swap-service): satisfy lint in chain-adapters jest mock Use jest.requireActual with a top-level type-only import for the BigNumber type, replacing bare require() and inline import() type annotations that tripped @typescript-eslint/no-require-imports and consistent-type-imports. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/swap-service/src/verification/__tests__/setup.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/swap-service/src/verification/__tests__/setup.ts b/apps/swap-service/src/verification/__tests__/setup.ts index 68dbc42..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) @@ -27,10 +28,10 @@ jest.mock('../../utils/pricing', () => ({ // 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 = require('bignumber.js') + const BigNumber = jest.requireActual('bignumber.js') return { bnOrZero: (x: unknown) => { - const bn = new BigNumber(x as BigNumber.Value) + const bn = new BigNumber(x as BigNumberJs.Value) return bn.isFinite() ? bn : new BigNumber(0) }, }