diff --git a/apps/swap-service/src/polling/swap-polling.service.ts b/apps/swap-service/src/polling/swap-polling.service.ts index 07ef0e9..29d0ac0 100644 --- a/apps/swap-service/src/polling/swap-polling.service.ts +++ b/apps/swap-service/src/polling/swap-polling.service.ts @@ -118,7 +118,7 @@ export class SwapPollingService { private async pollAttribution(swap: Swap): Promise { try { - const updated = await this.swapsService.checkQuoteBinding(swap) + const updated = await this.swapsService.resolveAttribution(swap) if (updated.attributionStatus !== swap.attributionStatus) { this.logger.log( `Attribution resolved for swap ${swap.swapId}: ${updated.attributionStatus} ${JSON.stringify(updated.attributionDetails)}`, diff --git a/apps/swap-service/src/swaps/__tests__/attribution-contest.test.ts b/apps/swap-service/src/swaps/__tests__/attribution-contest.test.ts new file mode 100644 index 0000000..0c413d2 --- /dev/null +++ b/apps/swap-service/src/swaps/__tests__/attribution-contest.test.ts @@ -0,0 +1,138 @@ +import { SwapsService } from '../swaps.service' +import type { Swap } from '../types' + +// unchained-client is ESM; ts-jest can't transform it and nothing here uses it. +jest.mock('@shapeshiftoss/unchained-client', () => ({ TxStatus: {} })) + +// contracts reaches lodash-es, which is ESM for the same reason, and only the block time lookup needs it. +jest.mock('@shapeshiftoss/contracts', () => ({ viemClientByChainId: {} })) + +process.env.NOTIFICATIONS_SERVICE_URL ??= 'http://notifications.test' +process.env.USER_SERVICE_URL ??= 'http://user.test' +process.env.SERVICE_API_KEY ??= 'test-api-key' + +const TX = '0xsharedtx' +const BLOCK_TIME = Math.floor(Date.UTC(2026, 8, 7, 15, 13, 56) / 1000) + +type Claim = { swapId: string; quotedAt: Date } + +const buildService = (claims: Claim[]) => { + const updates: { swapId: string; data: Record }[] = [] + const sweeps: { where: Record; data: Record }[] = [] + + const prisma = { + swap: { + // mirrors the real predicate: an older quote, or the same quote time with a lower swapId + findFirst: (args: { + where: { + sellAsset: { path: string[]; equals: string } + OR: [{ quotedAt: { lt: Date } }, { quotedAt: Date; swapId: { lt: string } }] + } + }): Promise<{ swapId: string } | null> => { + const [older, tied] = args.where.OR + const claim = claims.find( + (c) => + c.quotedAt < older.quotedAt.lt || + (c.quotedAt.getTime() === tied.quotedAt.getTime() && c.swapId < tied.swapId.lt), + ) + return Promise.resolve(claim ? { swapId: claim.swapId } : null) + }, + update: (args: { where: { swapId: string }; data: Record }) => { + updates.push({ swapId: args.where.swapId, data: args.data }) + return Promise.resolve({ ...args.data, swapId: args.where.swapId }) + }, + updateMany: (args: { where: Record; data: Record }) => { + sweeps.push(args) + return Promise.resolve({ count: 0 }) + }, + }, + } + + const blockTime = { lookup: () => Promise.resolve({ blockTime: BLOCK_TIME }) } + const stub = {} as never + const service = new SwapsService( + prisma as never, + stub, + stub, + stub, + stub, + stub, + stub, + stub, + stub, + stub, + stub, + blockTime as never, + ) + + return { service, updates, sweeps } +} + +const swapOf = (claim: Claim): Swap => + ({ + swapId: claim.swapId, + sellTxHash: TX, + sellAsset: { chainId: 'eip155:1' }, + quotedAt: claim.quotedAt, + status: 'SUCCESS', + createdAt: claim.quotedAt, + attributionStatus: 'PENDING', + attributionDetails: null, + }) as unknown as Swap + +describe('resolveAttribution contest', () => { + const first = { swapId: 'first', quotedAt: new Date(Date.UTC(2026, 8, 7, 14, 14, 57)) } + const second = { swapId: 'second', quotedAt: new Date(Date.UTC(2026, 8, 7, 14, 26, 12)) } + + // both quotes precede the block, so binding accepts each one on its own + it('accepts the oldest claim on a transaction', async () => { + const { service, updates } = buildService([first, second]) + + await service.resolveAttribution(swapOf(first)) + + expect(updates[0]?.data).toMatchObject({ + attributionStatus: 'ACCEPTED', + attributionDetails: { reason: 'quote-precedes-tx' }, + }) + }) + + it('disputes a later claim on a transaction another quote already holds', async () => { + const { service, updates } = buildService([first, second]) + + await service.resolveAttribution(swapOf(second)) + + expect(updates[0]?.data).toMatchObject({ + attributionStatus: 'DISPUTED', + attributionDetails: { checked: true, reason: 'quote-superseded' }, + }) + }) + + // a younger claim can be accepted before its older sibling is even registered + it('demotes a claim already accepted on the transaction when the oldest arrives', async () => { + const { service, updates, sweeps } = buildService([first, second]) + + await service.resolveAttribution(swapOf(first)) + + expect(updates[0]?.data).toMatchObject({ attributionStatus: 'ACCEPTED' }) + expect(sweeps[0]).toMatchObject({ + where: { sellTxHash: TX, swapId: { not: 'first' }, attributionStatus: 'ACCEPTED' }, + data: { attributionStatus: 'DISPUTED', attributionDetails: { reason: 'quote-superseded' } }, + }) + }) + + it('leaves other claims alone when this one loses', async () => { + const { service, sweeps } = buildService([first, second]) + + await service.resolveAttribution(swapOf(second)) + + expect(sweeps).toHaveLength(0) + }) + + it('accepts an uncontested claim', async () => { + const { service, updates } = buildService([second]) + + await service.resolveAttribution(swapOf(second)) + + expect(updates[0]?.data).toMatchObject({ attributionStatus: 'ACCEPTED' }) + }) +}) diff --git a/apps/swap-service/src/swaps/__tests__/utils.test.ts b/apps/swap-service/src/swaps/__tests__/utils.test.ts index 0d37b35..b3f32fe 100644 --- a/apps/swap-service/src/swaps/__tests__/utils.test.ts +++ b/apps/swap-service/src/swaps/__tests__/utils.test.ts @@ -3,7 +3,7 @@ import { Logger } from '@nestjs/common' import { mayachainAssetId } from '@shapeshiftoss/caip' import type { Swap } from '../types' -import { calculateFeeForSwap, describeError, resolveQuoteBinding, resolveStalledSwap } from '../utils' +import { calculateFeeForSwap, describeError, resolveAttributionFromChain, resolveStalledSwap } from '../utils' // Minimal swap shape exercising calculateFeeForSwap's fee/volume math. CACAO fee asset so a stored // '0' fee amount resolves to actualFeeUsd = 0 (the real 0-bps case this branch introduced). @@ -165,7 +165,7 @@ describe('resolveStalledSwap', () => { }) }) -describe('resolveQuoteBinding', () => { +describe('resolveAttributionFromChain', () => { const blockTime = Date.UTC(2026, 8, 1, 12, 0, 0) const found = { blockTime: blockTime / 1000 } as const const at = (offsetMs: number) => new Date(blockTime + offsetMs) @@ -174,7 +174,7 @@ describe('resolveQuoteBinding', () => { const justFailed = { status: 'FAILED' as const, createdAt: new Date() } it('accepts a quote minted before its transaction was mined', () => { - const { status, details } = resolveQuoteBinding(found, at(-60_000), live) + const { status, details } = resolveAttributionFromChain(found, at(-60_000), live) expect(status).toBe('ACCEPTED') expect(details).toMatchObject({ checked: true, reason: 'quote-precedes-tx', blockTime }) @@ -182,19 +182,19 @@ describe('resolveQuoteBinding', () => { // the harvest attack: the txid cannot be known until it exists, so the claim's quote is younger it('rejects a quote minted after its transaction was mined', () => { - const { status, details } = resolveQuoteBinding(found, at(1000), live) + const { status, details } = resolveAttributionFromChain(found, at(1000), live) expect(status).toBe('REJECTED') expect(details).toMatchObject({ checked: true, reason: 'quote-postdates-tx' }) }) it('accepts a quote minted in the same instant as its block', () => { - expect(resolveQuoteBinding(found, at(0), live).status).toBe('ACCEPTED') + expect(resolveAttributionFromChain(found, at(0), live).status).toBe('ACCEPTED') }) // the block timestamp is the whole boundary now - a second past it is a rejection like any other it('rejects a quote that postdates its block by a single second', () => { - expect(resolveQuoteBinding(found, at(1000), live).status).toBe('REJECTED') + expect(resolveAttributionFromChain(found, at(1000), live).status).toBe('REJECTED') }) // absence of evidence is never evidence - none of these may reject @@ -204,7 +204,7 @@ describe('resolveQuoteBinding', () => { ['a transaction still in the mempool', { unavailable: 'unmined' } as const, 'unmined'], ['a failed lookup', { unavailable: 'error' } as const, 'error'], ])('holds on %s rather than deciding', (_label, lookup, reason) => { - const { status, details } = resolveQuoteBinding(lookup, at(-60_000), live) + const { status, details } = resolveAttributionFromChain(lookup, at(-60_000), live) expect(status).toBe('PENDING') expect(details).toMatchObject({ checked: false, reason }) @@ -212,7 +212,7 @@ describe('resolveQuoteBinding', () => { // holding forever would be a lie; the reason states what was observed, not why it('rejects an unfindable claim once the swap itself has failed', () => { - expect(resolveQuoteBinding({ unavailable: 'not-found' }, at(-60_000), abandoned)).toEqual({ + expect(resolveAttributionFromChain({ unavailable: 'not-found' }, at(-60_000), abandoned)).toEqual({ status: 'REJECTED', details: { checked: true, reason: 'tx-not-found' }, }) @@ -220,23 +220,23 @@ describe('resolveQuoteBinding', () => { // a node briefly behind also reports not-found, and a rejection cannot be walked back it('holds an unfindable claim while the failed swap is still young', () => { - expect(resolveQuoteBinding({ unavailable: 'not-found' }, at(-60_000), justFailed).status).toBe('PENDING') + expect(resolveAttributionFromChain({ unavailable: 'not-found' }, at(-60_000), justFailed).status).toBe('PENDING') }) // only the chain disowning the transaction can reject; a stuck one still exists and may yet mine it('still holds a failed swap that was not answered with a denial', () => { - expect(resolveQuoteBinding({ unavailable: 'error' }, at(-60_000), abandoned).status).toBe('PENDING') - expect(resolveQuoteBinding({ unavailable: 'unsupported' }, at(-60_000), abandoned).status).toBe('PENDING') - expect(resolveQuoteBinding({ unavailable: 'unmined' }, at(-60_000), abandoned).status).toBe('PENDING') + expect(resolveAttributionFromChain({ unavailable: 'error' }, at(-60_000), abandoned).status).toBe('PENDING') + expect(resolveAttributionFromChain({ unavailable: 'unsupported' }, at(-60_000), abandoned).status).toBe('PENDING') + expect(resolveAttributionFromChain({ unavailable: 'unmined' }, at(-60_000), abandoned).status).toBe('PENDING') }) // a failed swap whose transaction is real is still attributable, it simply cannot be paid it('resolves a failed swap normally when its transaction exists', () => { - expect(resolveQuoteBinding(found, at(-60_000), abandoned).status).toBe('ACCEPTED') + expect(resolveAttributionFromChain(found, at(-60_000), abandoned).status).toBe('ACCEPTED') }) it('holds a row with no quote time, which cannot be checked at all', () => { - expect(resolveQuoteBinding(found, null, live)).toMatchObject({ + expect(resolveAttributionFromChain(found, null, live)).toMatchObject({ status: 'PENDING', details: { checked: false, reason: 'no-quoted-at' }, }) diff --git a/apps/swap-service/src/swaps/swaps.service.ts b/apps/swap-service/src/swaps/swaps.service.ts index 3360268..a8cf45b 100644 --- a/apps/swap-service/src/swaps/swaps.service.ts +++ b/apps/swap-service/src/swaps/swaps.service.ts @@ -33,6 +33,7 @@ import type { AffiliateVerificationDetails, AggregateFeesParams, AttributionDetails, + AttributionVerdict, FeeTotals, PaginatedSwaps, Swap, @@ -44,7 +45,7 @@ import { computeSellAmountUsd, describeError, fetchUsdPrices, - resolveQuoteBinding, + resolveAttributionFromChain, resolveStalledSwap, toQuotedAt, toSwap, @@ -297,32 +298,89 @@ export class SwapsService { return swaps.map(toSwap) } - async checkQuoteBinding(swap: Swap): Promise { + async resolveAttribution(swap: Swap): Promise { if (!swap.sellTxHash) return swap const lookup = await this.blockTimeService.lookup(swap.sellAsset.chainId, swap.sellTxHash) - const { status, details } = resolveQuoteBinding(lookup, swap.quotedAt, { + const chainVerdict = resolveAttributionFromChain(lookup, swap.quotedAt, { status: swap.status as SwapStatus, createdAt: swap.createdAt, }) - const previous = swap.attributionDetails as AttributionDetails | null - const unchanged = status === swap.attributionStatus && details.reason === previous?.reason + const superseded = chainVerdict.status === 'ACCEPTED' && (await this.hasPrecedingQuote(swap)) + const attribution: AttributionVerdict = superseded + ? { status: 'DISPUTED', details: { ...chainVerdict.details, reason: 'quote-superseded' } } + : chainVerdict - if (unchanged) return swap + // a repeat of the same verdict would only churn updatedAt, so only the reason is compared + const recorded = swap.attributionDetails as AttributionDetails | null + const sameVerdict = attribution.status === swap.attributionStatus && attribution.details.reason === recorded?.reason + + if (sameVerdict) return swap + + if (attribution.status === 'ACCEPTED') await this.disputeSupersededQuotes(swap) return toSwap( await this.prisma.swap.update({ where: { swapId: swap.swapId }, data: { - attributionStatus: status, - attributionDetails: details, - ...(status !== 'PENDING' && { attributionResolvedAt: new Date() }), + attributionStatus: attribution.status, + attributionDetails: attribution.details, + ...(attribution.status !== 'PENDING' && { attributionResolvedAt: new Date() }), }, }), ) } + // a pre-EIP-155 transaction carries the same hash on every EVM chain, so a quote only contends with + // quotes on its own chain; ties break on swapId so the winner is stable + private async findPrecedingQuote(quote: { + sellTxHash: string + chainId: string + quotedAt: Date + swapId: string + }): Promise { + const preceding = await this.prisma.swap.findFirst({ + where: { + sellTxHash: quote.sellTxHash, + sellAsset: { path: ['chainId'], equals: quote.chainId }, + OR: [{ quotedAt: { lt: quote.quotedAt } }, { quotedAt: quote.quotedAt, swapId: { lt: quote.swapId } }], + }, + select: { swapId: true }, + }) + + return preceding?.swapId ?? null + } + + private async hasPrecedingQuote(swap: Swap): Promise { + if (!swap.sellTxHash || !swap.quotedAt) return false + + const preceding = await this.findPrecedingQuote({ + sellTxHash: swap.sellTxHash, + chainId: swap.sellAsset.chainId, + quotedAt: swap.quotedAt, + swapId: swap.swapId, + }) + + return preceding !== null + } + + private async disputeSupersededQuotes(swap: Swap): Promise { + await this.prisma.swap.updateMany({ + where: { + sellTxHash: swap.sellTxHash, + sellAsset: { path: ['chainId'], equals: swap.sellAsset.chainId }, + swapId: { not: swap.swapId }, + attributionStatus: 'ACCEPTED', + }, + data: { + attributionStatus: 'DISPUTED', + attributionDetails: { checked: true, reason: 'quote-superseded' }, + attributionResolvedAt: new Date(), + }, + }) + } + async getPendingVerificationSwaps(): Promise { const swaps = await this.prisma.swap.findMany({ where: { diff --git a/apps/swap-service/src/swaps/types.ts b/apps/swap-service/src/swaps/types.ts index 6b18a74..c682fd9 100644 --- a/apps/swap-service/src/swaps/types.ts +++ b/apps/swap-service/src/swaps/types.ts @@ -1,4 +1,4 @@ -import type { Prisma, Swap as PrismaSwap } from '@prisma/client' +import type { AttributionStatus, Prisma, Swap as PrismaSwap } from '@prisma/client' import { Type } from 'class-transformer' import { IsInt, IsOptional, IsString, Max, Min } from 'class-validator' @@ -23,6 +23,11 @@ export type AttributionDetails = { quotedAt?: number } +export type AttributionVerdict = { + status: AttributionStatus + details: AttributionDetails +} + export type AffiliateVerificationDetails = { affiliateBps?: number affiliateAddress?: string diff --git a/apps/swap-service/src/swaps/utils.ts b/apps/swap-service/src/swaps/utils.ts index 86c139f..7684390 100644 --- a/apps/swap-service/src/swaps/utils.ts +++ b/apps/swap-service/src/swaps/utils.ts @@ -1,5 +1,5 @@ import { Logger } from '@nestjs/common' -import type { AttributionStatus, Swap as PrismaSwap } from '@prisma/client' +import type { Swap as PrismaSwap } from '@prisma/client' import axios from 'axios' import type { CreateSwapDto, SwapStatus } from '@shapeshift/shared-types' @@ -13,7 +13,7 @@ import type { BlockTimeLookup } from '../lib/block-time.service' import { getAssetPriceUsd } from '../utils/pricing' import { PENDING_TIMEOUT_MS } from './constants' -import type { AffiliateVerificationDetails, AttributionDetails, StatusNotification, Swap, UsdPrices } from './types' +import type { AffiliateVerificationDetails, AttributionVerdict, StatusNotification, Swap, UsdPrices } from './types' const logger = new Logger('SwapsService') @@ -60,11 +60,11 @@ export const resolveStalledSwap = ( } } -export const resolveQuoteBinding = ( +export const resolveAttributionFromChain = ( lookup: BlockTimeLookup, quotedAt: Date | null, swap: { status: SwapStatus; createdAt: Date }, -): { status: AttributionStatus; details: AttributionDetails } => { +): AttributionVerdict => { if (!quotedAt) return { status: 'PENDING', details: { checked: false, reason: 'no-quoted-at' } } if ('unavailable' in lookup) { diff --git a/scripts/affiliate-payouts/utils.ts b/scripts/affiliate-payouts/utils.ts index 649672c..6cec146 100644 --- a/scripts/affiliate-payouts/utils.ts +++ b/scripts/affiliate-payouts/utils.ts @@ -82,6 +82,7 @@ export function checkFeeAnomaly( // Accrue each partner's fee share from the verified on-chain fee, using the injected swap-service // fee math. Rows are the window query — swap status SUCCESS with a partner code, in any // verification state — and each falls into exactly one bucket, tested top to bottom: +// attributionStatus not ACCEPTED → unattributed (this transaction is not theirs to be paid for) // verificationStatus PENDING → unverified 'pending' (not verified yet; may still settle) // verificationStatus FAILED → unverified 'failed' (verification failed; investigate) // verified, no affiliate fee → noAffiliateFee (not ours, or ours @ 0 bps) @@ -111,7 +112,7 @@ export function aggregateByPartner( const partnerCode = row.partnerCode.toLowerCase() - // a claim that has not been settled in this partner's favour is not payable, whatever else is true + // a transaction not attributed to this partner is not theirs to be paid for, whatever else is true if (row.attributionStatus !== 'ACCEPTED') { unattributed.push({ swapId: row.swapId, @@ -286,7 +287,7 @@ export function buildRecord(input: { type: 'unattributed' as const, partnerCode: u.partnerCode, swapId: u.swapId, - reason: `attribution ${u.status.toLowerCase()}${u.reason ? ` (${u.reason})` : ''} — not paid, this transaction's claim is unsettled`, + reason: `attribution ${u.status.toLowerCase()}${u.reason ? ` (${u.reason})` : ''} — not paid, this transaction is not attributed to them`, })), ...unverified.map((u) => ({ type: 'unverified' as const,