Skip to content

feat: affiliate fee split model (partnerBps + shapeshiftBps)#12391

Merged
kaladinlight merged 8 commits into
developfrom
feat/affiliate-fee-split
May 28, 2026
Merged

feat: affiliate fee split model (partnerBps + shapeshiftBps)#12391
kaladinlight merged 8 commits into
developfrom
feat/affiliate-fee-split

Conversation

@kaladinlight
Copy link
Copy Markdown
Member

@kaladinlight kaladinlight commented May 28, 2026

Description

Introduces the split-fee affiliate model across the stack. affiliateBps is now the total on-chain fee while partnerBps and shapeshiftBps are the configured intent snapshotted at quote time. Swap-service owns the source of truth; web, public-api, swap-widget, and the affiliate dashboard are updated to consume and forward the three values consistently.

Pairs with: companion microservices PR on feat/swap-bps-and-partner-fields (separate repo).

Issue (if applicable)

closes #

Risk

Medium. Touches the affiliate fee plumbing end-to-end and the public-api request/response surface. No on-chain transaction changes. Verified manually against swap-service local; existing test suites pass after fixture updates.

What protocols, transaction types, wallets or contract interactions might be affected by this PR?

All swappers indirectly through affiliate-fee plumbing — no protocol-specific changes. THORChain getL1RateOrQuote switches .toString().toFixed(0) on integer base-unit strings (correctness, not behavioral).

Breaking API changes — call out for integrators

  • POST /v1/swap/quote: sendAddress is now required (was optional).
  • POST /v1/swap/quote and /v1/swap/rates: allowMultiHop request param removed; always single-hop.
  • GET /v1/swap/status response: affiliateAddress removed; partnerAddress added.
  • All affiliate config endpoints now return partnerBps + shapeshiftBps (was bps).

Testing

Engineering

  • pnpm run lint --fix && pnpm run type-check (both packages)
  • pnpm test in packages/public-api (includes new calculatePartnerFeeAmountUsd regression tests for the Math.min(partnerBps/affiliateBps, 1) cap and the fee-exempt branch)
  • Manual: hit a partner URL → confirm partner_bps, shapeshift_bps, partner_code written to localStorage with the partner's share (not the legacy total + 10). Then trade and inspect the POST /swaps payload — should send all three bps as numbers plus partnerAddress / partnerCode.
  • Existing users on the previous storage schema: the module-load migration in useAffiliateTracking clears pre-v2 entries once and sets a version marker; returning users see "no partner" until they re-hit a partner URL.

Operations

No infra changes. Deploy after the companion swap-service PR lands so the /v1/partner/:code and affiliate config responses include shapeshiftBps.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Affiliate dashboard now displays partner basis points separately from shapeshift fees
    • Added partner address tracking to affiliate information
  • Updates

    • Partner code label simplified to "Code" in settings
    • Updated messaging on partner code earnings
  • Refactor

    • Restructured fee breakdown to distinguish partner fees from shapeshift fees across dashboard and API responses

Review Change Stack

kaladinlight and others added 8 commits May 27, 2026 12:12
Aligns the dashboard with the updated API response shape where `bps` is
now `partnerBps` to distinguish partner fees from the ShapeShift base cut.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Rename affiliateAddress to partnerAddress on AffiliateInfo and StoredQuote
  (swap-service registration maps to their partnerAddress field)
- Add partnerBps/shapeshiftBps to auth middleware, stored quotes, and all
  response schemas (quote, rates, status)
- Make partnerBps optional — only present for attributed partner swaps
- Use quote.affiliateBps from swapper (supports free bridge swaps with 0 bps)
- Remove affiliateAddress/partnerAddress from API responses (not actionable
  for clients; kept on StoredQuote for swap-service registration)
- Remove allowMultiHop from request schemas (hardcode false — not supported
  through status tracking / swap-service)
- Make sendAddress required on quote requests (needed for all chains;
  UTXO clients use account's 0/0 receive address)
- Remove dead fields from StoredQuote (sellChainId, stepChainIds)
- Simplify getQuote to single step (firstStep/lastStep collapsed)
- Delegate approval check entirely to buildApprovalInfo (already handles
  non-EVM chains internally)
- Add shared BpsFields for consistent OpenAPI descriptions across schemas
- Add PartnerCodeResponse type to auth middleware
- Fix swap-service registration: send partnerAddress (not affiliateAddress),
  include partnerBps and shapeshiftBps

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Make partnerBps optional on TradeQuote, TradeRate, and QuoteResponse
- Remove affiliateAddress from QuoteResponse (no longer in API response)
- Fix test fixtures: remove partnerBps from unattributed rate mocks,
  use `as unknown as QuoteResponse` for partial quote fixtures

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Avoids fractional scientific-notation output (e.g. "1.5e18") from
BigNumber when converting to base-unit strings — downstream consumers
expect integer-only strings.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…countId

- SwapServiceAffiliateSwap: require affiliateBps and partnerBps (no longer
  nullable / derived). Swap-service is now the source of truth and sends
  both fields explicitly.
- getAffiliateSwaps: read partnerBps directly from the swap record instead
  of recomputing as max(0, affiliateBps - shapeshiftBps).
- Swap registration payload: include buyAccountId (receiveAddress), now
  required by swap-service.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…-end

- useAffiliateTracking: drop the hardcoded SHAPESHIFT_CUT_BPS=10 and read
  partnerBps + shapeshiftBps directly from /v1/partner/:code. Store each as
  its own localStorage entry plus a derived affiliate_bps (sum) so consumers
  don't repeat the math.
- Rename readStoredAffiliate -> readStoredPartnerAddress for consistency with
  the new partner-prefixed key names. Add readStoredShapeshiftBps and
  readStoredAffiliateBps; the latter defaults to DEFAULT_FEE_BPS so
  getAffiliateBps collapses to a one-line read.
- tradeExecution: drop the inlined SHAPESHIFT_CUT_BPS math and orphaned
  bnOrZero/selectUsdRateByAssetId/sellAmountUsd plumbing. Send partnerAddress,
  partnerBps, affiliateBps, shapeshiftBps in the POST /swaps body, mirroring
  public-api's status/utils.ts shape. shapeshiftBps falls back to affiliateBps
  at the call site when no split is stored (no-partner case: SS keeps the full
  fee), keeping the "no stored split" semantics visible instead of buried
  behind a shared default.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…sponse

Same-asset bridge swaps (e.g. ETH/Ethereum -> ETH/Arbitrum) settle with
affiliateBps=0 on-chain even when the swap was attributed to a partner with a
non-zero configured partnerBps. Two callers got this wrong:

- calculatePartnerFeeAmountUsd: Path 1 was correctly skipped by the
  affiliateBps>0 guard, but Path 2 (inferred from volume * partnerBps / 10000)
  ran anyway and reported a phantom partner fee. Settlement is unaffected
  because swap-service's calculateFeeForSwap bails on verifiedBps=0, but the
  dashboard surfaced inflated revenue. Short-circuit on affiliateBps===0 so
  both paths agree that no fee earns the partner nothing.
- AffiliateSwapSchema: response didn't include affiliateBps, leaving
  consumers unable to distinguish "fee-exempt swap" from "partner with low
  share" — both showed partnerBps=50 with no further context. Add the field
  so dashboard rows can detect affiliateBps===0 && partnerBps>0 and render
  accordingly. Mirror schema in affiliate-dashboard's useAffiliateSwaps.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
public-api:
- calculatePartnerFeeAmountUsd: cap partner share at 100% of capture via
  Math.min(partnerBps/affiliateBps, 1) implemented as an explicit branch so
  BigNumber precision is preserved; collapse the early-guard tree into a single
  `if (!partnerBps || !affiliateBps) return null`.
- getQuote/getRates: restore `lastStep` for buyAmountAfter/Before; keep `step`
  for first-hop-only fields. No-op for current single-step swappers; correct for
  any future multi-step quote (e.g. THORChain longtail).
- status: re-add partnerAddress to SwapStatusResponse alongside the bps fields.
- auth middleware: validate the /v1/partner response through a zod schema so
  malformed upstream data falls back to the default-bps path instead of
  propagating NaN through req.affiliateInfo.
- BpsFields: docstring clarifies affiliateBps is the swapper-reported total,
  not strictly partnerBps + shapeshiftBps (fee-exempt swaps break the sum).

web:
- useAffiliateTracking: one-shot module-load migration clears pre-v2 partner
  storage (previously stored partner+10 under PARTNER_BPS_KEY) so returning
  users re-resolve cleanly instead of reading legacy totals as partner-only
  values. Collapse five duplicated readStored* helpers into one shared reader.
  Drop denormalized AFFILIATE_BPS_KEY in favour of lazy sum at read time.
- tradeExecution: Number(affiliateBps) so all three bps fields in POST /swaps
  share the type swap-service's CreateSwapDto declares.

affiliate-dashboard:
- AffiliateConfigSchema: add shapeshiftBps to match the public-api response
  shape so future UI can display the platform-fee breakdown.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@kaladinlight kaladinlight requested a review from a team as a code owner May 28, 2026 19:37
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 28, 2026

📝 Walkthrough

Walkthrough

This PR refactors the affiliate fee structure across the entire ShapeShift stack, splitting the single bps field into separate partnerBps and shapeshiftBps components. It also migrates client-side partner tracking from code-based to address-based resolution. The changes affect API contracts, middleware, quote/rate handlers, affiliate-specific routes, and client-side fee integration.

Changes

Affiliate Fee Structure Refactoring

Layer / File(s) Summary
Affiliate data model and BpsFields schema
packages/public-api/src/types.ts
Introduces BpsFields constant with Zod schemas for affiliateBps, optional partnerBps, and required shapeshiftBps; updates AffiliateInfo type to include partnerAddress?, partnerCode?, partnerBps?, and required shapeshiftBps.
Public API contract schemas
packages/public-api/src/routes/affiliate/types.ts, packages/public-api/src/routes/quote/types.ts, packages/public-api/src/routes/rates/types.ts, packages/public-api/src/routes/status/types.ts
Updates AffiliateConfigResponseSchema to use partnerBps instead of bps; makes affiliateBps non-nullable in swap service schemas; adds partnerBps to swap service schema; refactors quote/rates/status response schemas to use BpsFields spread and removes affiliateAddress fields; removes allowMultiHop from request schemas.
Quote storage and partner resolution
packages/public-api/src/lib/quoteStore.ts, packages/public-api/src/lib/quoteStore.test.ts, packages/public-api/src/middleware/auth.ts
Updates StoredQuote type to include partnerAddress?, partnerBps?, and required shapeshiftBps; removes affiliateAddress, sellChainId, and stepChainIds; refactors resolvePartnerCodeFromService to parse and return partnerAddress, partnerBps, and shapeshiftBps via Zod schema; computes affiliateBps as the sum of partnerBps and shapeshiftBps.
Quote endpoint: allowMultiHop removal and baseQuote refactoring
packages/public-api/src/routes/quote/getQuote.ts
Hardcodes allowMultiHop: false instead of accepting from request; updates EVM chain detection to use CHAIN_NAMESPACE.Evm; introduces shared baseQuote object and refactors quote storage to include partnerBps/shapeshiftBps and remove affiliateAddress/sellChainId/stepChainIds; makes approval generation unconditional.
Rates endpoint: allowMultiHop and bps field updates
packages/public-api/src/routes/rates/getRates.ts, packages/public-api/src/routes/rates/types.ts
Hardcodes allowMultiHop: false; derives buy amount from last step instead of first step; updates bps field mapping to use partnerBps/shapeshiftBps; removes affiliateAddress from response.
Affiliate-specific routes: partner fee and swaps
packages/public-api/src/routes/affiliate/calculatePartnerFeeAmountUsd.ts, packages/public-api/src/routes/affiliate/calculatePartnerFeeAmountUsd.test.ts, packages/public-api/src/routes/affiliate/getAffiliateSwaps.ts
Refactors partner fee calculation to immediately return null when partnerBps or affiliateBps is falsy; implements fee capping when partnerBps >= affiliateBps; derives partnerBps directly from swap data instead of computing from affiliateBps and shapeshiftBps; adds test cases for fee-exempt and capping scenarios.
Status endpoint: partner fields and registration payload
packages/public-api/src/routes/status/getSwapStatus.ts, packages/public-api/src/routes/status/utils.ts
Updates status response to include partnerAddress, partnerBps, and shapeshiftBps from stored quote; refactors registration payload builder to send partner data with numeric conversions instead of parsed affiliate bps.
Client affiliate tracking: address-based resolution and storage
src/hooks/useAffiliateTracking/useAffiliateTracking.ts, src/hooks/useAffiliateTracking/index.ts
Major refactor from code-based to address-based partner resolution fetching from SWAP_SERVICE_BASE_URL; introduces localStorage versioning for migration; adds readStoredAffiliateBps() that sums partnerBps and shapeshiftBps with fallback; updates state to use partnerAddress instead of affiliate address; exports new reader functions for partner address/BPS/shapeshift BPS.
Trade execution and fee calculation integration
src/lib/tradeExecution.ts, src/lib/fees/utils.ts
Updates trade creation payload to send partnerAddress, partnerBps, and shapeshiftBps instead of partner code; refactors getAffiliateBps() to use readStoredAffiliateBps() helper for total affiliate fee computation.
Widget and dashboard types: partnerBps and shapeshiftBps
packages/swap-widget/src/types/index.ts, packages/affiliate-dashboard/src/hooks/useAffiliateConfig.ts, packages/affiliate-dashboard/src/hooks/useAffiliateSwaps.ts, packages/affiliate-dashboard/src/components/ConfigBar.tsx, packages/affiliate-dashboard/src/components/settings/ConfigSummaryCard.tsx, packages/affiliate-dashboard/src/components/settings/SettingsTab.tsx
Updates TradeQuote, TradeRate, and QuoteResponse types to include optional partnerBps and required shapeshiftBps; removes affiliateAddress field; updates affiliate dashboard config schema to expect partnerBps and shapeshiftBps; updates UI components to display config.partnerBps and changes "Partner Code" label to "Code".
Test fixtures and number formatting
packages/swap-widget/src/machines/__tests__/swapMachine.test.ts, packages/swap-widget/src/machines/__tests__/types.test.ts, packages/swapper/src/thorchain-utils/getL1RateOrQuote.ts
Updates swap machine test fixtures to include shapeshiftBps field; refactors quote fixtures to use as unknown as QuoteResponse casting; changes thorchain fee formatting to use toFixed(0) for consistent integer string output.
Dashboard description update
packages/affiliate-dashboard/src/components/settings/RegisterCard.tsx
Updates settings card description text to clarify fee earnings message for partner code usage.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~75 minutes

This PR involves substantial changes across the entire stack with multiple interconnected layers. The refactoring affects data models, API contracts, middleware logic, multiple route handlers, client-side storage and resolution, and test fixtures. While most changes are consistent patterns (field renames and structure changes), the complexity arises from the breadth of affected files, the logic changes in fee calculation and partner resolution, and the need to verify data flow across all layers.

🐰 A rabbit hops through fields where partners prance,
Where bps now splits in a graceful dance,
Address-based tracking, fees so clear,
Partner and Shapeshift together appear! 🌟

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely summarizes the main change: introducing a split-fee affiliate model using partnerBps and shapeshiftBps instead of a single bps field, which is the primary architectural change across the entire changeset.
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.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/affiliate-fee-split

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 and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🤖 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 `@packages/public-api/src/routes/status/utils.ts`:
- Around line 25-27: The BPS fields currently convert values with Number(...)
which turns invalid/missing inputs into NaN (serialized as null) and the
partnerBps ternary drops numeric 0; update the mapping for partnerBps,
affiliateBps, and shapeshiftBps to validate and preserve zero by only converting
when storedQuote.<field> is not null/undefined and is a valid finite number
string/number (e.g., use Number.isFinite(Number(val)) or a small helper like
toNumberOrUndefined(val) that returns undefined for null/undefined/invalid and
returns 0 for "0"), and otherwise set the property to undefined so JSON won’t
contain null/NaN values. Ensure you change the three references partnerBps,
affiliateBps, and shapeshiftBps in utils.ts accordingly.

In `@src/hooks/useAffiliateTracking/useAffiliateTracking.ts`:
- Around line 72-85: resolvePartnerCode currently parses remote JSON and returns
it without checking shape; add a runtime guard that verifies the parsed object
matches the PartnerData contract (presence and types of required fields) before
returning or letting it be cached in localStorage. Modify resolvePartnerCode to
validate the decoded JSON (e.g., check required keys and primitive types on the
object returned by await response.json()), return null if validation fails, and
ensure any place that stores partner info to localStorage only writes when the
value is a valid PartnerData. Use the PartnerData type and the
resolvePartnerCode function name to locate where to add the guard.
- Around line 138-143: readStoredAffiliateBps can return "NaN" if stored strings
are non-numeric; fix by parsing and validating both stored values (use
readStored(PARTNER_BPS_KEY) and readStored(SHAPESHIFT_BPS_KEY)), convert each to
Number, and check with Number.isFinite (or !Number.isNaN) — if either parsed
value is invalid, return DEFAULT_FEE_BPS; otherwise return String(parsedPartner
+ parsedShapeshift). Ensure you reference readStoredAffiliateBps,
PARTNER_BPS_KEY, SHAPESHIFT_BPS_KEY, DEFAULT_FEE_BPS, and readStored when making
the change.

In `@src/lib/tradeExecution.ts`:
- Around line 223-240: Validate numeric fee fields before sending the POST to
/swaps: when building the payload in the axios.post call, parse and guard
partnerBps (from readStoredPartnerBps()), shapeshiftBps (from
readStoredShapeshiftBps()) and any use of affiliateBps so they are finite
numbers (e.g., Number(...) and Number.isFinite check); if a parsed value is NaN
or not finite, omit the field (use undefined) or fallback to a safe default (for
shapeshiftBps fallback to affiliateBps only if affiliateBps is valid). Update
the payload construction around partnerBps and shapeshiftBps in
tradeExecution.ts so invalid localStorage values do not result in NaN being
posted.
🪄 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: bb47ab28-1374-42ad-a02e-5d66b26af6bc

📥 Commits

Reviewing files that changed from the base of the PR and between 17460d2 and 99c7e4f.

📒 Files selected for processing (29)
  • packages/affiliate-dashboard/src/components/ConfigBar.tsx
  • packages/affiliate-dashboard/src/components/settings/ConfigSummaryCard.tsx
  • packages/affiliate-dashboard/src/components/settings/RegisterCard.tsx
  • packages/affiliate-dashboard/src/components/settings/SettingsTab.tsx
  • packages/affiliate-dashboard/src/hooks/useAffiliateConfig.ts
  • packages/affiliate-dashboard/src/hooks/useAffiliateSwaps.ts
  • packages/public-api/src/lib/quoteStore.test.ts
  • packages/public-api/src/lib/quoteStore.ts
  • packages/public-api/src/middleware/auth.ts
  • packages/public-api/src/routes/affiliate/calculatePartnerFeeAmountUsd.test.ts
  • packages/public-api/src/routes/affiliate/calculatePartnerFeeAmountUsd.ts
  • packages/public-api/src/routes/affiliate/getAffiliateSwaps.ts
  • packages/public-api/src/routes/affiliate/types.ts
  • packages/public-api/src/routes/quote/getQuote.ts
  • packages/public-api/src/routes/quote/types.ts
  • packages/public-api/src/routes/rates/getRates.ts
  • packages/public-api/src/routes/rates/types.ts
  • packages/public-api/src/routes/status/getSwapStatus.ts
  • packages/public-api/src/routes/status/types.ts
  • packages/public-api/src/routes/status/utils.ts
  • packages/public-api/src/types.ts
  • packages/swap-widget/src/machines/__tests__/swapMachine.test.ts
  • packages/swap-widget/src/machines/__tests__/types.test.ts
  • packages/swap-widget/src/types/index.ts
  • packages/swapper/src/thorchain-utils/getL1RateOrQuote.ts
  • src/hooks/useAffiliateTracking/index.ts
  • src/hooks/useAffiliateTracking/useAffiliateTracking.ts
  • src/lib/fees/utils.ts
  • src/lib/tradeExecution.ts

Comment thread packages/public-api/src/routes/status/utils.ts
Comment thread src/hooks/useAffiliateTracking/useAffiliateTracking.ts
Comment thread src/hooks/useAffiliateTracking/useAffiliateTracking.ts
Comment thread src/lib/tradeExecution.ts
@kaladinlight kaladinlight merged commit 6c36490 into develop May 28, 2026
4 checks passed
@kaladinlight kaladinlight deleted the feat/affiliate-fee-split branch May 28, 2026 19:59
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