Skip to content
2 changes: 1 addition & 1 deletion apps/swap-service/src/polling/swap-polling.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ export class SwapPollingService {

private async pollAttribution(swap: Swap): Promise<void> {
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)}`,
Expand Down
138 changes: 138 additions & 0 deletions apps/swap-service/src/swaps/__tests__/attribution-contest.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> }[] = []
const sweeps: { where: Record<string, unknown>; data: Record<string, unknown> }[] = []

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<string, unknown> }) => {
updates.push({ swapId: args.where.swapId, data: args.data })
return Promise.resolve({ ...args.data, swapId: args.where.swapId })
},
updateMany: (args: { where: Record<string, unknown>; data: Record<string, unknown> }) => {
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' })
})
})
28 changes: 14 additions & 14 deletions apps/swap-service/src/swaps/__tests__/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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)
Expand All @@ -174,27 +174,27 @@ 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 })
})

// 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
Expand All @@ -204,39 +204,39 @@ 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 })
})

// 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' },
})
})

// 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' },
})
Expand Down
76 changes: 67 additions & 9 deletions apps/swap-service/src/swaps/swaps.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import type {
AffiliateVerificationDetails,
AggregateFeesParams,
AttributionDetails,
AttributionVerdict,
FeeTotals,
PaginatedSwaps,
Swap,
Expand All @@ -44,7 +45,7 @@ import {
computeSellAmountUsd,
describeError,
fetchUsdPrices,
resolveQuoteBinding,
resolveAttributionFromChain,
resolveStalledSwap,
toQuotedAt,
toSwap,
Expand Down Expand Up @@ -297,32 +298,89 @@ export class SwapsService {
return swaps.map(toSwap)
}

async checkQuoteBinding(swap: Swap): Promise<Swap> {
async resolveAttribution(swap: Swap): Promise<Swap> {
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)
Comment thread
coderabbitai[bot] marked this conversation as resolved.

return toSwap(
await this.prisma.swap.update({
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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<string | null> {
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<boolean> {
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<void> {
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<Swap[]> {
const swaps = await this.prisma.swap.findMany({
where: {
Expand Down
7 changes: 6 additions & 1 deletion apps/swap-service/src/swaps/types.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand All @@ -23,6 +23,11 @@ export type AttributionDetails = {
quotedAt?: number
}

export type AttributionVerdict = {
status: AttributionStatus
details: AttributionDetails
}

export type AffiliateVerificationDetails = {
affiliateBps?: number
affiliateAddress?: string
Expand Down
Loading
Loading