From 5e8aed3bf06b471112faceaafdc28051ebc9ed22 Mon Sep 17 00:00:00 2001 From: kaladinlight <35275952+kaladinlight@users.noreply.github.com> Date: Tue, 30 Jun 2026 14:59:33 -0600 Subject: [PATCH 1/5] docs(scripts): design for affiliate monthly USDC payout script Co-Authored-By: Claude Opus 4.8 --- ...30-affiliate-monthly-usdc-payout-design.md | 186 ++++++++++++++++++ 1 file changed, 186 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-30-affiliate-monthly-usdc-payout-design.md diff --git a/docs/superpowers/specs/2026-06-30-affiliate-monthly-usdc-payout-design.md b/docs/superpowers/specs/2026-06-30-affiliate-monthly-usdc-payout-design.md new file mode 100644 index 0000000..662d15d --- /dev/null +++ b/docs/superpowers/specs/2026-06-30-affiliate-monthly-usdc-payout-design.md @@ -0,0 +1,186 @@ +# Affiliate Monthly USDC Payout Script — Design + +**Date:** 2026-06-30 +**Status:** Approved (design), pending implementation plan +**Author:** kaladinlight (+ Claude) + +## Purpose + +Generate a Gnosis Safe CSV-airdrop file that pays each affiliate partner their +earned USDC revenue for a calendar month, aggregated by `partnerCode`, plus a +machine-readable run record that a later settlement-tracking feature can build +on. + +Payouts are USDC on **Arbitrum One**, imported via the Safe CSV-airdrop app +(same target as `~/github/shapeshift/rFOX`). + +## Context / prior art + +- **`scripts/referral-rewards.ts`** (+ `yarn referral-rewards`) is the existing + precedent for period-windowed payout scripts in this repo. We mirror its + shape (root `scripts/` dir, `yarn` entry, ISO date args, output artifacts in a + sibling dir). +- **rFOX `cli/src/safeWallet.ts`** defines the Safe CSV format we target. +- **`apps/swap-service`** owns the swap + affiliate data and the fee math we reuse. + +## Data model (existing, swap-service / Postgres / Prisma) + +- `Swap` (`swaps` table): `partnerCode` (FK → `Affiliate.partnerCode`), `status`, + `isAffiliateVerified`, `createdAt`, `partnerBps`, `affiliateVerificationDetails` + (`{ hasAffiliate, affiliateBps, verifiedSellAmountCryptoBaseUnit, ... }`), + `actualAffiliateFeeAmountCryptoBaseUnit`, `affiliateFeeAssetId`, `sellAssetUsd`, + `buyAssetUsd`, `affiliateAssetUsd`. +- `Affiliate` (`affiliates` table): `partnerCode` (unique), `walletAddress` + (unique, SIWE/EVM), `receiveAddress` (optional, free-form Citext), `bps`. +- Payout destination for a partner = **current** `receiveAddress ?? walletAddress` + (the live address, NOT the per-swap `partnerAddress` snapshot — we pay where the + partner wants funds now). +- Fee math lives in `apps/swap-service/src/swaps/utils.ts`: + - `calculateFeeForSwap(swap) -> { feeUsd, volumeUsd, verifiedBps } | null` + - `getPartnerFeeRate(verifiedBps, partnerBps) -> min(partnerBps/verifiedBps, 1)` + - `toSwap(prismaSwap) -> Swap` (deserializes JSON columns) +- `AffiliateService.getAffiliateStats` already performs the per-partner + aggregation we want; this script generalizes it across all partners and emits + payout artifacts. + +## Scope decisions (locked) + +| Decision | Choice | +|---|---| +| Eligible swaps | `status='SUCCESS' AND isAffiliateVerified=true`, **all origins** (web + api). Matches the affiliate `/stats` endpoint partners already see. | +| Minimum payout | **No minimum** — any partner with `feesEarnedUsd > 0` and a valid address gets a row. | +| Invalid/non-EVM recipient | **Exclude from CSV + warn** (listed in summary and JSON). Run still succeeds. | +| Location / invocation | `scripts/affiliate-payouts.ts`, wired as `yarn affiliate-payouts`. | +| USD → USDC | Treated **1:1**, valued at swap time and summed over the window. | + +## Invocation + +```bash +yarn affiliate-payouts generate [startDate] [endDate] +``` + +- **No args** → previous calendar month in **UTC**. Run on 2026-07-01 → covers + `2026-06-01T00:00:00Z` (inclusive) to `2026-07-01T00:00:00Z` (**exclusive**). +- Optional ISO date args override the window. End is always treated as + **exclusive** (`gte: start, lt: end`) to avoid boundary double-counting. + +## Computation + +1. **Query once:** + ```ts + prisma.swap.findMany({ + where: { + partnerCode: { not: null }, + status: 'SUCCESS', + isAffiliateVerified: true, + createdAt: { gte: start, lt: end }, + }, + }) + ``` +2. **Group by `partnerCode`.** For each swap, run + `calculateFeeForSwap(toSwap(swap))`. If it returns `null` (missing/unpriceable + verification details), skip the swap and increment a `skippedSwaps` counter. + Otherwise: + ```ts + const rate = getPartnerFeeRate(fee.verifiedBps, swap.partnerBps) + partner.feesEarnedUsd += fee.feeUsd * rate + partner.volumeUsd += fee.volumeUsd + partner.swapCount += 1 + ``` + Reusing the app functions keeps payout numbers identical to the affiliate + `/stats` dashboard (single source of truth). +3. **Resolve addresses:** for each `partnerCode`, look up the `Affiliate` and take + `receiveAddress ?? walletAddress`. +4. **Convert to USDC:** USD amount is treated 1:1 with USDC. Floor each partner + total to **6 dp** (USDC precision) so we never overpay. Use `BigNumber` for the + formatting (avoid float drift). + +## Address validation + +- A recipient is valid if it is a well-formed EVM address. Prefer `viem`'s + `isAddress` / `getAddress` (checksum) if present in the workspace; otherwise a + `^0x[0-9a-fA-F]{40}$` regex fallback. +- Partners failing validation are **excluded from the CSV** and recorded as + warnings (with `excludedReason`) in the summary and JSON record. The run still + exits 0. + +## Outputs + +Written to `payouts/` at repo root (add to `.gitignore` if not already ignored). + +1. **`affiliate-payouts-.csv`** — Safe CSV-airdrop format: + ``` + token_type,token_address,receiver,amount,id + erc20,0xaf88d065e77c8cC2239327C5EDb3A432268e5831,,, + ``` + - `token_address` = Arbitrum USDC `0xaf88d065e77c8cC2239327C5EDb3A432268e5831`. + - `amount` = human-decimal USDC (≤6 dp). + - `id` = sequential index starting at 0. + - Only included (valid-address, `>0`) partners appear, sorted by + `feesEarnedUsd` descending. + +2. **`affiliate-payouts-.json`** — full run record: + ```jsonc + { + "window": { "start": "...Z", "end": "...Z", "label": "2026-06" }, + "generatedAt": "...Z", + "token": { "chain": "arbitrum", "address": "0xaf88...5831", "symbol": "USDC" }, + "totals": { + "partnersPaid": 0, + "totalUsdc": "0.000000", + "eligibleSwaps": 0, + "skippedSwaps": 0 + }, + "partners": [ + { + "partnerCode": "...", + "receiveAddress": "0x...", + "swapCount": 0, + "volumeUsd": "0.00", + "feesEarnedUsd": "0.000000", + "included": true, + "excludedReason": null + } + ], + "warnings": [ { "partnerCode": "...", "reason": "invalid receive address: ..." } ] + } + ``` + This is the seam the future settlement-tracking feature builds on. + +3. **Console summary** — window, total USDC, partner count, top partners by + earnings, skipped-swap count, and any warnings. + +## Module structure + +Single-file script is acceptable (mirrors `referral-rewards.ts`), but factor pure +helpers so they are independently testable: + +- `resolveWindow(args): { start, end, label }` — UTC previous-month default + ISO override, end-exclusive. +- `aggregateByPartner(swaps): Map` — pure; uses `calculateFeeForSwap` / `getPartnerFeeRate`. +- `toCsv(rows): string` — pure; Safe format. +- `formatUsdc(usd): string` — floor to 6 dp via BigNumber. +- `isValidRecipient(addr): boolean` / `normalizeRecipient(addr): string`. +- `main()` — wires Prisma query → aggregate → resolve addresses → validate → write artifacts → print summary; `prisma.$disconnect()` in `finally`. + +## Testing + +- Unit-test the pure helpers (no DB): `resolveWindow` (default UTC month + + explicit override + exclusivity), `aggregateByPartner` (rate capping, skipped + unpriceable swaps, multi-swap accrual), `formatUsdc` (6-dp floor, no float + drift), `toCsv` (header + row shape + indices), address validation + (valid/invalid/checksum). +- Test file: `scripts/affiliate-payouts.test.ts` (`*.test.ts` convention). + +## Out of scope (next conversation) + +Settlement tracking — idempotency, double-pay protection, marking a window as +paid, recording the executed Safe tx. The JSON run record is the foundation; the +actual tracking design comes after this script lands. + +## Open implementation notes + +- Confirm `viem` availability in the workspace for checksum validation; fall back + to regex if absent. +- Importing app fee math into a root `scripts/` file couples the script to + `apps/swap-service` internals. Accepted tradeoff for a single source of truth; + revisit only if the script needs to run without the app present. From 05ab3148dde599447d090dc0ece2d1c3601db75c Mon Sep 17 00:00:00 2001 From: kaladinlight <35275952+kaladinlight@users.noreply.github.com> Date: Tue, 30 Jun 2026 15:38:00 -0600 Subject: [PATCH 2/5] feat(scripts): affiliate monthly USDC payout generator Aggregates verified successful swaps by partnerCode for a month window and emits a Gnosis Safe CSV-airdrop file (USDC on Arbitrum) plus a JSON run record. Uses the on-chain verified affiliate fee, guarded by a deviation check against the bps-implied fee (volume x verifiedBps): swaps whose on-chain fee is wildly off (e.g. MayaChain rows that label the fee asset USDC but collect CACAO) are flagged and excluded rather than paid. Extends calculateFeeForSwap to expose actualFeeUsd + impliedFeeUsd (additive, backward-compatible) so the guard can compare them. Run: yarn affiliate-payouts generate [startDate] [endDate] Test: yarn affiliate-payouts:test Co-Authored-By: Claude Opus 4.8 --- .gitignore | 3 + apps/swap-service/src/swaps/utils.ts | 20 +- ...30-affiliate-monthly-usdc-payout-design.md | 91 ++++-- package.json | 4 +- scripts/affiliate-payouts-lib.ts | 266 ++++++++++++++++++ scripts/affiliate-payouts.test.ts | 193 +++++++++++++ scripts/affiliate-payouts.ts | 125 ++++++++ scripts/jest.config.ts | 11 + tsconfig.json | 2 +- 9 files changed, 690 insertions(+), 25 deletions(-) create mode 100644 scripts/affiliate-payouts-lib.ts create mode 100644 scripts/affiliate-payouts.test.ts create mode 100644 scripts/affiliate-payouts.ts create mode 100644 scripts/jest.config.ts diff --git a/.gitignore b/.gitignore index 9abd83e..f49030c 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,9 @@ node_modules .turbo /generated/prisma +# Payout artifacts (contain partner addresses + amounts) +/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 65acc69..6d5db28 100644 --- a/apps/swap-service/src/swaps/utils.ts +++ b/apps/swap-service/src/swaps/utils.ts @@ -136,7 +136,18 @@ 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 + // The on-chain collected fee (null when unavailable) and the bps-implied fee + // (volume × verifiedBps; null when volume can't be priced). Exposed so consumers + // can guard against on-chain fee amounts that don't align with the implied fee. + 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`) @@ -162,8 +173,11 @@ export const calculateFeeForSwap = (swap: Swap): { feeUsd: number; volumeUsd: nu return null } - const feeUsd = actualFeeUsd ?? bnOrZero(sellAmountUsd).times(verifiedBps).div(BPS_DENOMINATOR).toNumber() + const impliedFeeUsd = + sellAmountUsd === null ? null : bnOrZero(sellAmountUsd).times(verifiedBps).div(BPS_DENOMINATOR).toNumber() + + const feeUsd = actualFeeUsd ?? impliedFeeUsd ?? 0 const volumeUsd = sellAmountUsd ?? bnOrZero(actualFeeUsd).times(BPS_DENOMINATOR).div(verifiedBps).toNumber() - return { feeUsd, volumeUsd, verifiedBps } + return { feeUsd, volumeUsd, verifiedBps, actualFeeUsd, impliedFeeUsd } } diff --git a/docs/superpowers/specs/2026-06-30-affiliate-monthly-usdc-payout-design.md b/docs/superpowers/specs/2026-06-30-affiliate-monthly-usdc-payout-design.md index 662d15d..c981257 100644 --- a/docs/superpowers/specs/2026-06-30-affiliate-monthly-usdc-payout-design.md +++ b/docs/superpowers/specs/2026-06-30-affiliate-monthly-usdc-payout-design.md @@ -48,6 +48,7 @@ Payouts are USDC on **Arbitrum One**, imported via the Safe CSV-airdrop app | Decision | Choice | |---|---| | Eligible swaps | `status='SUCCESS' AND isAffiliateVerified=true`, **all origins** (web + api). Matches the affiliate `/stats` endpoint partners already see. | +| Fee basis | **On-chain verified actual fee**, guarded by a deviation check vs. the bps-implied fee; anomalies are flagged + excluded (see Fee-deviation guard). | | Minimum payout | **No minimum** — any partner with `feesEarnedUsd > 0` and a valid address gets a row. | | Invalid/non-EVM recipient | **Exclude from CSV + warn** (listed in summary and JSON). Run still succeeds. | | Location / invocation | `scripts/affiliate-payouts.ts`, wired as `yarn affiliate-payouts`. | @@ -80,15 +81,45 @@ yarn affiliate-payouts generate [startDate] [endDate] 2. **Group by `partnerCode`.** For each swap, run `calculateFeeForSwap(toSwap(swap))`. If it returns `null` (missing/unpriceable verification details), skip the swap and increment a `skippedSwaps` counter. - Otherwise: + Otherwise apply the **fee-deviation guard** (below), and if it passes: ```ts const rate = getPartnerFeeRate(fee.verifiedBps, swap.partnerBps) - partner.feesEarnedUsd += fee.feeUsd * rate + partner.feesEarnedUsd += fee.feeUsd * rate // fee.feeUsd = on-chain actual when present partner.volumeUsd += fee.volumeUsd partner.swapCount += 1 ``` - Reusing the app functions keeps payout numbers identical to the affiliate + Reusing the app functions keeps payout numbers consistent with the affiliate `/stats` dashboard (single source of truth). + +### Fee-deviation guard (money-correctness) + +The payout uses the **on-chain verified affiliate fee** (`actualAffiliateFeeAmountCryptoBaseUnit` +→ `fee.actualFeeUsd`) when present, because that is what was actually collected. But that +field is **not always trustworthy**: some swaps record a fee asset / amount that doesn't match +what was really taken. Confirmed example: **MayaChain swaps** (affiliate `ssmaya`) label the +affiliate fee asset as USDC while the fee is actually collected in **CACAO**, so the stored +base-unit amount, priced/scaled as USDC, produces a wildly wrong USD fee (observed: **$39,020 +"fee" on a $100 swap**, which would have paid a partner ~$29k). + +To catch this, `calculateFeeForSwap` is extended to also expose `actualFeeUsd` and +`impliedFeeUsd` (`= verifiedVolumeUsd × verifiedBps / 10000`). For each swap with an on-chain +fee, the script compares the two: + +``` +deviation = |actualFeeUsd - impliedFeeUsd| / impliedFeeUsd +``` + +- An on-chain fee never equals the implied fee exactly (quote→execution price drift, partial / + streaming fills, fee-asset conversion), so a relative tolerance band is allowed: + `FEE_DEVIATION_TOLERANCE = 0.5` (±50%, tunable). +- If `deviation > tolerance`, **or** the implied fee can't be computed (volume unpriceable), + the swap is treated as an **anomaly**: excluded from the partner's total and recorded in + `warnings`. The partner is still paid for their other, non-anomalous swaps. +- Swaps with **no** on-chain fee are not guarded — `fee.feeUsd` already equals the bps-implied + fee, which is the trusted value. + +This diverges from the current `/stats` numbers for affected swaps — by design, because +`/stats` would surface the same corrupt figures. 3. **Resolve addresses:** for each `partnerCode`, look up the `Affiliate` and take `receiveAddress ?? walletAddress`. 4. **Convert to USDC:** USD amount is treated 1:1 with USDC. Floor each partner @@ -129,7 +160,8 @@ Written to `payouts/` at repo root (add to `.gitignore` if not already ignored). "partnersPaid": 0, "totalUsdc": "0.000000", "eligibleSwaps": 0, - "skippedSwaps": 0 + "skippedSwaps": 0, + "anomalousSwaps": 0 }, "partners": [ { @@ -138,11 +170,15 @@ Written to `payouts/` at repo root (add to `.gitignore` if not already ignored). "swapCount": 0, "volumeUsd": "0.00", "feesEarnedUsd": "0.000000", + "usdcAmount": "0", "included": true, "excludedReason": null } ], - "warnings": [ { "partnerCode": "...", "reason": "invalid receive address: ..." } ] + "warnings": [ + { "type": "fee-anomaly", "partnerCode": "...", "swapId": "...", "reason": "on-chain fee ... deviates ...% from bps-implied ..." }, + { "type": "address", "partnerCode": "...", "swapId": null, "reason": "invalid (non-EVM) payout address: ..." } + ] } ``` This is the seam the future settlement-tracking feature builds on. @@ -152,24 +188,39 @@ Written to `payouts/` at repo root (add to `.gitignore` if not already ignored). ## Module structure -Single-file script is acceptable (mirrors `referral-rewards.ts`), but factor pure -helpers so they are independently testable: - -- `resolveWindow(args): { start, end, label }` — UTC previous-month default + ISO override, end-exclusive. -- `aggregateByPartner(swaps): Map` — pure; uses `calculateFeeForSwap` / `getPartnerFeeRate`. -- `toCsv(rows): string` — pure; Safe format. -- `formatUsdc(usd): string` — floor to 6 dp via BigNumber. -- `isValidRecipient(addr): boolean` / `normalizeRecipient(addr): string`. -- `main()` — wires Prisma query → aggregate → resolve addresses → validate → write artifacts → print summary; `prisma.$disconnect()` in `finally`. +Split into a pure, dependency-light lib (jest-testable) and a thin IO entry, because the +swap-service fee math transitively imports ESM-only packages (`@shapeshiftoss/chain-adapters` +→ `p-queue`) that jest won't transform. The lib never imports the app graph; the entry injects +the real fee functions. + +- **`scripts/affiliate-payouts-lib.ts`** — pure (only `bignumber.js` + `viem`): + - `resolveWindow(start?, end?, now?)` — UTC previous-month default + ISO override, end-exclusive. + - `aggregateByPartner(rows, deps, tolerance?)` — groups + accrues; `deps` injects + `{ toSwap, calculateFeeForSwap, getPartnerFeeRate }` so it's testable without the app graph. + Returns `{ partners, skippedSwaps, anomalies }`. + - `checkFeeAnomaly(row, fee, tolerance)` — the deviation guard; returns a `FeeAnomaly` or null. + - `formatUsdc(usd)` — floor to 6 dp via BigNumber, strip trailing zeros. + - `normalizeRecipient(addr)` — viem `isAddress` / `getAddress` checksum; null if invalid. + - `toCsv(rows)`, `buildPayouts(...)`, `buildRecord(...)`. +- **`scripts/affiliate-payouts.ts`** — entry: `PrismaClient`, the real `toSwap` / + `calculateFeeForSwap` / `getPartnerFeeRate` from `apps/swap-service/src/swaps/utils`, plus + `printSummary` / `writeArtifacts` / `generate` / `main`; `prisma.$disconnect()` in `finally`. + Run via `ts-node --transpile-only` (Node 22 `require(esm)` handles the ESM deps at runtime). +- **App change:** `calculateFeeForSwap` (in `swaps/utils.ts`) extended to also return + `actualFeeUsd` and `impliedFeeUsd` (additive, backward-compatible) so the guard can compare them. ## Testing -- Unit-test the pure helpers (no DB): `resolveWindow` (default UTC month + - explicit override + exclusivity), `aggregateByPartner` (rate capping, skipped - unpriceable swaps, multi-swap accrual), `formatUsdc` (6-dp floor, no float - drift), `toCsv` (header + row shape + indices), address validation - (valid/invalid/checksum). -- Test file: `scripts/affiliate-payouts.test.ts` (`*.test.ts` convention). +- Unit-test the lib helpers (no DB, jest via `scripts/jest.config.ts`, `*.test.ts`): + `resolveWindow` (default UTC month + override + exclusivity + arg validation), + `aggregateByPartner` (rate capping, skipped unpriceable swaps, multi-swap accrual, + anomaly exclusion, partial-partner payout), `checkFeeAnomaly` (within/over tolerance, + no-actual, missing-implied), `formatUsdc` (6-dp floor), `toCsv` (header + indices), + `normalizeRecipient` (valid/invalid/checksum), `buildPayouts` (address exclusion + sort). + `aggregateByPartner` / `checkFeeAnomaly` use stub fee deps to stay off the app graph. +- Test command: `yarn affiliate-payouts:test`. +- Integration: verified end-to-end against a live DB snapshot for 2026-06 — the guard + excluded the Maya/`ssmaya` corrupt-fee swaps (total $29,265 → $0.05). ## Out of scope (next conversation) diff --git a/package.json b/package.json index f73a20e..eb22a41 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.ts", + "affiliate-payouts:test": "jest --config scripts/jest.config.ts" }, "dependencies": { "@bitcoinerlab/secp256k1": "^1.1.1", diff --git a/scripts/affiliate-payouts-lib.ts b/scripts/affiliate-payouts-lib.ts new file mode 100644 index 0000000..876de5c --- /dev/null +++ b/scripts/affiliate-payouts-lib.ts @@ -0,0 +1,266 @@ +import type { Swap as PrismaSwap } from '@prisma/client' +import BigNumber from 'bignumber.js' +import { getAddress, isAddress } from 'viem' + +// USDC on Arbitrum One — payouts are imported into the Safe CSV-airdrop app on arbitrum. +export const ARBITRUM_USDC_ADDRESS = '0xaf88d065e77c8cC2239327C5EDb3A432268e5831' +export const USDC_DECIMALS = 6 + +// A swap's on-chain affiliate fee should land near the bps-implied fee (volume × verifiedBps), +// but never matches exactly — price drift between quote and execution, partial/streaming fills, +// and fee-asset conversion all introduce slack. Beyond this relative band we treat the on-chain +// amount as untrustworthy (corrupt/misreported), flag it, and exclude the swap from payout. +export const FEE_DEVIATION_TOLERANCE = 0.5 + +export type PartnerAccrual = { + partnerCode: string + swapCount: number + volumeUsd: number + feesEarnedUsd: number +} + +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 +} + +// Injected so the aggregation can be unit-tested without loading the swap-service module graph. +export type FeeDeps = { + toSwap: (row: PrismaSwap) => S + calculateFeeForSwap: (swap: S) => FeeResult | null + getPartnerFeeRate: (verifiedBps: number, partnerBps: number) => number +} + +const pad2 = (n: number): string => String(n).padStart(2, '0') +const monthLabel = (d: Date): string => `${d.getUTCFullYear()}-${pad2(d.getUTCMonth() + 1)}` + +// Default window is the previous calendar month in UTC, end-exclusive (gte start, lt end). +// Explicit ISO args override it. `now` is injectable for testing. +export const resolveWindow = (startArg?: string, endArg?: string, now: Date = new Date()): PayoutWindow => { + if (startArg || endArg) { + if (!startArg || !endArg) throw new Error('Provide both startDate and endDate, or neither.') + const start = new Date(startArg) + const end = new Date(endArg) + if (Number.isNaN(start.getTime())) throw new Error(`Invalid startDate: ${startArg}`) + if (Number.isNaN(end.getTime())) throw new Error(`Invalid endDate: ${endArg}`) + if (end <= start) throw new Error('endDate must be after startDate.') + return { start, end, label: monthLabel(start) } + } + + const start = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - 1, 1)) + const end = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)) + return { start, end, label: monthLabel(start) } +} + +// Returns an anomaly when a swap reports an on-chain fee that doesn't align with its +// bps-implied fee (or can't be validated against one). null means the on-chain fee is +// trustworthy — or absent, in which case the bps-implied fee is used as-is. +export const checkFeeAnomaly = ( + row: { swapId: string; partnerCode: string }, + fee: FeeResult, + tolerance: number, +): FeeAnomaly | null => { + if (fee.actualFeeUsd === null) return null // no on-chain amount to distrust; implied fee is used + + const base = { swapId: row.swapId, partnerCode: row.partnerCode, actualFeeUsd: fee.actualFeeUsd, volumeUsd: fee.volumeUsd } + + if (fee.impliedFeeUsd === null || fee.impliedFeeUsd <= 0) { + return { ...base, impliedFeeUsd: fee.impliedFeeUsd, 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, + impliedFeeUsd: fee.impliedFeeUsd, + 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 +} + +// Sum each partner's earned fee share using the injected swap-service fee math. Swaps whose +// on-chain fee fails the deviation guard are excluded and returned as anomalies; unpriceable +// swaps are skipped. +export const aggregateByPartner = ( + rows: PrismaSwap[], + deps: FeeDeps, + tolerance: number = FEE_DEVIATION_TOLERANCE, +): { partners: Map; skippedSwaps: number; anomalies: FeeAnomaly[] } => { + const partners = new Map() + const anomalies: FeeAnomaly[] = [] + let skippedSwaps = 0 + + for (const row of rows) { + if (!row.partnerCode) continue + + const fee = deps.calculateFeeForSwap(deps.toSwap(row)) + if (!fee) { + skippedSwaps++ + continue + } + + const anomaly = checkFeeAnomaly(row, fee, tolerance) + if (anomaly) { + anomalies.push(anomaly) + continue + } + + const rate = deps.getPartnerFeeRate(fee.verifiedBps, row.partnerBps) + + const accrual = partners.get(row.partnerCode) ?? { + partnerCode: row.partnerCode, + swapCount: 0, + volumeUsd: 0, + feesEarnedUsd: 0, + } + + accrual.swapCount += 1 + accrual.volumeUsd += fee.volumeUsd + accrual.feesEarnedUsd += fee.feeUsd * rate + partners.set(row.partnerCode, accrual) + } + + return { partners, skippedSwaps, anomalies } +} + +// USD is paid 1:1 as USDC, floored to 6 dp (USDC precision), trailing zeros stripped. +export const formatUsdc = (usd: number): string => + new BigNumber(usd) + .toFixed(USDC_DECIMALS, BigNumber.ROUND_DOWN) + .replace(/\.?0+$/, '') + +// null for missing/non-EVM addresses; otherwise the checksummed address. +export const normalizeRecipient = (address: string | null | undefined): string | null => { + if (!address) return null + if (!isAddress(address)) return null + return getAddress(address) +} + +export const 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 const buildPayouts = ( + partners: Map, + affiliatesByCode: Map, +): PartnerPayout[] => { + const payouts: PartnerPayout[] = [] + + for (const accrual of partners.values()) { + if (accrual.feesEarnedUsd <= 0) continue + + const affiliate = affiliatesByCode.get(accrual.partnerCode) + const rawAddress = affiliate ? (affiliate.receiveAddress ?? affiliate.walletAddress) : null + const receiveAddress = normalizeRecipient(rawAddress) + + let excludedReason: string | null = null + if (!affiliate) excludedReason = 'no affiliate found for partner code' + else if (!receiveAddress) excludedReason = `invalid (non-EVM) payout address: ${rawAddress ?? 'none'}` + + payouts.push({ + ...accrual, + receiveAddress, + included: excludedReason === null, + excludedReason, + usdcAmount: formatUsdc(accrual.feesEarnedUsd), + }) + } + + return payouts.sort((a, b) => b.feesEarnedUsd - a.feesEarnedUsd) +} + +export type PayoutWarning = { + type: 'fee-anomaly' | 'address' + 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; eligibleSwaps: number; skippedSwaps: number; anomalousSwaps: number } + partners: { + partnerCode: string + receiveAddress: string | null + swapCount: number + volumeUsd: string + feesEarnedUsd: string + usdcAmount: string + included: boolean + excludedReason: string | null + }[] + warnings: PayoutWarning[] +} + +export const buildRecord = ( + window: PayoutWindow, + payouts: PartnerPayout[], + skippedSwaps: number, + anomalies: FeeAnomaly[], + generatedAt: string, +): PayoutRecord => { + 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 })), + ] + + 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), + eligibleSwaps: payouts.reduce((sum, p) => sum + p.swapCount, 0), + skippedSwaps, + anomalousSwaps: anomalies.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/affiliate-payouts.test.ts b/scripts/affiliate-payouts.test.ts new file mode 100644 index 0000000..0ec855e --- /dev/null +++ b/scripts/affiliate-payouts.test.ts @@ -0,0 +1,193 @@ +import type { Swap as PrismaSwap } from '@prisma/client' +import { getAddress } from 'viem' + +import { + aggregateByPartner, + buildPayouts, + checkFeeAnomaly, + type FeeDeps, + type FeeResult, + formatUsdc, + normalizeRecipient, + resolveWindow, + toCsv, +} from './affiliate-payouts-lib' + +type RowExtras = { swapId?: string; priceable?: boolean; fee?: Partial } + +const makeRow = (overrides: Partial & RowExtras = {}): PrismaSwap => + ({ swapId: 's1', partnerCode: 'acme', partnerBps: 30, 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) => Math.min(partnerBps / verifiedBps, 1), +} + +describe('resolveWindow', () => { + it('defaults to the previous calendar month in UTC, end-exclusive', () => { + const { start, end, label } = resolveWindow(undefined, 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, 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 explicit ISO date args', () => { + const { start, end, label } = resolveWindow('2026-03-01', '2026-04-01') + expect(start.toISOString()).toBe('2026-03-01T00:00:00.000Z') + expect(end.toISOString()).toBe('2026-04-01T00:00:00.000Z') + expect(label).toBe('2026-03') + }) + + it('rejects a single date arg, invalid dates, and inverted ranges', () => { + expect(() => resolveWindow('2026-03-01')).toThrow() + expect(() => resolveWindow('not-a-date', '2026-04-01')).toThrow() + expect(() => resolveWindow('2026-04-01', '2026-03-01')).toThrow() + }) +}) + +describe('aggregateByPartner', () => { + it('sums a partner share across swaps', () => { + const { partners, skippedSwaps, anomalies } = aggregateByPartner([makeRow(), makeRow()], stubDeps) + const acme = partners.get('acme') + expect(skippedSwaps).toBe(0) + expect(anomalies).toHaveLength(0) + expect(acme?.swapCount).toBe(2) + expect(acme?.volumeUsd).toBeCloseTo(4000) + // $12 fee * (30/60) = $6 per swap → $12 total + expect(acme?.feesEarnedUsd).toBeCloseTo(12) + }) + + it('caps the partner share at 100% when partnerBps exceeds verifiedBps', () => { + const { partners } = aggregateByPartner([makeRow({ partnerBps: 120 })], stubDeps) + expect(partners.get('acme')?.feesEarnedUsd).toBeCloseTo(12) + }) + + it('skips swaps that cannot be priced', () => { + const { partners, skippedSwaps } = aggregateByPartner([makeRow({ priceable: false })], stubDeps) + expect(skippedSwaps).toBe(1) + expect(partners.size).toBe(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).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('passes when no on-chain fee is present (implied fee used as-is)', () => { + expect(checkFeeAnomaly(row, fee({ actualFeeUsd: null }), 0.5)).toBeNull() + }) + + it('passes when on-chain fee is within tolerance of implied', () => { + expect(checkFeeAnomaly(row, fee({ actualFeeUsd: 13, impliedFeeUsd: 12 }), 0.5)).toBeNull() + }) + + it('flags when on-chain fee exceeds the deviation band', () => { + const anomaly = checkFeeAnomaly(row, fee({ actualFeeUsd: 39020, impliedFeeUsd: 0.4 }), 0.5) + expect(anomaly?.deviation).toBeGreaterThan(0.5) + 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.5) + 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') + }) +}) + +describe('normalizeRecipient', () => { + it('checksums valid EVM addresses and rejects everything else', () => { + const lower = '0xd8da6bf26964af9d7eed9e03e53415d37aa96045' + expect(normalizeRecipient(lower)).toBe(getAddress(lower)) + expect(normalizeRecipient(null)).toBeNull() + expect(normalizeRecipient('not-an-address')).toBeNull() + expect(normalizeRecipient('cosmos1abc')).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', { partnerCode: 'acme', swapCount: 1, volumeUsd: 2000, feesEarnedUsd: 6 }], + ['big', { partnerCode: 'big', swapCount: 5, volumeUsd: 9000, feesEarnedUsd: 50 }], + ['bad', { partnerCode: 'bad', swapCount: 1, volumeUsd: 100, feesEarnedUsd: 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(/non-EVM/) + }) +}) diff --git a/scripts/affiliate-payouts.ts b/scripts/affiliate-payouts.ts new file mode 100644 index 0000000..3fdeac8 --- /dev/null +++ b/scripts/affiliate-payouts.ts @@ -0,0 +1,125 @@ +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 { + aggregateByPartner, + buildPayouts, + buildRecord, + type PartnerPayout, + type PayoutRecord, + resolveWindow, + toCsv, +} from './affiliate-payouts-lib' + +const prisma = new PrismaClient() + +const 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( + `Eligible swaps: ${record.totals.eligibleSwaps} | Skipped (unpriceable): ${record.totals.skippedSwaps} | Fee anomalies excluded: ${record.totals.anomalousSwaps}`, + ) + + 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 (excluded from CSV) ===') + record.warnings.forEach((w) => { + const ref = w.swapId ? ` [swap ${w.swapId}]` : '' + console.log(`- [${w.type}] ${w.partnerCode}${ref}: ${w.reason}`) + }) + } +} + +const writeArtifacts = (record: PayoutRecord, payouts: PartnerPayout[]): void => { + const outputDir = path.join(__dirname, '../payouts') + if (!fs.existsSync(outputDir)) fs.mkdirSync(outputDir, { recursive: true }) + + const csv = toCsv( + payouts + .filter((p) => p.included && p.receiveAddress) + .map((p) => ({ receiveAddress: p.receiveAddress as string, usdcAmount: p.usdcAmount })), + ) + + const csvPath = path.join(outputDir, `affiliate-payouts-${record.window.label}.csv`) + const jsonPath = path.join(outputDir, `affiliate-payouts-${record.window.label}.json`) + + fs.writeFileSync(csvPath, csv) + fs.writeFileSync(jsonPath, JSON.stringify(record, null, 2) + '\n') + + console.log(`\nCSV written: ${csvPath}`) + console.log(`JSON written: ${jsonPath}`) +} + +const generate = async (startArg?: string, endArg?: string): Promise => { + const window = resolveWindow(startArg, endArg) + 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', + isAffiliateVerified: true, + createdAt: { gte: window.start, lt: window.end }, + }, + }) + console.log(`Found ${rows.length} verified successful swaps with a partner code`) + + const { partners, skippedSwaps, anomalies } = 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, a])) + + const payouts = buildPayouts(partners, affiliatesByCode) + const record = buildRecord(window, payouts, skippedSwaps, anomalies, new Date().toISOString()) + + writeArtifacts(record, payouts) + printSummary(record, payouts) +} + +const main = async (): Promise => { + const args = process.argv.slice(2) + const command = args[0] + + try { + switch (command) { + case 'generate': + await generate(args[1], args[2]) + break + default: + console.log('Usage:') + console.log(' affiliate-payouts generate [startDate] [endDate]') + console.log(' No dates → previous calendar month (UTC).') + console.log(' Example: affiliate-payouts generate 2026-06-01 2026-07-01') + process.exit(1) + } + } finally { + await prisma.$disconnect() + } +} + +main().catch((error) => { + console.error(error) + process.exit(1) +}) diff --git a/scripts/jest.config.ts b/scripts/jest.config.ts new file mode 100644 index 0000000..f90627a --- /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', { isolatedModules: true }] }, + moduleFileExtensions: ['ts', 'js', 'json'], + testEnvironment: 'node', +} + +export default config diff --git a/tsconfig.json b/tsconfig.json index 08b9853..3b5fe2d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -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"] } From 46057f013eafe882f95653854b4babfb4dc12c29 Mon Sep 17 00:00:00 2001 From: kaladinlight <35275952+kaladinlight@users.noreply.github.com> Date: Thu, 2 Jul 2026 13:06:24 -0600 Subject: [PATCH 3/5] refactor(scripts): restructure affiliate payouts into a module Split scripts/affiliate-payouts.ts into scripts/affiliate-payouts/ with affiliate-payouts.ts (CLI), utils.ts (logic), types.ts, and tests. - resolveWindow takes a YYYY-MM month, defaulting to the previous calendar month; drops the start/end ISO date pair - Clearer excluded-swap category names (unpriceable / feeAnomaly / unresolvedFee) and a rewritten classification comment - buildPayouts takes a pre-lowercased affiliate map (caller owns citext normalization); normalizeRecipient renamed to normalizeAddress - swap-service calculateFeeForSwap: prefer the on-chain fee, fall back to the bps-implied fee, and skip only when neither is available (no longer defaults feeUsd to 0) - add scripts/tsconfig.json, move ts-jest isolatedModules into it, and update the yarn affiliate-payouts path Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/swap-service/src/swaps/utils.ts | 11 +- ...30-affiliate-monthly-usdc-payout-design.md | 95 +++-- package.json | 2 +- scripts/affiliate-payouts-lib.ts | 266 -------------- scripts/affiliate-payouts.test.ts | 193 ---------- scripts/affiliate-payouts.ts | 125 ------- .../affiliate-payouts.test.ts | 337 ++++++++++++++++++ .../affiliate-payouts/affiliate-payouts.ts | 145 ++++++++ scripts/affiliate-payouts/types.ts | 113 ++++++ scripts/affiliate-payouts/utils.ts | 306 ++++++++++++++++ scripts/jest.config.ts | 2 +- scripts/tsconfig.json | 9 + 12 files changed, 985 insertions(+), 619 deletions(-) delete mode 100644 scripts/affiliate-payouts-lib.ts delete mode 100644 scripts/affiliate-payouts.test.ts delete mode 100644 scripts/affiliate-payouts.ts create mode 100644 scripts/affiliate-payouts/affiliate-payouts.test.ts create mode 100644 scripts/affiliate-payouts/affiliate-payouts.ts create mode 100644 scripts/affiliate-payouts/types.ts create mode 100644 scripts/affiliate-payouts/utils.ts create mode 100644 scripts/tsconfig.json diff --git a/apps/swap-service/src/swaps/utils.ts b/apps/swap-service/src/swaps/utils.ts index 38123fc..f9a5d6c 100644 --- a/apps/swap-service/src/swaps/utils.ts +++ b/apps/swap-service/src/swaps/utils.ts @@ -186,16 +186,17 @@ export const calculateFeeForSwap = ( ) 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. Null only when neither + // is available (no on-chain amount and unpriceable volume) — nothing to attribute, so skip. + const feeUsd = actualFeeUsd ?? impliedFeeUsd + if (feeUsd === null) { logger.warn(`Unable to calculate fee for swap ${swap.swapId}, skipping`) return null } - const impliedFeeUsd = - sellAmountUsd === null ? null : bnOrZero(sellAmountUsd).times(verifiedBps).div(BPS_DENOMINATOR).toNumber() - - const feeUsd = actualFeeUsd ?? impliedFeeUsd ?? 0 const volumeUsd = sellAmountUsd ?? bnOrZero(actualFeeUsd).times(BPS_DENOMINATOR).div(verifiedBps).toNumber() return { feeUsd, volumeUsd, verifiedBps, actualFeeUsd, impliedFeeUsd } diff --git a/docs/superpowers/specs/2026-06-30-affiliate-monthly-usdc-payout-design.md b/docs/superpowers/specs/2026-06-30-affiliate-monthly-usdc-payout-design.md index c981257..f4b2529 100644 --- a/docs/superpowers/specs/2026-06-30-affiliate-monthly-usdc-payout-design.md +++ b/docs/superpowers/specs/2026-06-30-affiliate-monthly-usdc-payout-design.md @@ -47,23 +47,27 @@ Payouts are USDC on **Arbitrum One**, imported via the Safe CSV-airdrop app | Decision | Choice | |---|---| -| Eligible swaps | `status='SUCCESS' AND isAffiliateVerified=true`, **all origins** (web + api). Matches the affiliate `/stats` endpoint partners already see. | -| Fee basis | **On-chain verified actual fee**, guarded by a deviation check vs. the bps-implied fee; anomalies are flagged + excluded (see Fee-deviation guard). | +| Eligible swaps | `status='SUCCESS'` with a `partnerCode`, **all origins** (web + api). All are fetched; only swaps that verified successfully as ours (`verificationStatus='SUCCESS'` **and** `isAffiliateVerified=true`) are paid. Swaps are partitioned by `verificationStatus` and surfaced (not dropped) so a swap verified *after* the run isn't silently lost — a `verificationStatus='SUCCESS'` filter in the query would never revisit a still-`PENDING` one, since the next window keys off `createdAt`. | +| Fee basis | **Always the on-chain verified fee** — the bps-implied fee is *never* a payout basis. Verified fee is guarded by a deviation check vs. the bps-implied fee; anomalies are flagged + excluded (see Fee-deviation guard). A swap with **no** resolvable verified fee is surfaced (`no-verified-fee`) and excluded, not paid on an estimate. | | Minimum payout | **No minimum** — any partner with `feesEarnedUsd > 0` and a valid address gets a row. | | Invalid/non-EVM recipient | **Exclude from CSV + warn** (listed in summary and JSON). Run still succeeds. | +| Database | Reads the swap-service Postgres. **`DATABASE_URL` must be exported for the run** (`DATABASE_URL= yarn affiliate-payouts …`); the script fails fast if unset and logs the DB host it connected to. | | Location / invocation | `scripts/affiliate-payouts.ts`, wired as `yarn affiliate-payouts`. | | USD → USDC | Treated **1:1**, valued at swap time and summed over the window. | ## Invocation ```bash -yarn affiliate-payouts generate [startDate] [endDate] +yarn affiliate-payouts generate [startDate] [endDate] [--force] ``` - **No args** → previous calendar month in **UTC**. Run on 2026-07-01 → covers `2026-06-01T00:00:00Z` (inclusive) to `2026-07-01T00:00:00Z` (**exclusive**). - Optional ISO date args override the window. End is always treated as **exclusive** (`gte: start, lt: end`) to avoid boundary double-counting. +- **`--force`** is required to overwrite an existing window's artifacts. A re-run whose numbers + shifted (e.g. more swaps verified since) must not silently clobber a file that may already have + been executed on the Safe — the guard makes re-generation an explicit choice. ## Computation @@ -73,23 +77,43 @@ yarn affiliate-payouts generate [startDate] [endDate] where: { partnerCode: { not: null }, status: 'SUCCESS', - isAffiliateVerified: true, createdAt: { gte: start, lt: end }, }, }) ``` -2. **Group by `partnerCode`.** For each swap, run - `calculateFeeForSwap(toSwap(swap))`. If it returns `null` (missing/unpriceable - verification details), skip the swap and increment a `skippedSwaps` counter. - Otherwise apply the **fee-deviation guard** (below), and if it passes: + Verification is intentionally **not** filtered in the query — it's partitioned in step 2 so + unverified swaps can be surfaced instead of silently dropped. `partnerCode` is Postgres + **citext** (case-insensitive), so all in-memory keying canonicalizes to lower-case; otherwise + a case difference between a swap and its affiliate row splits accruals or drops the lookup. +2. **Partition + group by `partnerCode`.** Read `verificationStatus` (the tri-state verification + *job* outcome) directly — `isAffiliateVerified` alone can't distinguish a real failure from a + swap that verified fine but has no affiliate fee for us. For each swap: + - `verificationStatus = PENDING` → **not paid**; `unverified` review item (`pending`). Only + these can still flip to paid on a later run, so they're the ones to re-check before payout. + - `verificationStatus = FAILED` → **not paid**; `unverified` review item (`failed`) — investigate. + - `verificationStatus = SUCCESS` but `isAffiliateVerified = false` (`hasAffiliate=false`) + → **not paid**; counted as `noAffiliateFee`. This is *no affiliate fee for us* — either the + on-chain affiliate wasn't ours, or it **was** ours but the applied/verified affiliate bps was 0 + (nothing collected). Expected, so it's counted in totals but *not* warned per-swap. + - `calculateFeeForSwap(toSwap(swap))` returns `null` (missing/unpriceable verification + details) → skip, increment `skippedSwaps`. + - no resolvable **verified on-chain fee** (`fee.actualFeeUsd === null`) → recorded as a + `no-verified-fee` review item and excluded. Payouts are **only** on the verified fee; the + bps-implied fee is never paid. + - verified fee fails the **fee-deviation guard** (below) → excluded as an `anomaly`. + - partner share resolves to 0 (`partnerBps = 0`) → recorded as a `partner-bps-unset` review item + and excluded. `partnerBps` is set independently of `partnerCode` at creation (client-supplied, + column default 0), so an attributed swap can arrive with it unset — usually a mis-populated + `partnerBps` rather than a real 0% deal. + - otherwise accrue the **verified fee** (amounts summed with `BigNumber` to avoid float drift): ```ts const rate = getPartnerFeeRate(fee.verifiedBps, swap.partnerBps) - partner.feesEarnedUsd += fee.feeUsd * rate // fee.feeUsd = on-chain actual when present - partner.volumeUsd += fee.volumeUsd + partner.feesEarnedUsd = partner.feesEarnedUsd.plus(new BigNumber(fee.actualFeeUsd).times(rate)) + partner.volumeUsd = partner.volumeUsd.plus(fee.volumeUsd) partner.swapCount += 1 ``` - Reusing the app functions keeps payout numbers consistent with the affiliate - `/stats` dashboard (single source of truth). + Reusing the app fee math keeps volume/rate consistent with the affiliate `/stats` dashboard; + the payout differs only in that it pays **strictly** the verified fee (no implied fallback). ### Fee-deviation guard (money-correctness) @@ -102,8 +126,8 @@ base-unit amount, priced/scaled as USDC, produces a wildly wrong USD fee (observ "fee" on a $100 swap**, which would have paid a partner ~$29k). To catch this, `calculateFeeForSwap` is extended to also expose `actualFeeUsd` and -`impliedFeeUsd` (`= verifiedVolumeUsd × verifiedBps / 10000`). For each swap with an on-chain -fee, the script compares the two: +`impliedFeeUsd` (`= verifiedVolumeUsd × verifiedBps / 10000`). The implied fee is used **only** as +this guard's reference bound — never as an amount paid. For each swap the script compares the two: ``` deviation = |actualFeeUsd - impliedFeeUsd| / impliedFeeUsd @@ -111,12 +135,14 @@ deviation = |actualFeeUsd - impliedFeeUsd| / impliedFeeUsd - An on-chain fee never equals the implied fee exactly (quote→execution price drift, partial / streaming fills, fee-asset conversion), so a relative tolerance band is allowed: - `FEE_DEVIATION_TOLERANCE = 0.5` (±50%, tunable). + `FEE_DEVIATION_TOLERANCE = 0.25` (±25%, tunable). Corrupt cases seen in the wild are orders of + magnitude off (e.g. 750×), so a tight band still catches them while limiting how far a + within-band amount can drift from the implied fee. - If `deviation > tolerance`, **or** the implied fee can't be computed (volume unpriceable), the swap is treated as an **anomaly**: excluded from the partner's total and recorded in `warnings`. The partner is still paid for their other, non-anomalous swaps. -- Swaps with **no** on-chain fee are not guarded — `fee.feeUsd` already equals the bps-implied - fee, which is the trusted value. +- Swaps with **no** verified on-chain fee are excluded as `no-verified-fee` (see step 2) — they + are never paid on the bps-implied estimate. This diverges from the current `/stats` numbers for affected swaps — by design, because `/stats` would surface the same corrupt figures. @@ -128,9 +154,14 @@ This diverges from the current `/stats` numbers for affected swaps — by design ## Address validation -- A recipient is valid if it is a well-formed EVM address. Prefer `viem`'s - `isAddress` / `getAddress` (checksum) if present in the workspace; otherwise a - `^0x[0-9a-fA-F]{40}$` regex fallback. +- A recipient is valid if it is a well-formed EVM address. We use `viem`'s + `isAddress` / `getAddress` (checksum). The zero address is also rejected (never pay the burn + address). +- Destination is `receiveAddress ?? walletAddress`; the fallback only fires when `receiveAddress` + is null. A **non-null but non-EVM** `receiveAddress` (Citext is free-form) excludes the partner + rather than falling back — we don't redirect funds to an address the partner didn't choose for + USDC. The durable fix is validating `receiveAddress` at **write time** in the affiliate + service (currently stored raw); the payout script stays defensive regardless. - Partners failing validation are **excluded from the CSV** and recorded as warnings (with `excludedReason`) in the summary and JSON record. The run still exits 0. @@ -159,9 +190,13 @@ Written to `payouts/` at repo root (add to `.gitignore` if not already ignored). "totals": { "partnersPaid": 0, "totalUsdc": "0.000000", - "eligibleSwaps": 0, + "paidSwaps": 0, "skippedSwaps": 0, - "anomalousSwaps": 0 + "anomalousSwaps": 0, + "unverifiedSwaps": 0, + "noAffiliateFeeSwaps": 0, + "partnerBpsUnsetSwaps": 0, + "noVerifiedFeeSwaps": 0 }, "partners": [ { @@ -177,7 +212,10 @@ Written to `payouts/` at repo root (add to `.gitignore` if not already ignored). ], "warnings": [ { "type": "fee-anomaly", "partnerCode": "...", "swapId": "...", "reason": "on-chain fee ... deviates ...% from bps-implied ..." }, - { "type": "address", "partnerCode": "...", "swapId": null, "reason": "invalid (non-EVM) payout address: ..." } + { "type": "address", "partnerCode": "...", "swapId": null, "reason": "invalid (non-EVM) payout address: ..." }, + { "type": "unverified", "partnerCode": "...", "swapId": "...", "reason": "affiliate verification pending — not paid, inspect before final payout" }, + { "type": "partner-bps-unset", "partnerCode": "...", "swapId": "...", "reason": "partnerBps is 0 (verifiedBps ...) — no partner share configured, excluded" }, + { "type": "no-verified-fee", "partnerCode": "...", "swapId": "...", "reason": "no verified on-chain fee — not paid (bps-implied fee is never a payout basis)" } ] } ``` @@ -195,13 +233,14 @@ the real fee functions. - **`scripts/affiliate-payouts-lib.ts`** — pure (only `bignumber.js` + `viem`): - `resolveWindow(start?, end?, now?)` — UTC previous-month default + ISO override, end-exclusive. - - `aggregateByPartner(rows, deps, tolerance?)` — groups + accrues; `deps` injects + - `aggregateByPartner(rows, deps, tolerance?)` — partitions + accrues (BigNumber); `deps` injects `{ toSwap, calculateFeeForSwap, getPartnerFeeRate }` so it's testable without the app graph. - Returns `{ partners, skippedSwaps, anomalies }`. + Returns `{ partners, skippedSwaps, anomalies, unverified, noAffiliateFee, partnerBpsUnset, noVerifiedFee }`. + - `canonicalPartnerCode(code)` — lower-case, matching the citext columns; used for all keying. - `checkFeeAnomaly(row, fee, tolerance)` — the deviation guard; returns a `FeeAnomaly` or null. - - `formatUsdc(usd)` — floor to 6 dp via BigNumber, strip trailing zeros. - - `normalizeRecipient(addr)` — viem `isAddress` / `getAddress` checksum; null if invalid. - - `toCsv(rows)`, `buildPayouts(...)`, `buildRecord(...)`. + - `formatUsdc(usd)` — floor to 6 dp via BigNumber (accepts `BigNumber.Value`), strip trailing zeros. + - `normalizeRecipient(addr)` — viem `isAddress` / `getAddress` checksum; null if invalid or zero. + - `toCsv(rows)`, `buildPayouts(...)`, `buildRecord({ ... })`. - **`scripts/affiliate-payouts.ts`** — entry: `PrismaClient`, the real `toSwap` / `calculateFeeForSwap` / `getPartnerFeeRate` from `apps/swap-service/src/swaps/utils`, plus `printSummary` / `writeArtifacts` / `generate` / `main`; `prisma.$disconnect()` in `finally`. diff --git a/package.json b/package.json index eb22a41..129d9cb 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,7 @@ "db:migrate:create": "prisma migrate dev --create-only --name", "db:studio": "prisma studio", "referral-rewards": "ts-node scripts/referral-rewards.ts", - "affiliate-payouts": "ts-node --transpile-only scripts/affiliate-payouts.ts", + "affiliate-payouts": "ts-node --transpile-only scripts/affiliate-payouts/affiliate-payouts.ts", "affiliate-payouts:test": "jest --config scripts/jest.config.ts" }, "dependencies": { diff --git a/scripts/affiliate-payouts-lib.ts b/scripts/affiliate-payouts-lib.ts deleted file mode 100644 index 876de5c..0000000 --- a/scripts/affiliate-payouts-lib.ts +++ /dev/null @@ -1,266 +0,0 @@ -import type { Swap as PrismaSwap } from '@prisma/client' -import BigNumber from 'bignumber.js' -import { getAddress, isAddress } from 'viem' - -// USDC on Arbitrum One — payouts are imported into the Safe CSV-airdrop app on arbitrum. -export const ARBITRUM_USDC_ADDRESS = '0xaf88d065e77c8cC2239327C5EDb3A432268e5831' -export const USDC_DECIMALS = 6 - -// A swap's on-chain affiliate fee should land near the bps-implied fee (volume × verifiedBps), -// but never matches exactly — price drift between quote and execution, partial/streaming fills, -// and fee-asset conversion all introduce slack. Beyond this relative band we treat the on-chain -// amount as untrustworthy (corrupt/misreported), flag it, and exclude the swap from payout. -export const FEE_DEVIATION_TOLERANCE = 0.5 - -export type PartnerAccrual = { - partnerCode: string - swapCount: number - volumeUsd: number - feesEarnedUsd: number -} - -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 -} - -// Injected so the aggregation can be unit-tested without loading the swap-service module graph. -export type FeeDeps = { - toSwap: (row: PrismaSwap) => S - calculateFeeForSwap: (swap: S) => FeeResult | null - getPartnerFeeRate: (verifiedBps: number, partnerBps: number) => number -} - -const pad2 = (n: number): string => String(n).padStart(2, '0') -const monthLabel = (d: Date): string => `${d.getUTCFullYear()}-${pad2(d.getUTCMonth() + 1)}` - -// Default window is the previous calendar month in UTC, end-exclusive (gte start, lt end). -// Explicit ISO args override it. `now` is injectable for testing. -export const resolveWindow = (startArg?: string, endArg?: string, now: Date = new Date()): PayoutWindow => { - if (startArg || endArg) { - if (!startArg || !endArg) throw new Error('Provide both startDate and endDate, or neither.') - const start = new Date(startArg) - const end = new Date(endArg) - if (Number.isNaN(start.getTime())) throw new Error(`Invalid startDate: ${startArg}`) - if (Number.isNaN(end.getTime())) throw new Error(`Invalid endDate: ${endArg}`) - if (end <= start) throw new Error('endDate must be after startDate.') - return { start, end, label: monthLabel(start) } - } - - const start = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth() - 1, 1)) - const end = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1)) - return { start, end, label: monthLabel(start) } -} - -// Returns an anomaly when a swap reports an on-chain fee that doesn't align with its -// bps-implied fee (or can't be validated against one). null means the on-chain fee is -// trustworthy — or absent, in which case the bps-implied fee is used as-is. -export const checkFeeAnomaly = ( - row: { swapId: string; partnerCode: string }, - fee: FeeResult, - tolerance: number, -): FeeAnomaly | null => { - if (fee.actualFeeUsd === null) return null // no on-chain amount to distrust; implied fee is used - - const base = { swapId: row.swapId, partnerCode: row.partnerCode, actualFeeUsd: fee.actualFeeUsd, volumeUsd: fee.volumeUsd } - - if (fee.impliedFeeUsd === null || fee.impliedFeeUsd <= 0) { - return { ...base, impliedFeeUsd: fee.impliedFeeUsd, 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, - impliedFeeUsd: fee.impliedFeeUsd, - 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 -} - -// Sum each partner's earned fee share using the injected swap-service fee math. Swaps whose -// on-chain fee fails the deviation guard are excluded and returned as anomalies; unpriceable -// swaps are skipped. -export const aggregateByPartner = ( - rows: PrismaSwap[], - deps: FeeDeps, - tolerance: number = FEE_DEVIATION_TOLERANCE, -): { partners: Map; skippedSwaps: number; anomalies: FeeAnomaly[] } => { - const partners = new Map() - const anomalies: FeeAnomaly[] = [] - let skippedSwaps = 0 - - for (const row of rows) { - if (!row.partnerCode) continue - - const fee = deps.calculateFeeForSwap(deps.toSwap(row)) - if (!fee) { - skippedSwaps++ - continue - } - - const anomaly = checkFeeAnomaly(row, fee, tolerance) - if (anomaly) { - anomalies.push(anomaly) - continue - } - - const rate = deps.getPartnerFeeRate(fee.verifiedBps, row.partnerBps) - - const accrual = partners.get(row.partnerCode) ?? { - partnerCode: row.partnerCode, - swapCount: 0, - volumeUsd: 0, - feesEarnedUsd: 0, - } - - accrual.swapCount += 1 - accrual.volumeUsd += fee.volumeUsd - accrual.feesEarnedUsd += fee.feeUsd * rate - partners.set(row.partnerCode, accrual) - } - - return { partners, skippedSwaps, anomalies } -} - -// USD is paid 1:1 as USDC, floored to 6 dp (USDC precision), trailing zeros stripped. -export const formatUsdc = (usd: number): string => - new BigNumber(usd) - .toFixed(USDC_DECIMALS, BigNumber.ROUND_DOWN) - .replace(/\.?0+$/, '') - -// null for missing/non-EVM addresses; otherwise the checksummed address. -export const normalizeRecipient = (address: string | null | undefined): string | null => { - if (!address) return null - if (!isAddress(address)) return null - return getAddress(address) -} - -export const 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 const buildPayouts = ( - partners: Map, - affiliatesByCode: Map, -): PartnerPayout[] => { - const payouts: PartnerPayout[] = [] - - for (const accrual of partners.values()) { - if (accrual.feesEarnedUsd <= 0) continue - - const affiliate = affiliatesByCode.get(accrual.partnerCode) - const rawAddress = affiliate ? (affiliate.receiveAddress ?? affiliate.walletAddress) : null - const receiveAddress = normalizeRecipient(rawAddress) - - let excludedReason: string | null = null - if (!affiliate) excludedReason = 'no affiliate found for partner code' - else if (!receiveAddress) excludedReason = `invalid (non-EVM) payout address: ${rawAddress ?? 'none'}` - - payouts.push({ - ...accrual, - receiveAddress, - included: excludedReason === null, - excludedReason, - usdcAmount: formatUsdc(accrual.feesEarnedUsd), - }) - } - - return payouts.sort((a, b) => b.feesEarnedUsd - a.feesEarnedUsd) -} - -export type PayoutWarning = { - type: 'fee-anomaly' | 'address' - 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; eligibleSwaps: number; skippedSwaps: number; anomalousSwaps: number } - partners: { - partnerCode: string - receiveAddress: string | null - swapCount: number - volumeUsd: string - feesEarnedUsd: string - usdcAmount: string - included: boolean - excludedReason: string | null - }[] - warnings: PayoutWarning[] -} - -export const buildRecord = ( - window: PayoutWindow, - payouts: PartnerPayout[], - skippedSwaps: number, - anomalies: FeeAnomaly[], - generatedAt: string, -): PayoutRecord => { - 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 })), - ] - - 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), - eligibleSwaps: payouts.reduce((sum, p) => sum + p.swapCount, 0), - skippedSwaps, - anomalousSwaps: anomalies.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/affiliate-payouts.test.ts b/scripts/affiliate-payouts.test.ts deleted file mode 100644 index 0ec855e..0000000 --- a/scripts/affiliate-payouts.test.ts +++ /dev/null @@ -1,193 +0,0 @@ -import type { Swap as PrismaSwap } from '@prisma/client' -import { getAddress } from 'viem' - -import { - aggregateByPartner, - buildPayouts, - checkFeeAnomaly, - type FeeDeps, - type FeeResult, - formatUsdc, - normalizeRecipient, - resolveWindow, - toCsv, -} from './affiliate-payouts-lib' - -type RowExtras = { swapId?: string; priceable?: boolean; fee?: Partial } - -const makeRow = (overrides: Partial & RowExtras = {}): PrismaSwap => - ({ swapId: 's1', partnerCode: 'acme', partnerBps: 30, 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) => Math.min(partnerBps / verifiedBps, 1), -} - -describe('resolveWindow', () => { - it('defaults to the previous calendar month in UTC, end-exclusive', () => { - const { start, end, label } = resolveWindow(undefined, 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, 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 explicit ISO date args', () => { - const { start, end, label } = resolveWindow('2026-03-01', '2026-04-01') - expect(start.toISOString()).toBe('2026-03-01T00:00:00.000Z') - expect(end.toISOString()).toBe('2026-04-01T00:00:00.000Z') - expect(label).toBe('2026-03') - }) - - it('rejects a single date arg, invalid dates, and inverted ranges', () => { - expect(() => resolveWindow('2026-03-01')).toThrow() - expect(() => resolveWindow('not-a-date', '2026-04-01')).toThrow() - expect(() => resolveWindow('2026-04-01', '2026-03-01')).toThrow() - }) -}) - -describe('aggregateByPartner', () => { - it('sums a partner share across swaps', () => { - const { partners, skippedSwaps, anomalies } = aggregateByPartner([makeRow(), makeRow()], stubDeps) - const acme = partners.get('acme') - expect(skippedSwaps).toBe(0) - expect(anomalies).toHaveLength(0) - expect(acme?.swapCount).toBe(2) - expect(acme?.volumeUsd).toBeCloseTo(4000) - // $12 fee * (30/60) = $6 per swap → $12 total - expect(acme?.feesEarnedUsd).toBeCloseTo(12) - }) - - it('caps the partner share at 100% when partnerBps exceeds verifiedBps', () => { - const { partners } = aggregateByPartner([makeRow({ partnerBps: 120 })], stubDeps) - expect(partners.get('acme')?.feesEarnedUsd).toBeCloseTo(12) - }) - - it('skips swaps that cannot be priced', () => { - const { partners, skippedSwaps } = aggregateByPartner([makeRow({ priceable: false })], stubDeps) - expect(skippedSwaps).toBe(1) - expect(partners.size).toBe(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).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('passes when no on-chain fee is present (implied fee used as-is)', () => { - expect(checkFeeAnomaly(row, fee({ actualFeeUsd: null }), 0.5)).toBeNull() - }) - - it('passes when on-chain fee is within tolerance of implied', () => { - expect(checkFeeAnomaly(row, fee({ actualFeeUsd: 13, impliedFeeUsd: 12 }), 0.5)).toBeNull() - }) - - it('flags when on-chain fee exceeds the deviation band', () => { - const anomaly = checkFeeAnomaly(row, fee({ actualFeeUsd: 39020, impliedFeeUsd: 0.4 }), 0.5) - expect(anomaly?.deviation).toBeGreaterThan(0.5) - 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.5) - 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') - }) -}) - -describe('normalizeRecipient', () => { - it('checksums valid EVM addresses and rejects everything else', () => { - const lower = '0xd8da6bf26964af9d7eed9e03e53415d37aa96045' - expect(normalizeRecipient(lower)).toBe(getAddress(lower)) - expect(normalizeRecipient(null)).toBeNull() - expect(normalizeRecipient('not-an-address')).toBeNull() - expect(normalizeRecipient('cosmos1abc')).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', { partnerCode: 'acme', swapCount: 1, volumeUsd: 2000, feesEarnedUsd: 6 }], - ['big', { partnerCode: 'big', swapCount: 5, volumeUsd: 9000, feesEarnedUsd: 50 }], - ['bad', { partnerCode: 'bad', swapCount: 1, volumeUsd: 100, feesEarnedUsd: 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(/non-EVM/) - }) -}) diff --git a/scripts/affiliate-payouts.ts b/scripts/affiliate-payouts.ts deleted file mode 100644 index 3fdeac8..0000000 --- a/scripts/affiliate-payouts.ts +++ /dev/null @@ -1,125 +0,0 @@ -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 { - aggregateByPartner, - buildPayouts, - buildRecord, - type PartnerPayout, - type PayoutRecord, - resolveWindow, - toCsv, -} from './affiliate-payouts-lib' - -const prisma = new PrismaClient() - -const 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( - `Eligible swaps: ${record.totals.eligibleSwaps} | Skipped (unpriceable): ${record.totals.skippedSwaps} | Fee anomalies excluded: ${record.totals.anomalousSwaps}`, - ) - - 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 (excluded from CSV) ===') - record.warnings.forEach((w) => { - const ref = w.swapId ? ` [swap ${w.swapId}]` : '' - console.log(`- [${w.type}] ${w.partnerCode}${ref}: ${w.reason}`) - }) - } -} - -const writeArtifacts = (record: PayoutRecord, payouts: PartnerPayout[]): void => { - const outputDir = path.join(__dirname, '../payouts') - if (!fs.existsSync(outputDir)) fs.mkdirSync(outputDir, { recursive: true }) - - const csv = toCsv( - payouts - .filter((p) => p.included && p.receiveAddress) - .map((p) => ({ receiveAddress: p.receiveAddress as string, usdcAmount: p.usdcAmount })), - ) - - const csvPath = path.join(outputDir, `affiliate-payouts-${record.window.label}.csv`) - const jsonPath = path.join(outputDir, `affiliate-payouts-${record.window.label}.json`) - - fs.writeFileSync(csvPath, csv) - fs.writeFileSync(jsonPath, JSON.stringify(record, null, 2) + '\n') - - console.log(`\nCSV written: ${csvPath}`) - console.log(`JSON written: ${jsonPath}`) -} - -const generate = async (startArg?: string, endArg?: string): Promise => { - const window = resolveWindow(startArg, endArg) - 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', - isAffiliateVerified: true, - createdAt: { gte: window.start, lt: window.end }, - }, - }) - console.log(`Found ${rows.length} verified successful swaps with a partner code`) - - const { partners, skippedSwaps, anomalies } = 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, a])) - - const payouts = buildPayouts(partners, affiliatesByCode) - const record = buildRecord(window, payouts, skippedSwaps, anomalies, new Date().toISOString()) - - writeArtifacts(record, payouts) - printSummary(record, payouts) -} - -const main = async (): Promise => { - const args = process.argv.slice(2) - const command = args[0] - - try { - switch (command) { - case 'generate': - await generate(args[1], args[2]) - break - default: - console.log('Usage:') - console.log(' affiliate-payouts generate [startDate] [endDate]') - console.log(' No dates → previous calendar month (UTC).') - console.log(' Example: affiliate-payouts generate 2026-06-01 2026-07-01') - process.exit(1) - } - } finally { - await prisma.$disconnect() - } -} - -main().catch((error) => { - console.error(error) - process.exit(1) -}) 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 index f90627a..980b532 100644 --- a/scripts/jest.config.ts +++ b/scripts/jest.config.ts @@ -3,7 +3,7 @@ import type { Config } from 'jest' const config: Config = { rootDir: '.', testRegex: '.*\\.test\\.ts$', - transform: { '^.+\\.ts$': ['ts-jest', { isolatedModules: true }] }, + transform: { '^.+\\.ts$': 'ts-jest' }, moduleFileExtensions: ['ts', 'js', 'json'], testEnvironment: 'node', } diff --git a/scripts/tsconfig.json b/scripts/tsconfig.json new file mode 100644 index 0000000..a81e72a --- /dev/null +++ b/scripts/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "types": ["node", "jest"], + "isolatedModules": true + }, + "include": ["**/*.ts"], + "exclude": ["node_modules", "dist"] +} From 62b129c9a4d12967a9266385f1225edbb83b2c0e Mon Sep 17 00:00:00 2001 From: kaladinlight <35275952+kaladinlight@users.noreply.github.com> Date: Thu, 2 Jul 2026 13:40:53 -0600 Subject: [PATCH 4/5] chore(scripts): drop affiliate payout design spec from PR Co-Authored-By: Claude Opus 4.8 (1M context) --- ...30-affiliate-monthly-usdc-payout-design.md | 276 ------------------ 1 file changed, 276 deletions(-) delete mode 100644 docs/superpowers/specs/2026-06-30-affiliate-monthly-usdc-payout-design.md diff --git a/docs/superpowers/specs/2026-06-30-affiliate-monthly-usdc-payout-design.md b/docs/superpowers/specs/2026-06-30-affiliate-monthly-usdc-payout-design.md deleted file mode 100644 index f4b2529..0000000 --- a/docs/superpowers/specs/2026-06-30-affiliate-monthly-usdc-payout-design.md +++ /dev/null @@ -1,276 +0,0 @@ -# Affiliate Monthly USDC Payout Script — Design - -**Date:** 2026-06-30 -**Status:** Approved (design), pending implementation plan -**Author:** kaladinlight (+ Claude) - -## Purpose - -Generate a Gnosis Safe CSV-airdrop file that pays each affiliate partner their -earned USDC revenue for a calendar month, aggregated by `partnerCode`, plus a -machine-readable run record that a later settlement-tracking feature can build -on. - -Payouts are USDC on **Arbitrum One**, imported via the Safe CSV-airdrop app -(same target as `~/github/shapeshift/rFOX`). - -## Context / prior art - -- **`scripts/referral-rewards.ts`** (+ `yarn referral-rewards`) is the existing - precedent for period-windowed payout scripts in this repo. We mirror its - shape (root `scripts/` dir, `yarn` entry, ISO date args, output artifacts in a - sibling dir). -- **rFOX `cli/src/safeWallet.ts`** defines the Safe CSV format we target. -- **`apps/swap-service`** owns the swap + affiliate data and the fee math we reuse. - -## Data model (existing, swap-service / Postgres / Prisma) - -- `Swap` (`swaps` table): `partnerCode` (FK → `Affiliate.partnerCode`), `status`, - `isAffiliateVerified`, `createdAt`, `partnerBps`, `affiliateVerificationDetails` - (`{ hasAffiliate, affiliateBps, verifiedSellAmountCryptoBaseUnit, ... }`), - `actualAffiliateFeeAmountCryptoBaseUnit`, `affiliateFeeAssetId`, `sellAssetUsd`, - `buyAssetUsd`, `affiliateAssetUsd`. -- `Affiliate` (`affiliates` table): `partnerCode` (unique), `walletAddress` - (unique, SIWE/EVM), `receiveAddress` (optional, free-form Citext), `bps`. -- Payout destination for a partner = **current** `receiveAddress ?? walletAddress` - (the live address, NOT the per-swap `partnerAddress` snapshot — we pay where the - partner wants funds now). -- Fee math lives in `apps/swap-service/src/swaps/utils.ts`: - - `calculateFeeForSwap(swap) -> { feeUsd, volumeUsd, verifiedBps } | null` - - `getPartnerFeeRate(verifiedBps, partnerBps) -> min(partnerBps/verifiedBps, 1)` - - `toSwap(prismaSwap) -> Swap` (deserializes JSON columns) -- `AffiliateService.getAffiliateStats` already performs the per-partner - aggregation we want; this script generalizes it across all partners and emits - payout artifacts. - -## Scope decisions (locked) - -| Decision | Choice | -|---|---| -| Eligible swaps | `status='SUCCESS'` with a `partnerCode`, **all origins** (web + api). All are fetched; only swaps that verified successfully as ours (`verificationStatus='SUCCESS'` **and** `isAffiliateVerified=true`) are paid. Swaps are partitioned by `verificationStatus` and surfaced (not dropped) so a swap verified *after* the run isn't silently lost — a `verificationStatus='SUCCESS'` filter in the query would never revisit a still-`PENDING` one, since the next window keys off `createdAt`. | -| Fee basis | **Always the on-chain verified fee** — the bps-implied fee is *never* a payout basis. Verified fee is guarded by a deviation check vs. the bps-implied fee; anomalies are flagged + excluded (see Fee-deviation guard). A swap with **no** resolvable verified fee is surfaced (`no-verified-fee`) and excluded, not paid on an estimate. | -| Minimum payout | **No minimum** — any partner with `feesEarnedUsd > 0` and a valid address gets a row. | -| Invalid/non-EVM recipient | **Exclude from CSV + warn** (listed in summary and JSON). Run still succeeds. | -| Database | Reads the swap-service Postgres. **`DATABASE_URL` must be exported for the run** (`DATABASE_URL= yarn affiliate-payouts …`); the script fails fast if unset and logs the DB host it connected to. | -| Location / invocation | `scripts/affiliate-payouts.ts`, wired as `yarn affiliate-payouts`. | -| USD → USDC | Treated **1:1**, valued at swap time and summed over the window. | - -## Invocation - -```bash -yarn affiliate-payouts generate [startDate] [endDate] [--force] -``` - -- **No args** → previous calendar month in **UTC**. Run on 2026-07-01 → covers - `2026-06-01T00:00:00Z` (inclusive) to `2026-07-01T00:00:00Z` (**exclusive**). -- Optional ISO date args override the window. End is always treated as - **exclusive** (`gte: start, lt: end`) to avoid boundary double-counting. -- **`--force`** is required to overwrite an existing window's artifacts. A re-run whose numbers - shifted (e.g. more swaps verified since) must not silently clobber a file that may already have - been executed on the Safe — the guard makes re-generation an explicit choice. - -## Computation - -1. **Query once:** - ```ts - prisma.swap.findMany({ - where: { - partnerCode: { not: null }, - status: 'SUCCESS', - createdAt: { gte: start, lt: end }, - }, - }) - ``` - Verification is intentionally **not** filtered in the query — it's partitioned in step 2 so - unverified swaps can be surfaced instead of silently dropped. `partnerCode` is Postgres - **citext** (case-insensitive), so all in-memory keying canonicalizes to lower-case; otherwise - a case difference between a swap and its affiliate row splits accruals or drops the lookup. -2. **Partition + group by `partnerCode`.** Read `verificationStatus` (the tri-state verification - *job* outcome) directly — `isAffiliateVerified` alone can't distinguish a real failure from a - swap that verified fine but has no affiliate fee for us. For each swap: - - `verificationStatus = PENDING` → **not paid**; `unverified` review item (`pending`). Only - these can still flip to paid on a later run, so they're the ones to re-check before payout. - - `verificationStatus = FAILED` → **not paid**; `unverified` review item (`failed`) — investigate. - - `verificationStatus = SUCCESS` but `isAffiliateVerified = false` (`hasAffiliate=false`) - → **not paid**; counted as `noAffiliateFee`. This is *no affiliate fee for us* — either the - on-chain affiliate wasn't ours, or it **was** ours but the applied/verified affiliate bps was 0 - (nothing collected). Expected, so it's counted in totals but *not* warned per-swap. - - `calculateFeeForSwap(toSwap(swap))` returns `null` (missing/unpriceable verification - details) → skip, increment `skippedSwaps`. - - no resolvable **verified on-chain fee** (`fee.actualFeeUsd === null`) → recorded as a - `no-verified-fee` review item and excluded. Payouts are **only** on the verified fee; the - bps-implied fee is never paid. - - verified fee fails the **fee-deviation guard** (below) → excluded as an `anomaly`. - - partner share resolves to 0 (`partnerBps = 0`) → recorded as a `partner-bps-unset` review item - and excluded. `partnerBps` is set independently of `partnerCode` at creation (client-supplied, - column default 0), so an attributed swap can arrive with it unset — usually a mis-populated - `partnerBps` rather than a real 0% deal. - - otherwise accrue the **verified fee** (amounts summed with `BigNumber` to avoid float drift): - ```ts - const rate = getPartnerFeeRate(fee.verifiedBps, swap.partnerBps) - partner.feesEarnedUsd = partner.feesEarnedUsd.plus(new BigNumber(fee.actualFeeUsd).times(rate)) - partner.volumeUsd = partner.volumeUsd.plus(fee.volumeUsd) - partner.swapCount += 1 - ``` - Reusing the app fee math keeps volume/rate consistent with the affiliate `/stats` dashboard; - the payout differs only in that it pays **strictly** the verified fee (no implied fallback). - -### Fee-deviation guard (money-correctness) - -The payout uses the **on-chain verified affiliate fee** (`actualAffiliateFeeAmountCryptoBaseUnit` -→ `fee.actualFeeUsd`) when present, because that is what was actually collected. But that -field is **not always trustworthy**: some swaps record a fee asset / amount that doesn't match -what was really taken. Confirmed example: **MayaChain swaps** (affiliate `ssmaya`) label the -affiliate fee asset as USDC while the fee is actually collected in **CACAO**, so the stored -base-unit amount, priced/scaled as USDC, produces a wildly wrong USD fee (observed: **$39,020 -"fee" on a $100 swap**, which would have paid a partner ~$29k). - -To catch this, `calculateFeeForSwap` is extended to also expose `actualFeeUsd` and -`impliedFeeUsd` (`= verifiedVolumeUsd × verifiedBps / 10000`). The implied fee is used **only** as -this guard's reference bound — never as an amount paid. For each swap the script compares the two: - -``` -deviation = |actualFeeUsd - impliedFeeUsd| / impliedFeeUsd -``` - -- An on-chain fee never equals the implied fee exactly (quote→execution price drift, partial / - streaming fills, fee-asset conversion), so a relative tolerance band is allowed: - `FEE_DEVIATION_TOLERANCE = 0.25` (±25%, tunable). Corrupt cases seen in the wild are orders of - magnitude off (e.g. 750×), so a tight band still catches them while limiting how far a - within-band amount can drift from the implied fee. -- If `deviation > tolerance`, **or** the implied fee can't be computed (volume unpriceable), - the swap is treated as an **anomaly**: excluded from the partner's total and recorded in - `warnings`. The partner is still paid for their other, non-anomalous swaps. -- Swaps with **no** verified on-chain fee are excluded as `no-verified-fee` (see step 2) — they - are never paid on the bps-implied estimate. - -This diverges from the current `/stats` numbers for affected swaps — by design, because -`/stats` would surface the same corrupt figures. -3. **Resolve addresses:** for each `partnerCode`, look up the `Affiliate` and take - `receiveAddress ?? walletAddress`. -4. **Convert to USDC:** USD amount is treated 1:1 with USDC. Floor each partner - total to **6 dp** (USDC precision) so we never overpay. Use `BigNumber` for the - formatting (avoid float drift). - -## Address validation - -- A recipient is valid if it is a well-formed EVM address. We use `viem`'s - `isAddress` / `getAddress` (checksum). The zero address is also rejected (never pay the burn - address). -- Destination is `receiveAddress ?? walletAddress`; the fallback only fires when `receiveAddress` - is null. A **non-null but non-EVM** `receiveAddress` (Citext is free-form) excludes the partner - rather than falling back — we don't redirect funds to an address the partner didn't choose for - USDC. The durable fix is validating `receiveAddress` at **write time** in the affiliate - service (currently stored raw); the payout script stays defensive regardless. -- Partners failing validation are **excluded from the CSV** and recorded as - warnings (with `excludedReason`) in the summary and JSON record. The run still - exits 0. - -## Outputs - -Written to `payouts/` at repo root (add to `.gitignore` if not already ignored). - -1. **`affiliate-payouts-.csv`** — Safe CSV-airdrop format: - ``` - token_type,token_address,receiver,amount,id - erc20,0xaf88d065e77c8cC2239327C5EDb3A432268e5831,,, - ``` - - `token_address` = Arbitrum USDC `0xaf88d065e77c8cC2239327C5EDb3A432268e5831`. - - `amount` = human-decimal USDC (≤6 dp). - - `id` = sequential index starting at 0. - - Only included (valid-address, `>0`) partners appear, sorted by - `feesEarnedUsd` descending. - -2. **`affiliate-payouts-.json`** — full run record: - ```jsonc - { - "window": { "start": "...Z", "end": "...Z", "label": "2026-06" }, - "generatedAt": "...Z", - "token": { "chain": "arbitrum", "address": "0xaf88...5831", "symbol": "USDC" }, - "totals": { - "partnersPaid": 0, - "totalUsdc": "0.000000", - "paidSwaps": 0, - "skippedSwaps": 0, - "anomalousSwaps": 0, - "unverifiedSwaps": 0, - "noAffiliateFeeSwaps": 0, - "partnerBpsUnsetSwaps": 0, - "noVerifiedFeeSwaps": 0 - }, - "partners": [ - { - "partnerCode": "...", - "receiveAddress": "0x...", - "swapCount": 0, - "volumeUsd": "0.00", - "feesEarnedUsd": "0.000000", - "usdcAmount": "0", - "included": true, - "excludedReason": null - } - ], - "warnings": [ - { "type": "fee-anomaly", "partnerCode": "...", "swapId": "...", "reason": "on-chain fee ... deviates ...% from bps-implied ..." }, - { "type": "address", "partnerCode": "...", "swapId": null, "reason": "invalid (non-EVM) payout address: ..." }, - { "type": "unverified", "partnerCode": "...", "swapId": "...", "reason": "affiliate verification pending — not paid, inspect before final payout" }, - { "type": "partner-bps-unset", "partnerCode": "...", "swapId": "...", "reason": "partnerBps is 0 (verifiedBps ...) — no partner share configured, excluded" }, - { "type": "no-verified-fee", "partnerCode": "...", "swapId": "...", "reason": "no verified on-chain fee — not paid (bps-implied fee is never a payout basis)" } - ] - } - ``` - This is the seam the future settlement-tracking feature builds on. - -3. **Console summary** — window, total USDC, partner count, top partners by - earnings, skipped-swap count, and any warnings. - -## Module structure - -Split into a pure, dependency-light lib (jest-testable) and a thin IO entry, because the -swap-service fee math transitively imports ESM-only packages (`@shapeshiftoss/chain-adapters` -→ `p-queue`) that jest won't transform. The lib never imports the app graph; the entry injects -the real fee functions. - -- **`scripts/affiliate-payouts-lib.ts`** — pure (only `bignumber.js` + `viem`): - - `resolveWindow(start?, end?, now?)` — UTC previous-month default + ISO override, end-exclusive. - - `aggregateByPartner(rows, deps, tolerance?)` — partitions + accrues (BigNumber); `deps` injects - `{ toSwap, calculateFeeForSwap, getPartnerFeeRate }` so it's testable without the app graph. - Returns `{ partners, skippedSwaps, anomalies, unverified, noAffiliateFee, partnerBpsUnset, noVerifiedFee }`. - - `canonicalPartnerCode(code)` — lower-case, matching the citext columns; used for all keying. - - `checkFeeAnomaly(row, fee, tolerance)` — the deviation guard; returns a `FeeAnomaly` or null. - - `formatUsdc(usd)` — floor to 6 dp via BigNumber (accepts `BigNumber.Value`), strip trailing zeros. - - `normalizeRecipient(addr)` — viem `isAddress` / `getAddress` checksum; null if invalid or zero. - - `toCsv(rows)`, `buildPayouts(...)`, `buildRecord({ ... })`. -- **`scripts/affiliate-payouts.ts`** — entry: `PrismaClient`, the real `toSwap` / - `calculateFeeForSwap` / `getPartnerFeeRate` from `apps/swap-service/src/swaps/utils`, plus - `printSummary` / `writeArtifacts` / `generate` / `main`; `prisma.$disconnect()` in `finally`. - Run via `ts-node --transpile-only` (Node 22 `require(esm)` handles the ESM deps at runtime). -- **App change:** `calculateFeeForSwap` (in `swaps/utils.ts`) extended to also return - `actualFeeUsd` and `impliedFeeUsd` (additive, backward-compatible) so the guard can compare them. - -## Testing - -- Unit-test the lib helpers (no DB, jest via `scripts/jest.config.ts`, `*.test.ts`): - `resolveWindow` (default UTC month + override + exclusivity + arg validation), - `aggregateByPartner` (rate capping, skipped unpriceable swaps, multi-swap accrual, - anomaly exclusion, partial-partner payout), `checkFeeAnomaly` (within/over tolerance, - no-actual, missing-implied), `formatUsdc` (6-dp floor), `toCsv` (header + indices), - `normalizeRecipient` (valid/invalid/checksum), `buildPayouts` (address exclusion + sort). - `aggregateByPartner` / `checkFeeAnomaly` use stub fee deps to stay off the app graph. -- Test command: `yarn affiliate-payouts:test`. -- Integration: verified end-to-end against a live DB snapshot for 2026-06 — the guard - excluded the Maya/`ssmaya` corrupt-fee swaps (total $29,265 → $0.05). - -## Out of scope (next conversation) - -Settlement tracking — idempotency, double-pay protection, marking a window as -paid, recording the executed Safe tx. The JSON run record is the foundation; the -actual tracking design comes after this script lands. - -## Open implementation notes - -- Confirm `viem` availability in the workspace for checksum validation; fall back - to regex if absent. -- Importing app fee math into a root `scripts/` file couples the script to - `apps/swap-service` internals. Accepted tradeoff for a single source of truth; - revisit only if the script needs to run without the app present. From ad0c11bb62b71b82b903471f01527a1183be02df Mon Sep 17 00:00:00 2001 From: kaladinlight <35275952+kaladinlight@users.noreply.github.com> Date: Thu, 2 Jul 2026 13:53:21 -0600 Subject: [PATCH 5/5] chore: consolidate jest config, drop scripts tsconfig MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - add jest to root tsconfig types; remove scripts/tsconfig.json (scripts don't emit, so the per-workspace tsconfig split isn't needed — jest types belong at root) - remove dead root package.json jest block (matched no *.spec.ts and was never read; every jest call passes --config) - trim payouts gitignore + swap-service fee-calc comments Co-Authored-By: Claude Opus 4.8 (1M context) --- .gitignore | 2 +- apps/swap-service/src/swaps/utils.ts | 7 ++----- package.json | 17 ----------------- scripts/tsconfig.json | 9 --------- tsconfig.json | 2 +- 5 files changed, 4 insertions(+), 33 deletions(-) delete mode 100644 scripts/tsconfig.json diff --git a/.gitignore b/.gitignore index f49030c..9d050b3 100644 --- a/.gitignore +++ b/.gitignore @@ -4,7 +4,7 @@ node_modules .turbo /generated/prisma -# Payout artifacts (contain partner addresses + amounts) +# Payout artifacts /payouts/ # Yarn (Berry) diff --git a/apps/swap-service/src/swaps/utils.ts b/apps/swap-service/src/swaps/utils.ts index f9a5d6c..e6e0b2a 100644 --- a/apps/swap-service/src/swaps/utils.ts +++ b/apps/swap-service/src/swaps/utils.ts @@ -161,9 +161,6 @@ export const calculateFeeForSwap = ( feeUsd: number volumeUsd: number verifiedBps: number - // The on-chain collected fee (null when unavailable) and the bps-implied fee - // (volume × verifiedBps; null when volume can't be priced). Exposed so consumers - // can guard against on-chain fee amounts that don't align with the implied fee. actualFeeUsd: number | null impliedFeeUsd: number | null } | null => { @@ -189,9 +186,9 @@ export const calculateFeeForSwap = ( const impliedFeeUsd = sellAmountUsd === null ? null : bnOrZero(sellAmountUsd).times(verifiedBps).div(BPS_DENOMINATOR).toNumber() - // Prefer the on-chain collected fee; fall back to the bps-implied fee. Null only when neither - // is available (no on-chain amount and unpriceable volume) — nothing to attribute, so skip. + // 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 diff --git a/package.json b/package.json index 129d9cb..6b817d0 100644 --- a/package.json +++ b/package.json @@ -96,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/tsconfig.json b/scripts/tsconfig.json deleted file mode 100644 index a81e72a..0000000 --- a/scripts/tsconfig.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "extends": "../tsconfig.json", - "compilerOptions": { - "types": ["node", "jest"], - "isolatedModules": true - }, - "include": ["**/*.ts"], - "exclude": ["node_modules", "dist"] -} diff --git a/tsconfig.json b/tsconfig.json index 3b5fe2d..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,