Skip to content

feat(swap-service): stable partner attribution via partnerCode#44

Merged
kaladinlight merged 4 commits into
developfrom
feat/swap-service-partner-code-attribution
Jun 30, 2026
Merged

feat(swap-service): stable partner attribution via partnerCode#44
kaladinlight merged 4 commits into
developfrom
feat/swap-service-partner-code-attribution

Conversation

@kaladinlight

@kaladinlight kaladinlight commented Jun 26, 2026

Copy link
Copy Markdown
Member

Description

Swaps now attribute to partners by a stable partnerCode (a real FK to the affiliate registry) instead of the mutable partnerAddress payout snapshot. This fixes two latent bugs in the old address-keyed attribution:

  • Address changes fragment history — if a partner updates their receiveAddress, their swaps split across the old and new address, and no single address returns all of them.
  • Shared receiveAddress mixes partnersreceiveAddress isn't unique, so address filtering could return two partners' swaps together.

partnerCode is unique, stable, and FK-enforced, so a partner sees all their swaps regardless of address history.

What changed

  • Schema: Swap.partnerCode added as a real relation to Affiliate.partnerCode (now NOT NULL); @@index([partnerCode]). partnerAddress is kept as a per-swap payout snapshot (for the legacy migration window and to leave the settlement payout policy — per-swap vs. current — open).
  • Migration (20260626000000_swap_partner_code_attribution): backfill partnerCode from partnerAddress (unambiguous registry matches only) → guard-abort if any affiliate lacks a code → SET NOT NULL → add FK (ON DELETE SET NULL / ON UPDATE CASCADE) → add index. Additive; partnerAddress untouched.
  • Write path: resolvePartner stamps a registry-resolved partnerCode plus the affiliate's current payout address as a snapshot; unknown/absent codes fall back to a raw partnerAddress with null code (FK-safe).
  • Reads: attribution filters through the affiliate relation / partnerCode, never partnerAddress. New code-keyed getAffiliate{Stats,Swaps}ByPartnerCode for settlement/reporting.
  • GET /v1/affiliate/{swaps,stats}: dual-param — accept partnerCode (preferred) or address during the migration window; 400 if neither.
  • GET /swaps/affiliate-fees/:partnerCode: migrated straight to partnerCode (the endpoint has no callers anywhere in the org, so no bridge needed).
  • Removed the dead partnerAddress passthrough in the AVNU verifier stub (never used on-chain).

Migration safety

The full migration was executed transactionally (and rolled back) against both prod and develop DBs — runs cleanly, FK creates against the affiliates_partner_code_key unique index, NOT-NULL guard doesn't fire (0 null-code affiliates in either env). Backfill attributes 9 swaps on prod / 38 on develop; junk/unmatched addresses safely land as null partnerCode. Nothing manual required beyond yarn db:migrate.

Follow-ups (not in this PR)

  • API migration phases 2/3: move public-api to forward partnerCode (it currently sends address to /v1/affiliate/{stats,swaps}), then contract out address.
  • Partner payout / settlement script (the eventual consumer of this data): needs a settlement ledger for idempotency, per-asset payable amounts (actualAffiliateFeeAmountCryptoBaseUnit × partner rate), and the per-swap-vs-current payout-address decision. /swaps/affiliate-fees is a USD reporting view, not the payout engine.
  • Poller gap (separate finding): checkSwapStatus never inspects the source-tx receipt, so reverted/fabricated-hash swaps sit PENDING forever — should fail on receipt.status === 0 / never-mined.

Testing

  • yarn jest in apps/swap-service58/58 pass (added attribution-read guards: by-address filters via the relation, by-code filters directly, neither touches partnerAddress).
  • eslint clean on all changed files.
  • prisma validate passes; full migration dry-run verified transactionally against prod and develop (see Migration safety above).

Attribute swaps by a stable partnerCode (FK to the affiliate registry)
instead of the mutable partnerAddress payout snapshot. Address changes
no longer fragment a partner's history, and shared receive addresses
can no longer mix two partners' swaps.

- schema: add Swap.partnerCode as a real relation to Affiliate.partnerCode
  (now NOT NULL); keep partnerAddress as a per-swap payout snapshot
- migration: backfill partnerCode from partnerAddress (unambiguous matches
  only), enforce affiliate code NOT NULL, add FK + index; partnerAddress
  retained for the legacy window
- write path: resolvePartner stamps a registry-resolved code plus the
  payout-address snapshot; unknown/absent codes fall back to a raw address
- reads: attribute through the affiliate relation / partnerCode, never
  partnerAddress; add code-keyed getAffiliate{Stats,Swaps}ByPartnerCode
- /v1/affiliate/{swaps,stats}: accept partnerCode (preferred) or address
  during the migration window
- drop the dead partnerAddress passthrough in the AVNU verifier stub

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jun 26, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@kaladinlight, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 53 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: d86c1ec3-6725-42b5-b2b9-1656f529a081

📥 Commits

Reviewing files that changed from the base of the PR and between 6f3e8c8 and 54f992a.

📒 Files selected for processing (1)
  • apps/swap-service/src/swaps/swaps.service.ts
📝 Walkthrough

Walkthrough

This PR adds partnerCode-based attribution across swap creation, affiliate read endpoints, affiliate fee calculation, Prisma schema/migration, verification payloads, and test coverage, while keeping partnerAddress support for address-based lookups.

Changes

Partner code attribution flow

Layer / File(s) Summary
Schema and backfill
prisma/schema/swap-service.prisma, prisma/migrations/20260626000000_swap_partner_code_attribution/migration.sql
Affiliate.partnerCode becomes required, Swap.partnerCode is added, and the migration backfills, constrains, and indexes the new relation.
Swap partner resolution
apps/swap-service/src/swaps/swaps.service.ts
createSwap resolves partnerCode and partnerAddress together, persists both fields, and logs the combined identifier.
Partner-code fee endpoint
apps/swap-service/src/swaps/swaps.controller.ts, apps/swap-service/src/swaps/swaps.service.ts
GET /affiliate-fees/:partnerCode now calls calculateAffiliateFeesByPartnerCode, which filters swaps by partnerCode.
Affiliate query contract and dispatch
apps/swap-service/src/affiliate/types.ts, apps/swap-service/src/affiliate/affiliate.controller.ts
Affiliate stats and swap DTOs make address and partnerCode optional, and the controller routes requests by partnerCode, address, or a bad-request error.
Affiliate read queries and tests
apps/swap-service/src/affiliate/affiliate.service.ts, apps/swap-service/src/affiliate/__tests__/affiliate.service.test.ts
Affiliate read methods filter through the affiliate relation or partnerCode, and tests assert the generated Prisma filters plus partner-code stats totals.
Verification payload and fixtures
apps/swap-service/src/verification/swap-verification.service.ts, apps/swap-service/src/verification/__tests__/fixtures/*/swap.ts
verifyAvnu removes affiliateAddress from the success payload, and swap fixtures add partnerCode values.

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant AffiliateController
  participant AffiliateService
  participant PrismaService
  Client->>AffiliateController: GET stats or swaps with partnerCode or address
  alt partnerCode present
    AffiliateController->>AffiliateService: getAffiliateStatsByPartnerCode() / getAffiliateSwapsByPartnerCode()
    AffiliateService->>PrismaService: findMany where partnerCode = ...
  else address present
    AffiliateController->>AffiliateService: getAffiliateStatsByAddress() / getAffiliateSwapsByAddress()
    AffiliateService->>PrismaService: findMany where affiliate.walletAddress or receiveAddress = ...
  else missing query
    AffiliateController-->>Client: BadRequestException
  end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • shapeshift/microservices#41: Touches the same affiliate attribution and fee code paths in affiliate.service.ts and swaps.service.ts, which this PR extends with partnerCode-based lookups and routes.

Poem

A rabbit hopped where swaps were spun,
and sniffed out partnerCode in the sun.
With carrots, tests, and a tiny cheer,
I bounded through the code with ears held clear. 🐇

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: stable partner attribution in swap-service via partnerCode.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/swap-service-partner-code-attribution

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

The endpoint has no callers anywhere in ~/github/shapeshift (public-api
proxies /v1/affiliate/* and /swaps/:quoteId only), so migrate it straight
to the canonical partnerCode key rather than a backwards-compatible bridge.

- route: /swaps/affiliate-fees/:partnerCode
- service: calculateAffiliateFees -> calculateAffiliateFeesByPartnerCode,
  filtering directly on partnerCode

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (3)
prisma/migrations/20260626000000_swap_partner_code_attribution/migration.sql (1)

38-45: 🩺 Stability & Availability | 🔵 Trivial

Verify this migration is lock-safe for production.

ADD CONSTRAINT and non-concurrent CREATE INDEX can block writes on a hot swaps table. If this runs against prod traffic, consider NOT VALID/VALIDATE CONSTRAINT and CREATE INDEX CONCURRENTLY, or document the maintenance window.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@prisma/migrations/20260626000000_swap_partner_code_attribution/migration.sql`
around lines 38 - 45, The migration adds a foreign key and a standard index on
the hot swaps table, which can block writes during deployment. Update the
migration around the swaps_partnerCode_fkey and swaps_partnerCode_idx changes to
use a lock-safe approach such as adding the constraint as NOT VALID then
validating it separately, and creating the index with CONCURRENTLY if the
database path supports it; otherwise, explicitly document that this migration
must run in a maintenance window.
apps/swap-service/src/affiliate/affiliate.service.ts (2)

110-205: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consolidate the duplicated stats/swaps query logic.

getAffiliateStatsByAddress/getAffiliateStatsByPartnerCode differ only in the where partner filter yet repeat the identical aggregation loop, and the four methods all rebuild the same createdAt date-range clause. Extracting a shared date-clause helper and a single aggregation routine that takes a partner-filter fragment would cut this duplication and prevent the two stats paths from diverging.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/swap-service/src/affiliate/affiliate.service.ts` around lines 110 - 205,
The affiliate query methods duplicate the same createdAt date-range filtering
and, for stats, the same aggregation loop, which should be shared. Extract a
helper for building the createdAt clause and a common stats aggregation routine
used by getAffiliateStatsByAddress and getAffiliateStatsByPartnerCode, with the
only varying input being the partner filter fragment. Keep
getAffiliateSwapsByAddress and getAffiliateSwapsByPartnerCode aligned with the
shared date helper so the query logic stays consistent.

110-113: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Reuse the DTO types for the partner-code option params.

These methods inline { startDate?: Date; endDate?: Date; ... } while the address variants accept AffiliateStatsQueryDto / AffiliateSwapsQueryDto. Reusing the DTO types keeps the option contracts consistent and avoids drift if the DTOs gain fields.

Also applies to: 180-183

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/swap-service/src/affiliate/affiliate.service.ts` around lines 110 - 113,
Reuse the existing query DTOs for the partner-code stats methods instead of
inlining option shapes: update getAffiliateStatsByPartnerCode and the matching
partner-code swaps method to accept the same DTO types used by the address
variants (AffiliateStatsQueryDto and AffiliateSwapsQueryDto). Keep the method
contracts aligned by referencing those DTO symbols directly so any future DTO
field changes apply consistently.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/swap-service/src/affiliate/affiliate.service.ts`:
- Around line 67-72: Address-based affiliate lookups are missing swaps that were
saved only with partnerAddress and no affiliate relation. Update
getAffiliateStatsByAddress and getAffiliateSwapsByAddress in
affiliate.service.ts to include a fallback filter on partnerAddress when
querying swaps, alongside the existing affiliate relation match, so legacy
migration-window rows remain reachable by address.

In `@apps/swap-service/src/swaps/swaps.service.ts`:
- Around line 155-173: The partner code lookup in swaps.service.ts should not
fall back to null on a Prisma query failure. In the branch inside the
partnerCode check of the swap resolution logic, keep returning null only when
findUnique returns no affiliate, but in the catch path for the prisma.affiliate
lookup, log the failure and rethrow so the create flow fails instead of silently
losing attribution. Use the existing logger.warn and the surrounding partnerCode
resolution block to locate the change.

---

Nitpick comments:
In `@apps/swap-service/src/affiliate/affiliate.service.ts`:
- Around line 110-205: The affiliate query methods duplicate the same createdAt
date-range filtering and, for stats, the same aggregation loop, which should be
shared. Extract a helper for building the createdAt clause and a common stats
aggregation routine used by getAffiliateStatsByAddress and
getAffiliateStatsByPartnerCode, with the only varying input being the partner
filter fragment. Keep getAffiliateSwapsByAddress and
getAffiliateSwapsByPartnerCode aligned with the shared date helper so the query
logic stays consistent.
- Around line 110-113: Reuse the existing query DTOs for the partner-code stats
methods instead of inlining option shapes: update getAffiliateStatsByPartnerCode
and the matching partner-code swaps method to accept the same DTO types used by
the address variants (AffiliateStatsQueryDto and AffiliateSwapsQueryDto). Keep
the method contracts aligned by referencing those DTO symbols directly so any
future DTO field changes apply consistently.

In
`@prisma/migrations/20260626000000_swap_partner_code_attribution/migration.sql`:
- Around line 38-45: The migration adds a foreign key and a standard index on
the hot swaps table, which can block writes during deployment. Update the
migration around the swaps_partnerCode_fkey and swaps_partnerCode_idx changes to
use a lock-safe approach such as adding the constraint as NOT VALID then
validating it separately, and creating the index with CONCURRENTLY if the
database path supports it; otherwise, explicitly document that this migration
must run in a maintenance window.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 1f947d77-1aff-40c0-a131-9b530827b476

📥 Commits

Reviewing files that changed from the base of the PR and between 45af3a2 and 6f3e8c8.

📒 Files selected for processing (13)
  • apps/swap-service/src/affiliate/__tests__/affiliate.service.test.ts
  • apps/swap-service/src/affiliate/affiliate.controller.ts
  • apps/swap-service/src/affiliate/affiliate.service.ts
  • apps/swap-service/src/affiliate/types.ts
  • apps/swap-service/src/swaps/swaps.controller.ts
  • apps/swap-service/src/swaps/swaps.service.ts
  • apps/swap-service/src/verification/__tests__/fixtures/maya/swap.ts
  • apps/swap-service/src/verification/__tests__/fixtures/near/swap.ts
  • apps/swap-service/src/verification/__tests__/fixtures/relay/swap.ts
  • apps/swap-service/src/verification/__tests__/fixtures/thorchain/swap.ts
  • apps/swap-service/src/verification/swap-verification.service.ts
  • prisma/migrations/20260626000000_swap_partner_code_attribution/migration.sql
  • prisma/schema/swap-service.prisma
💤 Files with no reviewable changes (1)
  • apps/swap-service/src/verification/swap-verification.service.ts

Comment thread apps/swap-service/src/affiliate/affiliate.service.ts
Comment thread apps/swap-service/src/swaps/swaps.service.ts Outdated
kaladinlight added a commit that referenced this pull request Jun 26, 2026
A thrown affiliate lookup (operational/DB error) is distinct from an
unknown code (findUnique returns null). Rethrow instead of swallowing,
so creation fails loudly rather than silently persisting a partner swap
with no attribution and losing payout data.

Addresses CodeRabbit review on PR #44.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A thrown affiliate lookup (operational/DB error) is distinct from an
unknown code (findUnique returns null). Rethrow instead of swallowing,
so creation fails loudly rather than silently persisting a partner swap
with no attribution and losing payout data.

Addresses CodeRabbit review on PR #44.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@kaladinlight kaladinlight force-pushed the feat/swap-service-partner-code-attribution branch from 0c8e910 to 1cbcd53 Compare June 26, 2026 23:47
…Address

Lets this branch deploy independently of the public-api change. When a caller
sends a raw partnerAddress (no partnerCode) — as public-api does today — the
write path reverse-resolves it to a partnerCode (unambiguous registry matches
only) so swaps stay code-attributed and remain visible to the relation-keyed
reads. public-api can switch to sending partnerCode later with no staging.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@kaladinlight kaladinlight merged commit 2385039 into develop Jun 30, 2026
2 checks passed
@kaladinlight kaladinlight deleted the feat/swap-service-partner-code-attribution branch June 30, 2026 16:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant