diff --git a/.gitignore b/.gitignore index 9abd83e..9d050b3 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,9 @@ node_modules .turbo /generated/prisma +# Payout artifacts +/payouts/ + # Yarn (Berry) .yarn/* !.yarn/patches diff --git a/apps/swap-service/src/swaps/utils.ts b/apps/swap-service/src/swaps/utils.ts index 241a1d7..e6e0b2a 100644 --- a/apps/swap-service/src/swaps/utils.ts +++ b/apps/swap-service/src/swaps/utils.ts @@ -155,7 +155,15 @@ const resolveActualFeeUsd = (swap: Swap): number | null => { return bnOrZero(amount).div(bnOrZero(10).pow(precision)).times(priceUsd).toNumber() } -export const calculateFeeForSwap = (swap: Swap): { feeUsd: number; volumeUsd: number; verifiedBps: number } | null => { +export const calculateFeeForSwap = ( + swap: Swap, +): { + feeUsd: number + volumeUsd: number + verifiedBps: number + actualFeeUsd: number | null + impliedFeeUsd: number | null +} | null => { const verifiedBps = swap.affiliateVerificationDetails?.affiliateBps if (!verifiedBps) { logger.warn(`Verified swap ${swap.swapId} missing affiliate bps in verification details, skipping`) @@ -175,14 +183,18 @@ export const calculateFeeForSwap = (swap: Swap): { feeUsd: number; volumeUsd: nu ) const actualFeeUsd = resolveActualFeeUsd(swap) + const impliedFeeUsd = + sellAmountUsd === null ? null : bnOrZero(sellAmountUsd).times(verifiedBps).div(BPS_DENOMINATOR).toNumber() - if (actualFeeUsd === null && sellAmountUsd === null) { + // Prefer the on-chain collected fee; fall back to the bps-implied fee. + const feeUsd = actualFeeUsd ?? impliedFeeUsd + + if (feeUsd === null) { logger.warn(`Unable to calculate fee for swap ${swap.swapId}, skipping`) return null } - const feeUsd = actualFeeUsd ?? bnOrZero(sellAmountUsd).times(verifiedBps).div(BPS_DENOMINATOR).toNumber() const volumeUsd = sellAmountUsd ?? bnOrZero(actualFeeUsd).times(BPS_DENOMINATOR).div(verifiedBps).toNumber() - return { feeUsd, volumeUsd, verifiedBps } + return { feeUsd, volumeUsd, verifiedBps, actualFeeUsd, impliedFeeUsd } } diff --git a/package.json b/package.json index f73a20e..6b817d0 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,9 @@ "db:migrate:status": "prisma migrate status", "db:migrate:create": "prisma migrate dev --create-only --name", "db:studio": "prisma studio", - "referral-rewards": "ts-node scripts/referral-rewards.ts" + "referral-rewards": "ts-node scripts/referral-rewards.ts", + "affiliate-payouts": "ts-node --transpile-only scripts/affiliate-payouts/affiliate-payouts.ts", + "affiliate-payouts:test": "jest --config scripts/jest.config.ts" }, "dependencies": { "@bitcoinerlab/secp256k1": "^1.1.1", @@ -94,22 +96,5 @@ "resolutions": { "google-protobuf": "3.15.7" }, - "jest": { - "moduleFileExtensions": [ - "js", - "json", - "ts" - ], - "rootDir": "src", - "testRegex": ".*\\.spec\\.ts$", - "transform": { - "^.+\\.(t|j)s$": "ts-jest" - }, - "collectCoverageFrom": [ - "**/*.(t|j)s" - ], - "coverageDirectory": "../coverage", - "testEnvironment": "node" - }, "packageManager": "yarn@4.7.0" } diff --git a/scripts/affiliate-payouts/affiliate-payouts.test.ts b/scripts/affiliate-payouts/affiliate-payouts.test.ts new file mode 100644 index 0000000..edc4010 --- /dev/null +++ b/scripts/affiliate-payouts/affiliate-payouts.test.ts @@ -0,0 +1,337 @@ +import type { Swap as PrismaSwap } from '@prisma/client' +import BigNumber from 'bignumber.js' +import { getAddress } from 'viem' + +import type { FeeDeps, FeeResult, PartnerAccrual } from './types' +import { + aggregateByPartner, + buildPayouts, + buildRecord, + checkFeeAnomaly, + formatUsdc, + normalizeAddress, + resolveWindow, + toCsv, +} from './utils' + +type RowExtras = { swapId?: string; priceable?: boolean; fee?: Partial } + +const makeRow = (overrides: Partial & RowExtras = {}): PrismaSwap => + ({ + swapId: 's1', + partnerCode: 'acme', + partnerBps: 30, + verificationStatus: 'SUCCESS', + isAffiliateVerified: true, + priceable: true, + ...overrides, + }) as unknown as PrismaSwap + +// Stub fee math: $12 fee on $2000 volume at 60 verified bps; on-chain actual matches implied by +// default so the guard passes. Per-row overrides via `fee` let tests exercise the deviation guard. +const stubDeps: FeeDeps = { + toSwap: (row) => row, + calculateFeeForSwap: (swap) => { + const r = swap as unknown as RowExtras + if (r.priceable === false) return null + return { feeUsd: 12, volumeUsd: 2000, verifiedBps: 60, actualFeeUsd: 12, impliedFeeUsd: 12, ...r.fee } + }, + getPartnerFeeRate: (verifiedBps, partnerBps) => (verifiedBps <= 0 ? 0 : Math.min(partnerBps / verifiedBps, 1)), +} + +const accrual = (over: Partial & Pick): PartnerAccrual => ({ + swapCount: 1, + volumeUsd: new BigNumber(2000), + feesEarnedUsd: new BigNumber(6), + ...over, +}) + +describe('resolveWindow', () => { + it('defaults to the previous calendar month in UTC, end-exclusive', () => { + const { start, end, label } = resolveWindow(undefined, new Date('2026-07-15T12:00:00Z')) + expect(start.toISOString()).toBe('2026-06-01T00:00:00.000Z') + expect(end.toISOString()).toBe('2026-07-01T00:00:00.000Z') + expect(label).toBe('2026-06') + }) + + it('wraps to December of the prior year in January', () => { + const { start, end, label } = resolveWindow(undefined, new Date('2026-01-10T00:00:00Z')) + expect(start.toISOString()).toBe('2025-12-01T00:00:00.000Z') + expect(end.toISOString()).toBe('2026-01-01T00:00:00.000Z') + expect(label).toBe('2025-12') + }) + + it('honors an explicit YYYY-MM month, spanning to the first of the next month', () => { + const { start, end, label } = resolveWindow('2026-12') + expect(start.toISOString()).toBe('2026-12-01T00:00:00.000Z') + expect(end.toISOString()).toBe('2027-01-01T00:00:00.000Z') + expect(label).toBe('2026-12') + }) + + it('rejects malformed or out-of-range month strings', () => { + expect(() => resolveWindow('2026-13')).toThrow() + expect(() => resolveWindow('2026-00')).toThrow() + expect(() => resolveWindow('2026-6')).toThrow() + expect(() => resolveWindow('2026')).toThrow() + expect(() => resolveWindow('not-a-month')).toThrow() + }) +}) + +describe('aggregateByPartner', () => { + it('sums a partner share across swaps', () => { + const { partners, unpriceableSwaps, anomalies } = aggregateByPartner([makeRow(), makeRow()], stubDeps) + const acme = partners.get('acme') + expect(unpriceableSwaps).toBe(0) + expect(anomalies).toHaveLength(0) + expect(acme?.swapCount).toBe(2) + expect(acme?.volumeUsd.toNumber()).toBeCloseTo(4000) + // $12 fee * (30/60) = $6 per swap → $12 total + expect(acme?.feesEarnedUsd.toNumber()).toBeCloseTo(12) + }) + + it('groups case-insensitively across partner-code casings (citext)', () => { + const { partners } = aggregateByPartner( + [makeRow({ swapId: 'a', partnerCode: 'Acme' }), makeRow({ swapId: 'b', partnerCode: 'acme' })], + stubDeps, + ) + expect(partners.size).toBe(1) + expect(partners.get('acme')?.swapCount).toBe(2) + }) + + it('caps the partner share at 100% when partnerBps exceeds verifiedBps', () => { + const { partners } = aggregateByPartner([makeRow({ partnerBps: 120 })], stubDeps) + expect(partners.get('acme')?.feesEarnedUsd.toNumber()).toBeCloseTo(12) + }) + + it('skips swaps that cannot be priced', () => { + const { partners, unpriceableSwaps } = aggregateByPartner([makeRow({ priceable: false })], stubDeps) + expect(unpriceableSwaps).toBe(1) + expect(partners.size).toBe(0) + }) + + it('partitions unpaid swaps by verificationStatus: pending vs failed for inspection', () => { + const { partners, unverified } = aggregateByPartner( + [ + makeRow({ swapId: 'pending', verificationStatus: 'PENDING', isAffiliateVerified: null as unknown as boolean }), + makeRow({ swapId: 'failed', verificationStatus: 'FAILED', isAffiliateVerified: false }), + ], + stubDeps, + ) + expect(partners.size).toBe(0) + expect(unverified).toEqual([ + { swapId: 'pending', partnerCode: 'acme', status: 'pending' }, + { swapId: 'failed', partnerCode: 'acme', status: 'failed' }, + ]) + }) + + it('does not pay a verified swap with no affiliate fee for us (hasAffiliate=false: not ours or 0 bps)', () => { + const { partners, noAffiliateFee, unverified } = aggregateByPartner( + [makeRow({ swapId: 'nofee', verificationStatus: 'SUCCESS', isAffiliateVerified: false })], + stubDeps, + ) + expect(partners.size).toBe(0) + expect(unverified).toHaveLength(0) + expect(noAffiliateFee).toEqual([{ swapId: 'nofee', partnerCode: 'acme' }]) + }) + + it('never pays the bps-implied fee: a swap with no verified on-chain fee is surfaced, not paid', () => { + const { partners, unresolvedFee } = aggregateByPartner( + [makeRow({ swapId: 'unresolved', fee: { actualFeeUsd: null, impliedFeeUsd: 12 } })], + stubDeps, + ) + expect(partners.size).toBe(0) + expect(unresolvedFee).toEqual([{ swapId: 'unresolved', partnerCode: 'acme' }]) + }) + + it('surfaces a swap with partnerBps unset (0) instead of silently dropping it', () => { + const { partners, partnerBpsUnset } = aggregateByPartner([makeRow({ swapId: 'z', partnerBps: 0 })], stubDeps) + expect(partners.size).toBe(0) + expect(partnerBpsUnset).toEqual([{ swapId: 'z', partnerCode: 'acme', verifiedBps: 60, partnerBps: 0 }]) + }) + + it('excludes a swap whose on-chain fee deviates beyond tolerance, recording an anomaly', () => { + // The woody/maya case: on-chain fee $9000 vs implied $12 → ~750x over → excluded. + const { partners, anomalies } = aggregateByPartner( + [makeRow({ swapId: 'bad', fee: { actualFeeUsd: 9000, impliedFeeUsd: 12 } })], + stubDeps, + ) + expect(partners.size).toBe(0) + expect(anomalies).toHaveLength(1) + expect(anomalies[0].swapId).toBe('bad') + }) + + it('still pays a partner for their non-anomalous swaps', () => { + const { partners, anomalies } = aggregateByPartner( + [makeRow({ swapId: 'ok' }), makeRow({ swapId: 'bad', fee: { actualFeeUsd: 9000, impliedFeeUsd: 12 } })], + stubDeps, + ) + expect(anomalies).toHaveLength(1) + expect(partners.get('acme')?.swapCount).toBe(1) + expect(partners.get('acme')?.feesEarnedUsd.toNumber()).toBeCloseTo(6) + }) +}) + +describe('checkFeeAnomaly', () => { + const row = { swapId: 's1', partnerCode: 'acme' } + const fee = (over: Partial): FeeResult => ({ + feeUsd: 12, + volumeUsd: 2000, + verifiedBps: 60, + actualFeeUsd: 12, + impliedFeeUsd: 12, + ...over, + }) + + it('returns null when there is no on-chain fee to check (handled as no-verified-fee upstream)', () => { + expect(checkFeeAnomaly(row, fee({ actualFeeUsd: null }), 0.25)).toBeNull() + }) + + it('passes when on-chain fee is within tolerance of implied', () => { + expect(checkFeeAnomaly(row, fee({ actualFeeUsd: 13, impliedFeeUsd: 12 }), 0.25)).toBeNull() + }) + + it('flags when on-chain fee exceeds the deviation band', () => { + const anomaly = checkFeeAnomaly(row, fee({ actualFeeUsd: 39020, impliedFeeUsd: 0.4 }), 0.25) + expect(anomaly?.deviation).toBeGreaterThan(0.25) + expect(anomaly?.reason).toMatch(/deviates/) + }) + + it('flags when the implied fee is unavailable for validation', () => { + const anomaly = checkFeeAnomaly(row, fee({ actualFeeUsd: 5, impliedFeeUsd: null }), 0.25) + expect(anomaly?.deviation).toBeNull() + expect(anomaly?.reason).toMatch(/cannot validate/) + }) +}) + +describe('formatUsdc', () => { + it('floors to 6 dp and strips trailing zeros', () => { + expect(formatUsdc(12)).toBe('12') + expect(formatUsdc(1000.5)).toBe('1000.5') + expect(formatUsdc(12.3456789)).toBe('12.345678') + expect(formatUsdc(0.0000005)).toBe('0') + }) + + it('accepts a BigNumber accrual without float drift', () => { + expect(formatUsdc(new BigNumber('0.1').plus('0.2'))).toBe('0.3') + }) +}) + +describe('normalizeAddress', () => { + it('checksums valid EVM addresses and rejects everything else', () => { + const lower = '0xd8da6bf26964af9d7eed9e03e53415d37aa96045' + expect(normalizeAddress(lower)).toBe(getAddress(lower)) + expect(normalizeAddress(null)).toBeNull() + expect(normalizeAddress('not-an-address')).toBeNull() + expect(normalizeAddress('cosmos1abc')).toBeNull() + }) + + it('rejects the zero address', () => { + expect(normalizeAddress('0x0000000000000000000000000000000000000000')).toBeNull() + }) +}) + +describe('toCsv', () => { + it('emits the Safe airdrop header and indexed erc20 rows', () => { + const csv = toCsv([ + { receiveAddress: '0xabc', usdcAmount: '10' }, + { receiveAddress: '0xdef', usdcAmount: '5.5' }, + ]) + const lines = csv.trimEnd().split('\n') + expect(lines[0]).toBe('token_type,token_address,receiver,amount,id') + expect(lines[1]).toBe('erc20,0xaf88d065e77c8cC2239327C5EDb3A432268e5831,0xabc,10,0') + expect(lines[2]).toBe('erc20,0xaf88d065e77c8cC2239327C5EDb3A432268e5831,0xdef,5.5,1') + }) +}) + +describe('buildPayouts', () => { + it('excludes partners with non-EVM addresses and sorts by earnings', () => { + const partners = new Map([ + ['acme', accrual({ partnerCode: 'acme', feesEarnedUsd: new BigNumber(6) })], + [ + 'big', + accrual({ partnerCode: 'big', swapCount: 5, volumeUsd: new BigNumber(9000), feesEarnedUsd: new BigNumber(50) }), + ], + ['bad', accrual({ partnerCode: 'bad', volumeUsd: new BigNumber(100), feesEarnedUsd: new BigNumber(1) })], + ]) + const affiliates = new Map([ + ['acme', { receiveAddress: null, walletAddress: '0xd8da6bf26964af9d7eed9e03e53415d37aa96045' }], + ['big', { receiveAddress: '0x52908400098527886e0f7030069857d2e4169ee7', walletAddress: '0xabc' }], + ['bad', { receiveAddress: 'cosmos1xyz', walletAddress: 'cosmos1xyz' }], + ]) + + const payouts = buildPayouts(partners, affiliates) + + expect(payouts.map((p) => p.partnerCode)).toEqual(['big', 'acme', 'bad']) + expect(payouts.find((p) => p.partnerCode === 'acme')?.receiveAddress).toBe( + getAddress('0xd8da6bf26964af9d7eed9e03e53415d37aa96045'), + ) + expect(payouts.find((p) => p.partnerCode === 'bad')?.included).toBe(false) + expect(payouts.find((p) => p.partnerCode === 'bad')?.excludedReason).toMatch(/invalid payout address/) + }) +}) + +describe('buildRecord', () => { + const window = { start: new Date('2026-06-01T00:00:00Z'), end: new Date('2026-07-01T00:00:00Z'), label: '2026-06' } + + it('maps every excluded bucket to its total and warns per-swap for all but noAffiliateFee', () => { + const payouts = buildPayouts( + new Map([ + ['paid', accrual({ partnerCode: 'paid', swapCount: 3, feesEarnedUsd: new BigNumber(30) })], + ['bad', accrual({ partnerCode: 'bad', feesEarnedUsd: new BigNumber(5) })], + ]), + new Map([ + ['paid', { receiveAddress: '0x52908400098527886e0f7030069857d2e4169ee7', walletAddress: '0xabc' }], + ['bad', { receiveAddress: 'cosmos1xyz', walletAddress: 'cosmos1xyz' }], + ]), + ) + + const record = buildRecord({ + window, + payouts, + generatedAt: '2026-07-01T00:00:00.000Z', + unpriceableSwaps: 2, + anomalies: [ + { + swapId: 'a1', + partnerCode: 'acme', + actualFeeUsd: 9000, + impliedFeeUsd: 12, + volumeUsd: 2000, + deviation: 749, + reason: 'deviates', + }, + ], + unverified: [{ swapId: 'u1', partnerCode: 'acme', status: 'pending' }], + noAffiliateFee: [ + { swapId: 'n1', partnerCode: 'acme' }, + { swapId: 'n2', partnerCode: 'acme' }, + ], + partnerBpsUnset: [{ swapId: 'p1', partnerCode: 'acme', verifiedBps: 60, partnerBps: 0 }], + unresolvedFee: [{ swapId: 'r1', partnerCode: 'acme' }], + }) + + expect(record.totals).toEqual({ + partnersPaid: 1, + totalUsdc: '30.000000', + paidSwaps: 3, + unpriceableSwaps: 2, + feeAnomalySwaps: 1, + unverifiedSwaps: 1, + noAffiliateFeeSwaps: 2, + partnerBpsUnsetSwaps: 1, + noVerifiedFeeSwaps: 1, + }) + + // Every surfaced bucket is warned per-swap except noAffiliateFee, which is counted-only. + expect(record.warnings.map((w) => w.type).sort()).toEqual([ + 'address', + 'fee-anomaly', + 'no-verified-fee', + 'partner-bps-unset', + 'unverified', + ]) + const warnedSwapIds = record.warnings.map((w) => w.swapId) + expect(warnedSwapIds).not.toContain('n1') + expect(warnedSwapIds).not.toContain('n2') + }) +}) diff --git a/scripts/affiliate-payouts/affiliate-payouts.ts b/scripts/affiliate-payouts/affiliate-payouts.ts new file mode 100644 index 0000000..f176e4b --- /dev/null +++ b/scripts/affiliate-payouts/affiliate-payouts.ts @@ -0,0 +1,145 @@ +import { PrismaClient } from '@prisma/client' +import * as fs from 'fs' +import * as path from 'path' + +import { calculateFeeForSwap, getPartnerFeeRate, toSwap } from '../../apps/swap-service/src/swaps/utils' + +import type { PartnerPayout, PayoutRecord } from './types' +import { aggregateByPartner, buildPayouts, buildRecord, resolveWindow, toCsv } from './utils' + +const databaseUrl = process.env.DATABASE_URL +if (!databaseUrl) { + console.error('DATABASE_URL is not set. Run with DATABASE_URL= yarn affiliate-payouts …') + process.exit(1) +} + +const prisma = new PrismaClient() + +function printSummary(record: PayoutRecord, payouts: PartnerPayout[]): void { + console.log('\n=== Affiliate Payout Summary ===') + console.log(`Period: ${record.window.start} → ${record.window.end} (${record.window.label})`) + console.log(`Partners paid: ${record.totals.partnersPaid}`) + 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`, + ) + + const top = payouts.filter((p) => p.included).slice(0, 10) + if (top.length) { + console.log('\n=== Top Partners ===') + top.forEach((p, i) => { + console.log(`${i + 1}. ${p.partnerCode} → ${p.receiveAddress}`) + console.log(` ${p.usdcAmount} USDC | volume $${p.volumeUsd.toFixed(2)} | ${p.swapCount} swaps`) + }) + } + + if (record.warnings.length) { + console.log('\n=== Warnings / review items (excluded from CSV) ===') + record.warnings.forEach((w) => { + const ref = w.swapId ? ` [swap ${w.swapId}]` : '' + console.log(`- [${w.type}] ${w.partnerCode}${ref}: ${w.reason}`) + }) + } +} + +function writeArtifacts(record: PayoutRecord, payouts: PartnerPayout[], force: boolean): void { + const outputDir = path.join(__dirname, '../payouts') + if (!fs.existsSync(outputDir)) fs.mkdirSync(outputDir, { recursive: true }) + + const csvPath = path.join(outputDir, `affiliate-payouts-${record.window.label}.csv`) + const jsonPath = path.join(outputDir, `affiliate-payouts-${record.window.label}.json`) + + if (!force) { + const existing = [csvPath, jsonPath].filter((p) => fs.existsSync(p)) + if (existing.length) { + throw new Error( + `Refusing to overwrite existing payout artifacts (pass --force to replace):\n ${existing.join('\n ')}`, + ) + } + } + + const csv = toCsv( + payouts + .filter((p): p is PartnerPayout & { receiveAddress: string } => p.included && p.receiveAddress !== null) + .map((p) => ({ receiveAddress: p.receiveAddress, usdcAmount: p.usdcAmount })), + ) + + fs.writeFileSync(csvPath, csv) + fs.writeFileSync(jsonPath, JSON.stringify(record, null, 2) + '\n') + + console.log(`\nCSV written: ${csvPath}`) + console.log(`JSON written: ${jsonPath}`) +} + +async function generate(monthArg: string | undefined, force: boolean): Promise { + const window = resolveWindow(monthArg) + console.log( + `Aggregating affiliate payouts for ${window.label} (${window.start.toISOString()} → ${window.end.toISOString()})`, + ) + + const rows = await prisma.swap.findMany({ + where: { + partnerCode: { not: null }, + status: 'SUCCESS', + createdAt: { gte: window.start, lt: window.end }, + }, + }) + console.log(`Found ${rows.length} successful swaps with a partner code`) + + const { partners, unpriceableSwaps, anomalies, unverified, noAffiliateFee, partnerBpsUnset, unresolvedFee } = + aggregateByPartner(rows, { toSwap, calculateFeeForSwap, getPartnerFeeRate }) + + const affiliates = await prisma.affiliate.findMany({ + where: { partnerCode: { in: Array.from(partners.keys()) } }, + select: { partnerCode: true, receiveAddress: true, walletAddress: true }, + }) + + const affiliatesByCode = new Map(affiliates.map((a) => [a.partnerCode.toLowerCase(), a])) + + const payouts = buildPayouts(partners, affiliatesByCode) + + const record = buildRecord({ + window, + payouts, + generatedAt: new Date().toISOString(), + unpriceableSwaps, + anomalies, + unverified, + noAffiliateFee, + partnerBpsUnset, + unresolvedFee, + }) + + writeArtifacts(record, payouts, force) + printSummary(record, payouts) +} + +async function main(): Promise { + const args = process.argv.slice(2) + const command = args[0] + const force = args.includes('--force') + const positional = args.slice(1).filter((a) => !a.startsWith('--')) + + try { + switch (command) { + case 'generate': + await generate(positional[0], force) + break + default: + console.log('Usage:') + console.log(' DATABASE_URL= affiliate-payouts generate [YYYY-MM] [--force]') + console.log(' No month → previous calendar month (UTC).') + console.log(' --force → overwrite existing artifacts for the window.') + console.log(' Example: DATABASE_URL= affiliate-payouts generate 2026-06') + process.exit(1) + } + } finally { + await prisma.$disconnect() + } +} + +main().catch((error) => { + console.error(error) + process.exit(1) +}) diff --git a/scripts/affiliate-payouts/types.ts b/scripts/affiliate-payouts/types.ts new file mode 100644 index 0000000..fe32504 --- /dev/null +++ b/scripts/affiliate-payouts/types.ts @@ -0,0 +1,113 @@ +import type { Swap as PrismaSwap } from '@prisma/client' +import type BigNumber from 'bignumber.js' + +export type PartnerAccrual = { + partnerCode: string + swapCount: number + volumeUsd: BigNumber + feesEarnedUsd: BigNumber +} + +export type PartnerPayout = PartnerAccrual & { + receiveAddress: string | null + included: boolean + excludedReason: string | null + usdcAmount: string +} + +export type PayoutWindow = { start: Date; end: Date; label: string } + +export type FeeResult = { + feeUsd: number + volumeUsd: number + verifiedBps: number + actualFeeUsd: number | null + impliedFeeUsd: number | null +} + +export type FeeAnomaly = { + swapId: string + partnerCode: string + actualFeeUsd: number | null + impliedFeeUsd: number | null + volumeUsd: number + deviation: number | null + reason: string +} + +export type UnverifiedSwap = { + swapId: string + partnerCode: string + status: 'pending' | 'failed' +} + +// A verified swap with no on chain affiliate fee. +export type NoAffiliateFeeSwap = { + swapId: string + partnerCode: string +} + +// A verified swap whose partner share is 0 because partnerBps is 0. +export type PartnerBpsUnsetSwap = { + swapId: string + partnerCode: string + verifiedBps: number + partnerBps: number +} + +// A verified swap with no resolvable on-chain fee (e.g. fee asset/price unavailable). +export type UnresolvedFeeSwap = { + swapId: string + partnerCode: string +} + +export type AggregateResult = { + partners: Map + unpriceableSwaps: number + anomalies: FeeAnomaly[] + unverified: UnverifiedSwap[] + noAffiliateFee: NoAffiliateFeeSwap[] + partnerBpsUnset: PartnerBpsUnsetSwap[] + unresolvedFee: UnresolvedFeeSwap[] +} + +export type FeeDeps = { + toSwap: (row: PrismaSwap) => S + calculateFeeForSwap: (swap: S) => FeeResult | null + getPartnerFeeRate: (verifiedBps: number, partnerBps: number) => number +} + +export type PayoutWarning = { + type: 'fee-anomaly' | 'address' | 'unverified' | 'partner-bps-unset' | 'no-verified-fee' + partnerCode: string + swapId: string | null + reason: string | null +} + +export type PayoutRecord = { + window: { start: string; end: string; label: string } + generatedAt: string + token: { chain: string; address: string; symbol: string } + totals: { + partnersPaid: number + totalUsdc: string + paidSwaps: number + unpriceableSwaps: number + feeAnomalySwaps: number + unverifiedSwaps: number + noAffiliateFeeSwaps: number + partnerBpsUnsetSwaps: number + noVerifiedFeeSwaps: number + } + partners: { + partnerCode: string + receiveAddress: string | null + swapCount: number + volumeUsd: string + feesEarnedUsd: string + usdcAmount: string + included: boolean + excludedReason: string | null + }[] + warnings: PayoutWarning[] +} diff --git a/scripts/affiliate-payouts/utils.ts b/scripts/affiliate-payouts/utils.ts new file mode 100644 index 0000000..91fcbd9 --- /dev/null +++ b/scripts/affiliate-payouts/utils.ts @@ -0,0 +1,306 @@ +import type { Swap as PrismaSwap } from '@prisma/client' +import BigNumber from 'bignumber.js' +import { getAddress, isAddress, zeroAddress } from 'viem' + +import type { + AggregateResult, + FeeAnomaly, + FeeDeps, + FeeResult, + NoAffiliateFeeSwap, + PartnerAccrual, + PartnerBpsUnsetSwap, + PartnerPayout, + PayoutRecord, + PayoutWarning, + PayoutWindow, + UnresolvedFeeSwap, + UnverifiedSwap, +} from './types' + +export const ARBITRUM_USDC_ADDRESS = '0xaf88d065e77c8cC2239327C5EDb3A432268e5831' +export const FEE_DEVIATION_TOLERANCE = 0.25 +export const USDC_DECIMALS = 6 + +function monthLabel(d: Date): string { + return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, '0')}` +} + +export function resolveWindow(date?: string, now: Date = new Date()): PayoutWindow { + const { year, monthIndex } = ((): { year: number; monthIndex: number } => { + if (!date) return { year: now.getUTCFullYear(), monthIndex: now.getUTCMonth() - 1 } + + const match = /^(\d{4})-(\d{2})$/.exec(date) + if (!match) throw new Error(`Invalid month (expected YYYY-MM): ${date}`) + + const monthIndex = Number(match[2]) - 1 + if (monthIndex < 0 || monthIndex > 11) throw new Error(`Invalid month (expected YYYY-MM): ${date}`) + + return { year: Number(match[1]), monthIndex } + })() + + const start = new Date(Date.UTC(year, monthIndex, 1)) + const end = new Date(Date.UTC(year, monthIndex + 1, 1)) + + return { start, end, label: monthLabel(start) } +} + +// Flags the on-chain fee when it deviates too far from — or can't be checked against — the +// bps-implied fee; null when trustworthy. The implied fee only guards, it's never a payout basis. +export function checkFeeAnomaly( + row: { swapId: string; partnerCode: string }, + fee: FeeResult, + tolerance: number, +): FeeAnomaly | null { + if (fee.actualFeeUsd === null) return null + + const base = { + swapId: row.swapId, + partnerCode: row.partnerCode, + actualFeeUsd: fee.actualFeeUsd, + impliedFeeUsd: fee.impliedFeeUsd, + volumeUsd: fee.volumeUsd, + } + + if (fee.impliedFeeUsd === null || fee.impliedFeeUsd <= 0) { + return { ...base, deviation: null, reason: 'cannot validate on-chain fee: no bps-implied fee available' } + } + + const deviation = Math.abs(fee.actualFeeUsd - fee.impliedFeeUsd) / fee.impliedFeeUsd + if (deviation > tolerance) { + return { + ...base, + deviation, + reason: `on-chain fee $${fee.actualFeeUsd.toFixed(6)} deviates ${(deviation * 100).toFixed(0)}% from bps-implied $${fee.impliedFeeUsd.toFixed(6)} (tolerance ${(tolerance * 100).toFixed(0)}%)`, + } + } + + return null +} + +// 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: +// 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) +// fee math returns null → unpriceableSwaps (can't price the swap) +// on-chain fee is null → unresolvedFee (nothing verified to pay on) +// fails the deviation guard → anomalies (on-chain fee looks wrong) +// partner share rate ≤ 0 → partnerBpsUnset (no partner share configured) +// otherwise → paid: verified fee × rate, accrued to the partner +// Only the verified on-chain fee is ever paid — never the bps-implied estimate. +export function aggregateByPartner( + rows: PrismaSwap[], + deps: FeeDeps, + tolerance: number = FEE_DEVIATION_TOLERANCE, +): AggregateResult { + const partners = new Map() + const anomalies: FeeAnomaly[] = [] + const unverified: UnverifiedSwap[] = [] + const noAffiliateFee: NoAffiliateFeeSwap[] = [] + const partnerBpsUnset: PartnerBpsUnsetSwap[] = [] + const unresolvedFee: UnresolvedFeeSwap[] = [] + + let unpriceableSwaps = 0 + + for (const row of rows) { + if (!row.partnerCode) continue + + const partnerCode = row.partnerCode.toLowerCase() + + if (row.verificationStatus === 'PENDING') { + unverified.push({ swapId: row.swapId, partnerCode, status: 'pending' }) + continue + } + + if (row.verificationStatus === 'FAILED') { + unverified.push({ swapId: row.swapId, partnerCode, status: 'failed' }) + continue + } + + if (!row.isAffiliateVerified) { + noAffiliateFee.push({ swapId: row.swapId, partnerCode }) + continue + } + + const fee = deps.calculateFeeForSwap(deps.toSwap(row)) + if (!fee) { + unpriceableSwaps++ + continue + } + + if (fee.actualFeeUsd === null) { + unresolvedFee.push({ swapId: row.swapId, partnerCode }) + continue + } + + const anomaly = checkFeeAnomaly({ swapId: row.swapId, partnerCode }, fee, tolerance) + if (anomaly) { + anomalies.push(anomaly) + continue + } + + const rate = deps.getPartnerFeeRate(fee.verifiedBps, row.partnerBps) + if (rate <= 0) { + partnerBpsUnset.push({ + swapId: row.swapId, + partnerCode, + verifiedBps: fee.verifiedBps, + partnerBps: row.partnerBps, + }) + continue + } + + const accrual = partners.get(partnerCode) ?? { + partnerCode, + swapCount: 0, + volumeUsd: new BigNumber(0), + feesEarnedUsd: new BigNumber(0), + } + + accrual.swapCount += 1 + accrual.volumeUsd = accrual.volumeUsd.plus(fee.volumeUsd) + accrual.feesEarnedUsd = accrual.feesEarnedUsd.plus(new BigNumber(fee.actualFeeUsd).times(rate)) + + partners.set(partnerCode.toLowerCase(), accrual) + } + + return { partners, unpriceableSwaps, anomalies, unverified, noAffiliateFee, partnerBpsUnset, unresolvedFee } +} + +// USD is paid 1:1 as USDC, floored to 6 dp (USDC precision), trailing zeros stripped. +export function formatUsdc(usd: BigNumber.Value): string { + return new BigNumber(usd).toFixed(USDC_DECIMALS, BigNumber.ROUND_DOWN).replace(/\.?0+$/, '') +} + +export function normalizeAddress(address: string | null | undefined): string | null { + if (!address) return null + if (!isAddress(address)) return null + const checksummed = getAddress(address) + if (checksummed === zeroAddress) return null + return checksummed +} + +export function toCsv(rows: { receiveAddress: string; usdcAmount: string }[]): string { + const header = 'token_type,token_address,receiver,amount,id' + const lines = rows.map( + (row, index) => `erc20,${ARBITRUM_USDC_ADDRESS},${row.receiveAddress},${row.usdcAmount},${index}`, + ) + return [header, ...lines].join('\n') + '\n' +} + +export function buildPayouts( + partners: Map, + affiliatesByCode: Map, +): PartnerPayout[] { + const payouts: PartnerPayout[] = [] + + for (const accrual of partners.values()) { + if (accrual.feesEarnedUsd.lte(0)) continue + + const affiliate = affiliatesByCode.get(accrual.partnerCode) + const receiveAddress = affiliate?.receiveAddress ?? affiliate?.walletAddress + const normalizedReceiveAddress = normalizeAddress(receiveAddress) + + const excludedReason = (() => { + if (!affiliate) return 'no affiliate found for partner code' + if (!normalizedReceiveAddress) return `invalid payout address: ${receiveAddress ?? 'none'}` + return null + })() + + payouts.push({ + ...accrual, + receiveAddress: normalizedReceiveAddress, + included: excludedReason === null, + excludedReason, + usdcAmount: formatUsdc(accrual.feesEarnedUsd), + }) + } + + return payouts.sort((a, b) => b.feesEarnedUsd.comparedTo(a.feesEarnedUsd) ?? 0) +} + +export function buildRecord(input: { + window: PayoutWindow + payouts: PartnerPayout[] + generatedAt: string + unpriceableSwaps: number + anomalies: FeeAnomaly[] + unverified: UnverifiedSwap[] + noAffiliateFee: NoAffiliateFeeSwap[] + partnerBpsUnset: PartnerBpsUnsetSwap[] + unresolvedFee: UnresolvedFeeSwap[] +}): PayoutRecord { + const { + window, + payouts, + generatedAt, + unpriceableSwaps, + anomalies, + unverified, + noAffiliateFee, + partnerBpsUnset, + unresolvedFee, + } = input + const included = payouts.filter((p) => p.included) + const totalUsdc = included.reduce((sum, p) => sum.plus(p.usdcAmount), new BigNumber(0)) + + const warnings: PayoutWarning[] = [ + ...anomalies.map((a) => ({ + type: 'fee-anomaly' as const, + partnerCode: a.partnerCode, + swapId: a.swapId, + reason: a.reason, + })), + ...payouts + .filter((p) => !p.included) + .map((p) => ({ type: 'address' as const, partnerCode: p.partnerCode, swapId: null, reason: p.excludedReason })), + ...unverified.map((u) => ({ + type: 'unverified' as const, + partnerCode: u.partnerCode, + swapId: u.swapId, + reason: `affiliate verification ${u.status} — not paid, inspect before final payout`, + })), + ...partnerBpsUnset.map((u) => ({ + type: 'partner-bps-unset' as const, + partnerCode: u.partnerCode, + swapId: u.swapId, + reason: `partnerBps is 0 (verifiedBps ${u.verifiedBps}) — no partner share configured, excluded`, + })), + ...unresolvedFee.map((n) => ({ + type: 'no-verified-fee' as const, + partnerCode: n.partnerCode, + swapId: n.swapId, + reason: 'no verified on-chain fee — not paid (bps-implied fee is never a payout basis)', + })), + ] + + return { + window: { start: window.start.toISOString(), end: window.end.toISOString(), label: window.label }, + generatedAt, + token: { chain: 'arbitrum', address: ARBITRUM_USDC_ADDRESS, symbol: 'USDC' }, + totals: { + partnersPaid: included.length, + totalUsdc: totalUsdc.toFixed(USDC_DECIMALS), + paidSwaps: included.reduce((sum, p) => sum + p.swapCount, 0), + unpriceableSwaps, + feeAnomalySwaps: anomalies.length, + unverifiedSwaps: unverified.length, + noAffiliateFeeSwaps: noAffiliateFee.length, + partnerBpsUnsetSwaps: partnerBpsUnset.length, + noVerifiedFeeSwaps: unresolvedFee.length, + }, + partners: payouts.map((p) => ({ + partnerCode: p.partnerCode, + receiveAddress: p.receiveAddress, + swapCount: p.swapCount, + volumeUsd: p.volumeUsd.toFixed(2), + feesEarnedUsd: p.feesEarnedUsd.toFixed(USDC_DECIMALS), + usdcAmount: p.usdcAmount, + included: p.included, + excludedReason: p.excludedReason, + })), + warnings, + } +} diff --git a/scripts/jest.config.ts b/scripts/jest.config.ts new file mode 100644 index 0000000..980b532 --- /dev/null +++ b/scripts/jest.config.ts @@ -0,0 +1,11 @@ +import type { Config } from 'jest' + +const config: Config = { + rootDir: '.', + testRegex: '.*\\.test\\.ts$', + transform: { '^.+\\.ts$': 'ts-jest' }, + moduleFileExtensions: ['ts', 'js', 'json'], + testEnvironment: 'node', +} + +export default config diff --git a/tsconfig.json b/tsconfig.json index 08b9853..e2206ce 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,7 +3,7 @@ "target": "ES2020", "module": "CommonJS", "lib": ["ES2020"], - "types": ["node"], + "types": ["node", "jest"], "skipLibCheck": true, "moduleResolution": "node", "resolveJsonModule": true, @@ -12,6 +12,6 @@ "emitDecoratorMetadata": true, "strictPropertyInitialization": false }, - "include": ["apps/**/*", "packages/**/*", "eslint.config.ts", "prisma.config.ts", "scripts/referral-rewards.ts"], + "include": ["apps/**/*", "packages/**/*", "eslint.config.ts", "prisma.config.ts", "scripts/**/*.ts"], "exclude": ["dist", "node_modules"] }