From 96db909f64d239a6054bba919765b43409e70086 Mon Sep 17 00:00:00 2001 From: kaladinlight <35275952+kaladinlight@users.noreply.github.com> Date: Thu, 2 Jul 2026 17:19:50 -0600 Subject: [PATCH 1/3] fix(affiliate): compute partnerFeeUsd exactly via getPartnerFeeUsd helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit feeUsd × getPartnerFeeRate multiplies by a lossy float rate (e.g. 50/60), which can leave binary-float artifacts in the "full precision" string. Add a fee-aware getPartnerFeeUsd helper that multiplies before dividing (feeUsd × partnerBps ÷ verifiedBps, capped at the fee) so the result is exact, and use it in getAffiliateSwaps. getPartnerFeeRate is unchanged for its rate-based callers. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/swap-service/src/affiliate/affiliate.service.ts | 4 ++-- apps/swap-service/src/swaps/utils.ts | 9 +++++++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/apps/swap-service/src/affiliate/affiliate.service.ts b/apps/swap-service/src/affiliate/affiliate.service.ts index 9a20877..ce244c2 100644 --- a/apps/swap-service/src/affiliate/affiliate.service.ts +++ b/apps/swap-service/src/affiliate/affiliate.service.ts @@ -3,7 +3,7 @@ import { Affiliate, Prisma } from '@prisma/client' import { PrismaService } from '../prisma/prisma.service' import { SHAPESHIFT_BPS } from '../swaps/constants' -import { calculateFeeForSwap, getPartnerFeeRate, toSwap } from '../swaps/utils' +import { calculateFeeForSwap, getPartnerFeeRate, getPartnerFeeUsd, toSwap } from '../swaps/utils' import { getNextCursor, swapCursorArgs } from '../utils/pagination' import type { AffiliateStatsResult, CreateAffiliateDto, UpdateAffiliateDto } from './types' @@ -133,7 +133,7 @@ export class AffiliateService { const fee = calculateFeeForSwap(swap) const feeUsd = fee ? fee.feeUsd.toString() : null const volumeUsd = fee ? fee.volumeUsd.toString() : null - const partnerFeeUsd = fee ? (fee.feeUsd * getPartnerFeeRate(fee.verifiedBps, swap.partnerBps)).toString() : null + const partnerFeeUsd = fee ? getPartnerFeeUsd(fee.feeUsd, fee.verifiedBps, swap.partnerBps) : null return { ...swap, feeUsd, partnerFeeUsd, volumeUsd } }) diff --git a/apps/swap-service/src/swaps/utils.ts b/apps/swap-service/src/swaps/utils.ts index e6e0b2a..bd0fbb3 100644 --- a/apps/swap-service/src/swaps/utils.ts +++ b/apps/swap-service/src/swaps/utils.ts @@ -61,6 +61,15 @@ export const getPartnerFeeRate = (verifiedBps: number, partnerBps: number): numb return Math.min(partnerBps / verifiedBps, 1) } +// The partner's share of the affiliate fee in USD, as an exact string. Multiplies before dividing +// (feeUsd × partnerBps ÷ verifiedBps) so the result stays precise — unlike feeUsd × getPartnerFeeRate, +// where the intermediate rate (e.g. 50/60) is a lossy float. Capped at the whole fee, matching the rate cap. +export const getPartnerFeeUsd = (feeUsd: number, verifiedBps: number, partnerBps: number): string => { + if (verifiedBps <= 0) return '0' + const share = bnOrZero(feeUsd).times(partnerBps).div(verifiedBps) + return (share.gt(feeUsd) ? bnOrZero(feeUsd) : share).toString() +} + export const computeSellAmountUsd = ( sellAmountCryptoBaseUnit: string, precision: number, From 04132c9b9de7b33b24901fb3206ce87e94737a7c Mon Sep 17 00:00:00 2001 From: kaladinlight <35275952+kaladinlight@users.noreply.github.com> Date: Thu, 2 Jul 2026 17:21:53 -0600 Subject: [PATCH 2/3] refactor(affiliate): use getPartnerFeeUsd for stats totals too getAffiliateStats now accumulates volume/fees in BigNumber and derives the partner fee via getPartnerFeeUsd, so the aggregate reconciles exactly with the sum of the per-swap partnerFeeUsd rows (no float-accumulation drift). Displayed 2dp output is unchanged. Drops the now-unused getPartnerFeeRate import. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../swap-service/src/affiliate/affiliate.service.ts | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/apps/swap-service/src/affiliate/affiliate.service.ts b/apps/swap-service/src/affiliate/affiliate.service.ts index ce244c2..b978e26 100644 --- a/apps/swap-service/src/affiliate/affiliate.service.ts +++ b/apps/swap-service/src/affiliate/affiliate.service.ts @@ -1,9 +1,10 @@ import { Injectable, NotFoundException } from '@nestjs/common' import { Affiliate, Prisma } from '@prisma/client' +import { bnOrZero } from '@shapeshiftoss/chain-adapters' import { PrismaService } from '../prisma/prisma.service' import { SHAPESHIFT_BPS } from '../swaps/constants' -import { calculateFeeForSwap, getPartnerFeeRate, getPartnerFeeUsd, toSwap } from '../swaps/utils' +import { calculateFeeForSwap, getPartnerFeeUsd, toSwap } from '../swaps/utils' import { getNextCursor, swapCursorArgs } from '../utils/pagination' import type { AffiliateStatsResult, CreateAffiliateDto, UpdateAffiliateDto } from './types' @@ -84,8 +85,8 @@ export class AffiliateService { }) let totalSwaps = 0 - let totalVolumeUsd = 0 - let totalFeesEarnedUsd = 0 + let totalVolumeUsd = bnOrZero(0) + let totalFeesEarnedUsd = bnOrZero(0) for (const item of items) { const swap = toSwap(item) @@ -93,11 +94,9 @@ export class AffiliateService { const fee = calculateFeeForSwap(swap) if (!fee) continue - const rate = getPartnerFeeRate(fee.verifiedBps, swap.partnerBps) - totalSwaps++ - totalVolumeUsd += fee.volumeUsd - totalFeesEarnedUsd += fee.feeUsd * rate + totalVolumeUsd = totalVolumeUsd.plus(fee.volumeUsd) + totalFeesEarnedUsd = totalFeesEarnedUsd.plus(getPartnerFeeUsd(fee.feeUsd, fee.verifiedBps, swap.partnerBps)) } return { From 4c6eb59aa01c1248f65483be4c44e4dc447f0873 Mon Sep 17 00:00:00 2001 From: kaladinlight <35275952+kaladinlight@users.noreply.github.com> Date: Thu, 2 Jul 2026 17:34:26 -0600 Subject: [PATCH 3/3] refactor: consolidate partner-fee math on getPartnerFeeUsd; drop getPartnerFeeRate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migrate the affiliate-payouts script's aggregateByPartner to the exact getPartnerFeeUsd helper (multiply-before-divide) rather than the lossy actualFeeUsd × getPartnerFeeRate float, and gate the partnerBpsUnset bucket on partnerBps directly. The affiliate service already uses getPartnerFeeUsd, so getPartnerFeeRate now has no callers and is removed. Payout amounts are unchanged (floored to 6dp); this just unifies the partner-fee definition and removes the float-artifact source across all three consumers. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/swap-service/src/affiliate/affiliate.service.ts | 1 + apps/swap-service/src/swaps/utils.ts | 9 ++------- scripts/affiliate-payouts/affiliate-payouts.test.ts | 3 ++- scripts/affiliate-payouts/affiliate-payouts.ts | 4 ++-- scripts/affiliate-payouts/types.ts | 2 +- scripts/affiliate-payouts/utils.ts | 6 +++--- 6 files changed, 11 insertions(+), 14 deletions(-) diff --git a/apps/swap-service/src/affiliate/affiliate.service.ts b/apps/swap-service/src/affiliate/affiliate.service.ts index b978e26..f48384c 100644 --- a/apps/swap-service/src/affiliate/affiliate.service.ts +++ b/apps/swap-service/src/affiliate/affiliate.service.ts @@ -1,5 +1,6 @@ import { Injectable, NotFoundException } from '@nestjs/common' import { Affiliate, Prisma } from '@prisma/client' + import { bnOrZero } from '@shapeshiftoss/chain-adapters' import { PrismaService } from '../prisma/prisma.service' diff --git a/apps/swap-service/src/swaps/utils.ts b/apps/swap-service/src/swaps/utils.ts index bd0fbb3..098957d 100644 --- a/apps/swap-service/src/swaps/utils.ts +++ b/apps/swap-service/src/swaps/utils.ts @@ -56,14 +56,9 @@ export const formatAmount = (amount: string | number): string => { .replace(/\.?0+$/, '') } -export const getPartnerFeeRate = (verifiedBps: number, partnerBps: number): number => { - if (verifiedBps <= 0) return 0 - return Math.min(partnerBps / verifiedBps, 1) -} - // The partner's share of the affiliate fee in USD, as an exact string. Multiplies before dividing -// (feeUsd × partnerBps ÷ verifiedBps) so the result stays precise — unlike feeUsd × getPartnerFeeRate, -// where the intermediate rate (e.g. 50/60) is a lossy float. Capped at the whole fee, matching the rate cap. +// (feeUsd × partnerBps ÷ verifiedBps) so the result stays precise — computing a partnerBps/verifiedBps +// rate first (e.g. 50/60) would introduce lossy-float artifacts. Capped at the whole fee. export const getPartnerFeeUsd = (feeUsd: number, verifiedBps: number, partnerBps: number): string => { if (verifiedBps <= 0) return '0' const share = bnOrZero(feeUsd).times(partnerBps).div(verifiedBps) diff --git a/scripts/affiliate-payouts/affiliate-payouts.test.ts b/scripts/affiliate-payouts/affiliate-payouts.test.ts index edc4010..f552676 100644 --- a/scripts/affiliate-payouts/affiliate-payouts.test.ts +++ b/scripts/affiliate-payouts/affiliate-payouts.test.ts @@ -36,7 +36,8 @@ const stubDeps: FeeDeps = { 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)), + getPartnerFeeUsd: (feeUsd, verifiedBps, partnerBps) => + verifiedBps <= 0 ? '0' : BigNumber.min(new BigNumber(feeUsd).times(partnerBps).div(verifiedBps), feeUsd).toString(), } const accrual = (over: Partial & Pick): PartnerAccrual => ({ diff --git a/scripts/affiliate-payouts/affiliate-payouts.ts b/scripts/affiliate-payouts/affiliate-payouts.ts index f176e4b..497e9aa 100644 --- a/scripts/affiliate-payouts/affiliate-payouts.ts +++ b/scripts/affiliate-payouts/affiliate-payouts.ts @@ -2,7 +2,7 @@ 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 { calculateFeeForSwap, getPartnerFeeUsd, toSwap } from '../../apps/swap-service/src/swaps/utils' import type { PartnerPayout, PayoutRecord } from './types' import { aggregateByPartner, buildPayouts, buildRecord, resolveWindow, toCsv } from './utils' @@ -88,7 +88,7 @@ async function generate(monthArg: string | undefined, force: boolean): Promise = { toSwap: (row: PrismaSwap) => S calculateFeeForSwap: (swap: S) => FeeResult | null - getPartnerFeeRate: (verifiedBps: number, partnerBps: number) => number + getPartnerFeeUsd: (feeUsd: number, verifiedBps: number, partnerBps: number) => string } export type PayoutWarning = { diff --git a/scripts/affiliate-payouts/utils.ts b/scripts/affiliate-payouts/utils.ts index 91fcbd9..b71cc5d 100644 --- a/scripts/affiliate-payouts/utils.ts +++ b/scripts/affiliate-payouts/utils.ts @@ -141,8 +141,7 @@ export function aggregateByPartner( continue } - const rate = deps.getPartnerFeeRate(fee.verifiedBps, row.partnerBps) - if (rate <= 0) { + if (row.partnerBps <= 0) { partnerBpsUnset.push({ swapId: row.swapId, partnerCode, @@ -159,9 +158,10 @@ export function aggregateByPartner( feesEarnedUsd: new BigNumber(0), } + // 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(new BigNumber(fee.actualFeeUsd).times(rate)) + accrual.feesEarnedUsd = accrual.feesEarnedUsd.plus(deps.getPartnerFeeUsd(fee.actualFeeUsd, fee.verifiedBps, row.partnerBps)) partners.set(partnerCode.toLowerCase(), accrual) }