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..20faa9a 100644 --- a/apps/swap-service/src/swaps/constants.ts +++ b/apps/swap-service/src/swaps/constants.ts @@ -2,4 +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 -export const BLOCK_TIME_TOLERANCE_MS = 30 * 60 * 1000 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' } } } 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,