Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 4 additions & 8 deletions apps/swap-service/src/swaps/__tests__/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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' })
Expand All @@ -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
Expand Down
1 change: 0 additions & 1 deletion apps/swap-service/src/swaps/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
10 changes: 2 additions & 8 deletions apps/swap-service/src/swaps/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down Expand Up @@ -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' } }
}
Expand Down
23 changes: 23 additions & 0 deletions scripts/affiliate-payouts/affiliate-payouts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ const makeRow = (overrides: Partial<PrismaSwap> & RowExtras = {}): PrismaSwap =>
partnerBps: 30,
verificationStatus: 'SUCCESS',
isAffiliateVerified: true,
attributionStatus: 'ACCEPTED',
priceable: true,
...overrides,
}) as unknown as PrismaSwap
Expand Down Expand Up @@ -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(
[
Expand Down Expand Up @@ -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' },
Expand All @@ -318,6 +339,7 @@ describe('buildRecord', () => {
unpriceableSwaps: 2,
feeAnomalySwaps: 1,
unverifiedSwaps: 1,
unattributedSwaps: 1,
noAffiliateFeeSwaps: 2,
partnerBpsUnsetSwaps: 1,
noVerifiedFeeSwaps: 1,
Expand All @@ -329,6 +351,7 @@ describe('buildRecord', () => {
'fee-anomaly',
'no-verified-fee',
'partner-bps-unset',
'unattributed',
'unverified',
])
const warnedSwapIds = record.warnings.map((w) => w.swapId)
Expand Down
15 changes: 12 additions & 3 deletions scripts/affiliate-payouts/affiliate-payouts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -87,8 +87,16 @@ async function generate(monthArg: string | undefined, force: boolean): Promise<v
})
console.log(`Found ${rows.length} successful swaps with a partner code`)

const { partners, unpriceableSwaps, anomalies, unverified, noAffiliateFee, partnerBpsUnset, unresolvedFee } =
aggregateByPartner(rows, { toSwap, calculateFeeForSwap, getPartnerFeeUsd })
const {
partners,
unpriceableSwaps,
anomalies,
unverified,
unattributed,
noAffiliateFee,
partnerBpsUnset,
unresolvedFee,
} = aggregateByPartner(rows, { toSwap, calculateFeeForSwap, getPartnerFeeUsd })

const affiliates = await prisma.affiliate.findMany({
where: { partnerCode: { in: Array.from(partners.keys()) } },
Expand All @@ -106,6 +114,7 @@ async function generate(monthArg: string | undefined, force: boolean): Promise<v
unpriceableSwaps,
anomalies,
unverified,
unattributed,
noAffiliateFee,
partnerBpsUnset,
unresolvedFee,
Expand Down
12 changes: 11 additions & 1 deletion scripts/affiliate-payouts/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,14 @@ export type UnverifiedSwap = {
status: 'pending' | 'failed'
}

// A swap whose claim on its transaction is not settled in this partner's favour.
export type UnattributedSwap = {
swapId: string
partnerCode: string
status: string
reason: string | null
}

// A verified swap with no on chain affiliate fee.
export type NoAffiliateFeeSwap = {
swapId: string
Expand All @@ -66,6 +74,7 @@ export type AggregateResult = {
unpriceableSwaps: number
anomalies: FeeAnomaly[]
unverified: UnverifiedSwap[]
unattributed: UnattributedSwap[]
noAffiliateFee: NoAffiliateFeeSwap[]
partnerBpsUnset: PartnerBpsUnsetSwap[]
unresolvedFee: UnresolvedFeeSwap[]
Expand All @@ -78,7 +87,7 @@ export type FeeDeps<S> = {
}

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
Expand All @@ -95,6 +104,7 @@ export type PayoutRecord = {
unpriceableSwaps: number
feeAnomalySwaps: number
unverifiedSwaps: number
unattributedSwaps: number
noAffiliateFeeSwaps: number
partnerBpsUnsetSwaps: number
noVerifiedFeeSwaps: number
Expand Down
37 changes: 35 additions & 2 deletions scripts/affiliate-payouts/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import type {
PayoutRecord,
PayoutWarning,
PayoutWindow,
UnattributedSwap,
UnresolvedFeeSwap,
UnverifiedSwap,
} from './types'
Expand Down Expand Up @@ -98,6 +99,7 @@ export function aggregateByPartner<S>(
const partners = new Map<string, PartnerAccrual>()
const anomalies: FeeAnomaly[] = []
const unverified: UnverifiedSwap[] = []
const unattributed: UnattributedSwap[] = []
const noAffiliateFee: NoAffiliateFeeSwap[] = []
const partnerBpsUnset: PartnerBpsUnsetSwap[] = []
const unresolvedFee: UnresolvedFeeSwap[] = []
Expand All @@ -109,6 +111,17 @@ export function aggregateByPartner<S>(

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
Expand Down Expand Up @@ -161,12 +174,23 @@ export function aggregateByPartner<S>(
// 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.
Expand Down Expand Up @@ -228,6 +252,7 @@ export function buildRecord(input: {
unpriceableSwaps: number
anomalies: FeeAnomaly[]
unverified: UnverifiedSwap[]
unattributed: UnattributedSwap[]
noAffiliateFee: NoAffiliateFeeSwap[]
partnerBpsUnset: PartnerBpsUnsetSwap[]
unresolvedFee: UnresolvedFeeSwap[]
Expand All @@ -239,6 +264,7 @@ export function buildRecord(input: {
unpriceableSwaps,
anomalies,
unverified,
unattributed,
noAffiliateFee,
partnerBpsUnset,
unresolvedFee,
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
Loading