From e1da1fa26fd2b7e1216fd2132b1ced531c68dbb3 Mon Sep 17 00:00:00 2001 From: kaladinlight <35275952+kaladinlight@users.noreply.github.com> Date: Wed, 9 Sep 2026 12:04:19 -0600 Subject: [PATCH 1/3] fix(swap-service): drop the block time tolerance The window was sized on the reasoning that it cost nothing, which was wrong: it is exactly how long a second quote has to bind cleanly to a transaction someone already claimed. No row ever used it - all 28 accepted swaps in production read quote-precedes-tx - because backfilled rows carry their own headroom and a real quote precedes its broadcast. A rejection close enough to be miner clock skew is logged instead, so the case that motivated the tolerance is visible if it ever happens. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P36At9muenEn9Uqzjo1rDw --- apps/swap-service/src/swaps/__tests__/utils.test.ts | 12 ++++-------- apps/swap-service/src/swaps/constants.ts | 4 +++- apps/swap-service/src/swaps/swaps.service.ts | 10 +++++++++- apps/swap-service/src/swaps/utils.ts | 10 ++-------- 4 files changed, 18 insertions(+), 18 deletions(-) diff --git a/apps/swap-service/src/swaps/__tests__/utils.test.ts b/apps/swap-service/src/swaps/__tests__/utils.test.ts index 60d6370..0d37b35 100644 --- a/apps/swap-service/src/swaps/__tests__/utils.test.ts +++ b/apps/swap-service/src/swaps/__tests__/utils.test.ts @@ -2,7 +2,6 @@ import { Logger } from '@nestjs/common' import { mayachainAssetId } from '@shapeshiftoss/caip' -import { BLOCK_TIME_TOLERANCE_MS } from '../constants' import type { Swap } from '../types' import { calculateFeeForSwap, describeError, resolveQuoteBinding, resolveStalledSwap } from '../utils' @@ -183,7 +182,7 @@ 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(BLOCK_TIME_TOLERANCE_MS + 1000), live) + const { status, details } = resolveQuoteBinding(found, at(1000), live) expect(status).toBe('REJECTED') expect(details).toMatchObject({ checked: true, reason: 'quote-postdates-tx' }) @@ -193,12 +192,9 @@ describe('resolveQuoteBinding', () => { expect(resolveQuoteBinding(found, at(0), live).status).toBe('ACCEPTED') }) - // a block declaring a time behind the broadcast that filled it must not cost an honest quote - it('accepts a quote the block only appears to predate, and says that is what happened', () => { - const { status, details } = resolveQuoteBinding(found, at(BLOCK_TIME_TOLERANCE_MS), live) - - expect(status).toBe('ACCEPTED') - expect(details).toMatchObject({ checked: true, reason: 'quote-within-tolerance' }) + // 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') }) // absence of evidence is never evidence - none of these may reject diff --git a/apps/swap-service/src/swaps/constants.ts b/apps/swap-service/src/swaps/constants.ts index 10821f0..aeb97d0 100644 --- a/apps/swap-service/src/swaps/constants.ts +++ b/apps/swap-service/src/swaps/constants.ts @@ -2,4 +2,6 @@ export const SHAPESHIFT_BPS = 10 export const REFERRER_FEE_RATE = 0.1 export const PENDING_TIMEOUT_MS = 24 * 60 * 60 * 1000 export const ATTRIBUTION_BATCH_SIZE = 200 -export const BLOCK_TIME_TOLERANCE_MS = 30 * 60 * 1000 +// a quote only just postdating its block may be miner clock skew rather than a harvested transaction, +// so rejections inside this margin are logged for review - it never changes the verdict +export const SKEW_REVIEW_MS = 30 * 60 * 1000 diff --git a/apps/swap-service/src/swaps/swaps.service.ts b/apps/swap-service/src/swaps/swaps.service.ts index 3360268..7a6769d 100644 --- a/apps/swap-service/src/swaps/swaps.service.ts +++ b/apps/swap-service/src/swaps/swaps.service.ts @@ -27,7 +27,7 @@ import { resolveAffiliateFeeAssetId } from '../utils/affiliateFeeAsset' import { getNextCursor, swapCursorArgs } from '../utils/pagination' import { SwapVerificationService } from '../verification/swap-verification.service' -import { ATTRIBUTION_BATCH_SIZE, REFERRER_FEE_RATE } from './constants' +import { ATTRIBUTION_BATCH_SIZE, REFERRER_FEE_RATE, SKEW_REVIEW_MS } from './constants' import { buildChainAdapterAsserts, getSwapperConfig } from './swapper-config' import type { AffiliateVerificationDetails, @@ -306,6 +306,14 @@ export class SwapsService { createdAt: swap.createdAt, }) + const overshoot = (details.quotedAt ?? 0) - (details.blockTime ?? 0) + + // a harvested transaction is claimed minutes to days late, so a near miss is worth a second look + if (details.reason === 'quote-postdates-tx' && overshoot <= SKEW_REVIEW_MS) { + const by = Math.round(overshoot / 1000) + logger.warn(`Swap ${swap.swapId} rejected on a quote postdating its block by only ${by}s - check for clock skew`) + } + const previous = swap.attributionDetails as AttributionDetails | null const unchanged = status === swap.attributionStatus && details.reason === previous?.reason diff --git a/apps/swap-service/src/swaps/utils.ts b/apps/swap-service/src/swaps/utils.ts index 267f403..86c139f 100644 --- a/apps/swap-service/src/swaps/utils.ts +++ b/apps/swap-service/src/swaps/utils.ts @@ -12,7 +12,7 @@ import type { Asset } from '@shapeshiftoss/types' import type { BlockTimeLookup } from '../lib/block-time.service' import { getAssetPriceUsd } from '../utils/pricing' -import { BLOCK_TIME_TOLERANCE_MS, PENDING_TIMEOUT_MS } from './constants' +import { PENDING_TIMEOUT_MS } from './constants' import type { AffiliateVerificationDetails, AttributionDetails, StatusNotification, Swap, UsdPrices } from './types' const logger = new Logger('SwapsService') @@ -83,13 +83,7 @@ export const resolveQuoteBinding = ( const quoted = quotedAt.getTime() const checked = { checked: true, blockTime, quotedAt: quoted } - if (quoted <= blockTime) { - return { status: 'ACCEPTED', details: { ...checked, reason: 'quote-precedes-tx' } } - } - - if (quoted <= blockTime + BLOCK_TIME_TOLERANCE_MS) { - return { status: 'ACCEPTED', details: { ...checked, reason: 'quote-within-tolerance' } } - } + if (quoted <= blockTime) return { status: 'ACCEPTED', details: { ...checked, reason: 'quote-precedes-tx' } } return { status: 'REJECTED', details: { ...checked, reason: 'quote-postdates-tx' } } } From 858dc7377fb11c144a7157a45876e3ce7959957d Mon Sep 17 00:00:00 2001 From: kaladinlight <35275952+kaladinlight@users.noreply.github.com> Date: Wed, 9 Sep 2026 12:08:28 -0600 Subject: [PATCH 2/3] feat(payouts): withhold and list swaps whose attribution is not accepted The payout script consulted verification but never attribution, so a rejected or contested claim was payable as long as its fee verified. Attribution now gates ahead of verification and unaccepted swaps join the review items, which is where an operator already looks before paying. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P36At9muenEn9Uqzjo1rDw --- apps/swap-service/src/swaps/constants.ts | 3 +- .../affiliate-payouts.test.ts | 23 ++++++++++++ .../affiliate-payouts/affiliate-payouts.ts | 15 ++++++-- scripts/affiliate-payouts/types.ts | 12 +++++- scripts/affiliate-payouts/utils.ts | 37 ++++++++++++++++++- 5 files changed, 82 insertions(+), 8 deletions(-) diff --git a/apps/swap-service/src/swaps/constants.ts b/apps/swap-service/src/swaps/constants.ts index aeb97d0..01aae0a 100644 --- a/apps/swap-service/src/swaps/constants.ts +++ b/apps/swap-service/src/swaps/constants.ts @@ -2,6 +2,5 @@ export const SHAPESHIFT_BPS = 10 export const REFERRER_FEE_RATE = 0.1 export const PENDING_TIMEOUT_MS = 24 * 60 * 60 * 1000 export const ATTRIBUTION_BATCH_SIZE = 200 -// a quote only just postdating its block may be miner clock skew rather than a harvested transaction, -// so rejections inside this margin are logged for review - it never changes the verdict +// a rejection this close to its block may be miner clock skew, so it is logged rather than trusted blindly export const SKEW_REVIEW_MS = 30 * 60 * 1000 diff --git a/scripts/affiliate-payouts/affiliate-payouts.test.ts b/scripts/affiliate-payouts/affiliate-payouts.test.ts index f552676..8988557 100644 --- a/scripts/affiliate-payouts/affiliate-payouts.test.ts +++ b/scripts/affiliate-payouts/affiliate-payouts.test.ts @@ -23,6 +23,7 @@ const makeRow = (overrides: Partial & RowExtras = {}): PrismaSwap => partnerBps: 30, verificationStatus: 'SUCCESS', isAffiliateVerified: true, + attributionStatus: 'ACCEPTED', priceable: true, ...overrides, }) as unknown as PrismaSwap @@ -110,6 +111,25 @@ describe('aggregateByPartner', () => { expect(partners.size).toBe(0) }) + // a verified fee on a transaction someone else has already claimed is still not this partner's + it('withholds any swap whose attribution is not accepted, however well it verifies', () => { + const { partners, unattributed } = aggregateByPartner( + [ + makeRow({ swapId: 'rejected', attributionStatus: 'REJECTED', attributionDetails: { reason: 'tx-not-found' } }), + makeRow({ swapId: 'pending', attributionStatus: 'PENDING', attributionDetails: null }), + makeRow({ swapId: 'disputed', attributionStatus: 'DISPUTED', attributionDetails: { reason: 'duplicate' } }), + ] as never, + stubDeps, + ) + + expect(partners.size).toBe(0) + expect(unattributed).toEqual([ + { swapId: 'rejected', partnerCode: 'acme', status: 'REJECTED', reason: 'tx-not-found' }, + { swapId: 'pending', partnerCode: 'acme', status: 'PENDING', reason: null }, + { swapId: 'disputed', partnerCode: 'acme', status: 'DISPUTED', reason: 'duplicate' }, + ]) + }) + it('partitions unpaid swaps by verificationStatus: pending vs failed for inspection', () => { const { partners, unverified } = aggregateByPartner( [ @@ -303,6 +323,7 @@ describe('buildRecord', () => { }, ], unverified: [{ swapId: 'u1', partnerCode: 'acme', status: 'pending' }], + unattributed: [{ swapId: 'x1', partnerCode: 'acme', status: 'REJECTED', reason: 'quote-postdates-tx' }], noAffiliateFee: [ { swapId: 'n1', partnerCode: 'acme' }, { swapId: 'n2', partnerCode: 'acme' }, @@ -318,6 +339,7 @@ describe('buildRecord', () => { unpriceableSwaps: 2, feeAnomalySwaps: 1, unverifiedSwaps: 1, + unattributedSwaps: 1, noAffiliateFeeSwaps: 2, partnerBpsUnsetSwaps: 1, noVerifiedFeeSwaps: 1, @@ -329,6 +351,7 @@ describe('buildRecord', () => { 'fee-anomaly', 'no-verified-fee', 'partner-bps-unset', + 'unattributed', 'unverified', ]) const warnedSwapIds = record.warnings.map((w) => w.swapId) diff --git a/scripts/affiliate-payouts/affiliate-payouts.ts b/scripts/affiliate-payouts/affiliate-payouts.ts index 497e9aa..0948368 100644 --- a/scripts/affiliate-payouts/affiliate-payouts.ts +++ b/scripts/affiliate-payouts/affiliate-payouts.ts @@ -22,7 +22,7 @@ function printSummary(record: PayoutRecord, payouts: PartnerPayout[]): void { console.log(`Total USDC: ${record.totals.totalUsdc}`) console.log(`Paid swaps: ${record.totals.paidSwaps}`) console.log( - `Excluded/review: ${record.totals.unpriceableSwaps} unpriceable | ${record.totals.feeAnomalySwaps} fee anomalies | ${record.totals.unverifiedSwaps} unverified | ${record.totals.noAffiliateFeeSwaps} no-affiliate-fee | ${record.totals.partnerBpsUnsetSwaps} partner-bps-unset | ${record.totals.noVerifiedFeeSwaps} no-verified-fee`, + `Excluded/review: ${record.totals.unpriceableSwaps} unpriceable | ${record.totals.feeAnomalySwaps} fee anomalies | ${record.totals.unverifiedSwaps} unverified | ${record.totals.unattributedSwaps} unattributed | ${record.totals.noAffiliateFeeSwaps} no-affiliate-fee | ${record.totals.partnerBpsUnsetSwaps} partner-bps-unset | ${record.totals.noVerifiedFeeSwaps} no-verified-fee`, ) const top = payouts.filter((p) => p.included).slice(0, 10) @@ -87,8 +87,16 @@ async function generate(monthArg: string | undefined, force: boolean): Promise = { } export type PayoutWarning = { - type: 'fee-anomaly' | 'address' | 'unverified' | 'partner-bps-unset' | 'no-verified-fee' + type: 'fee-anomaly' | 'address' | 'unverified' | 'unattributed' | 'partner-bps-unset' | 'no-verified-fee' partnerCode: string swapId: string | null reason: string | null @@ -95,6 +104,7 @@ export type PayoutRecord = { unpriceableSwaps: number feeAnomalySwaps: number unverifiedSwaps: number + unattributedSwaps: number noAffiliateFeeSwaps: number partnerBpsUnsetSwaps: number noVerifiedFeeSwaps: number diff --git a/scripts/affiliate-payouts/utils.ts b/scripts/affiliate-payouts/utils.ts index b71cc5d..649672c 100644 --- a/scripts/affiliate-payouts/utils.ts +++ b/scripts/affiliate-payouts/utils.ts @@ -14,6 +14,7 @@ import type { PayoutRecord, PayoutWarning, PayoutWindow, + UnattributedSwap, UnresolvedFeeSwap, UnverifiedSwap, } from './types' @@ -98,6 +99,7 @@ export function aggregateByPartner( const partners = new Map() const anomalies: FeeAnomaly[] = [] const unverified: UnverifiedSwap[] = [] + const unattributed: UnattributedSwap[] = [] const noAffiliateFee: NoAffiliateFeeSwap[] = [] const partnerBpsUnset: PartnerBpsUnsetSwap[] = [] const unresolvedFee: UnresolvedFeeSwap[] = [] @@ -109,6 +111,17 @@ 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 + if (row.attributionStatus !== 'ACCEPTED') { + unattributed.push({ + swapId: row.swapId, + partnerCode, + status: row.attributionStatus, + reason: (row.attributionDetails as { reason?: string } | null)?.reason ?? null, + }) + continue + } + if (row.verificationStatus === 'PENDING') { unverified.push({ swapId: row.swapId, partnerCode, status: 'pending' }) continue @@ -161,12 +174,23 @@ export function aggregateByPartner( // Pay only on the verified on-chain fee, via the shared exact partner-share helper. accrual.swapCount += 1 accrual.volumeUsd = accrual.volumeUsd.plus(fee.volumeUsd) - accrual.feesEarnedUsd = accrual.feesEarnedUsd.plus(deps.getPartnerFeeUsd(fee.actualFeeUsd, fee.verifiedBps, row.partnerBps)) + accrual.feesEarnedUsd = accrual.feesEarnedUsd.plus( + deps.getPartnerFeeUsd(fee.actualFeeUsd, fee.verifiedBps, row.partnerBps), + ) partners.set(partnerCode.toLowerCase(), accrual) } - return { partners, unpriceableSwaps, anomalies, unverified, noAffiliateFee, partnerBpsUnset, unresolvedFee } + return { + partners, + unpriceableSwaps, + anomalies, + unverified, + unattributed, + noAffiliateFee, + partnerBpsUnset, + unresolvedFee, + } } // USD is paid 1:1 as USDC, floored to 6 dp (USDC precision), trailing zeros stripped. @@ -228,6 +252,7 @@ export function buildRecord(input: { unpriceableSwaps: number anomalies: FeeAnomaly[] unverified: UnverifiedSwap[] + unattributed: UnattributedSwap[] noAffiliateFee: NoAffiliateFeeSwap[] partnerBpsUnset: PartnerBpsUnsetSwap[] unresolvedFee: UnresolvedFeeSwap[] @@ -239,6 +264,7 @@ export function buildRecord(input: { unpriceableSwaps, anomalies, unverified, + unattributed, noAffiliateFee, partnerBpsUnset, unresolvedFee, @@ -256,6 +282,12 @@ export function buildRecord(input: { ...payouts .filter((p) => !p.included) .map((p) => ({ type: 'address' as const, partnerCode: p.partnerCode, swapId: null, reason: p.excludedReason })), + ...unattributed.map((u) => ({ + 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`, + })), ...unverified.map((u) => ({ type: 'unverified' as const, partnerCode: u.partnerCode, @@ -287,6 +319,7 @@ export function buildRecord(input: { unpriceableSwaps, feeAnomalySwaps: anomalies.length, unverifiedSwaps: unverified.length, + unattributedSwaps: unattributed.length, noAffiliateFeeSwaps: noAffiliateFee.length, partnerBpsUnsetSwaps: partnerBpsUnset.length, noVerifiedFeeSwaps: unresolvedFee.length, From 9daa0dfb10e4ef586a691d66ab8590868c1db886 Mon Sep 17 00:00:00 2001 From: kaladinlight <35275952+kaladinlight@users.noreply.github.com> Date: Wed, 9 Sep 2026 12:43:33 -0600 Subject: [PATCH 3/3] fix(swap-service): drop the skew review log The payout run already lists every unaccepted swap with its status and reason, and the overshoot is derivable from attributionDetails, so the warning restated in logs what the review items say where it matters. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01P36At9muenEn9Uqzjo1rDw --- apps/swap-service/src/swaps/constants.ts | 2 -- apps/swap-service/src/swaps/swaps.service.ts | 10 +--------- 2 files changed, 1 insertion(+), 11 deletions(-) diff --git a/apps/swap-service/src/swaps/constants.ts b/apps/swap-service/src/swaps/constants.ts index 01aae0a..20faa9a 100644 --- a/apps/swap-service/src/swaps/constants.ts +++ b/apps/swap-service/src/swaps/constants.ts @@ -2,5 +2,3 @@ export const SHAPESHIFT_BPS = 10 export const REFERRER_FEE_RATE = 0.1 export const PENDING_TIMEOUT_MS = 24 * 60 * 60 * 1000 export const ATTRIBUTION_BATCH_SIZE = 200 -// a rejection this close to its block may be miner clock skew, so it is logged rather than trusted blindly -export const SKEW_REVIEW_MS = 30 * 60 * 1000 diff --git a/apps/swap-service/src/swaps/swaps.service.ts b/apps/swap-service/src/swaps/swaps.service.ts index 7a6769d..3360268 100644 --- a/apps/swap-service/src/swaps/swaps.service.ts +++ b/apps/swap-service/src/swaps/swaps.service.ts @@ -27,7 +27,7 @@ import { resolveAffiliateFeeAssetId } from '../utils/affiliateFeeAsset' import { getNextCursor, swapCursorArgs } from '../utils/pagination' import { SwapVerificationService } from '../verification/swap-verification.service' -import { ATTRIBUTION_BATCH_SIZE, REFERRER_FEE_RATE, SKEW_REVIEW_MS } from './constants' +import { ATTRIBUTION_BATCH_SIZE, REFERRER_FEE_RATE } from './constants' import { buildChainAdapterAsserts, getSwapperConfig } from './swapper-config' import type { AffiliateVerificationDetails, @@ -306,14 +306,6 @@ export class SwapsService { createdAt: swap.createdAt, }) - const overshoot = (details.quotedAt ?? 0) - (details.blockTime ?? 0) - - // a harvested transaction is claimed minutes to days late, so a near miss is worth a second look - if (details.reason === 'quote-postdates-tx' && overshoot <= SKEW_REVIEW_MS) { - const by = Math.round(overshoot / 1000) - logger.warn(`Swap ${swap.swapId} rejected on a quote postdating its block by only ${by}s - check for clock skew`) - } - const previous = swap.attributionDetails as AttributionDetails | null const unchanged = status === swap.attributionStatus && details.reason === previous?.reason