Skip to content

Charge and collect vortex/partner fees on Alfredpay corridors#1288

Open
ebma wants to merge 6 commits into
stagingfrom
claude/elated-wright-5ad4c5
Open

Charge and collect vortex/partner fees on Alfredpay corridors#1288
ebma wants to merge 6 commits into
stagingfrom
claude/elated-wright-5ad4c5

Conversation

@ebma

@ebma ebma commented Jul 24, 2026

Copy link
Copy Markdown
Member

Summary

The Alfredpay corridors (USDT-based MXN/COP/ARS/USD on- and off-ramps) displayed vortex and partner-markup fees but never collected them on-chain — their routes built no distributeFees transaction. This PR lands the fix in two commits:

  1. Interim honesty fix (first commit): forced the vortex/partner components to zero so displayed fees matched reality.
  2. Full fee collection (second commit): both directions now charge the components and collect them on Polygon; the zeroing is lifted.

How collection works

  • Charging — the onramp deducts vortex/partner fees from the Alfredpay mint (bridge/transfer legs move mint − fees); the offramp deducts them from the bridged USD leg before pricing the Alfredpay payout. The fee residual stays on the Polygon ephemeral.
  • Distribution — a new distributeFees phase (last before complete, entered from destinationTransfer / alfredpayOfframpTransfer only when fee transfers exist) broadcasts plain sequential ephemeral-signed USDT transfers: network+vortex to the vortex payout_address_evm (with the env fallback), partner markup to the pricing partner's address. Per-nonce hashes make retries idempotent. The SDK signs the new unsigned txs generically — no SDK changes.
  • Failure safety — fees are collected only after the user-facing leg succeeded. The onramp mint fallback stays full-mint; the offramp refund fallback is sized deposit + fees, so failed ramps return the user's full value. finalSettlementSubsidy (SELL) tops the ephemeral up to deposit + fees so the fee transfers are always covered.
  • Subsidy interaction — both Alfredpay discount engines subtract the charged fee from the subsidy's expected-output target (spec invariant 7: subsidization must not bypass fee collection). Without this, the unbounded-subsidy string-compare bug (SPEC-009, fixed on the review branch) would silently refill the fee via finalSettlementSubsidy.
  • Why not Multicall3aggregate3 executes calls with the Multicall3 contract as msg.sender, so a batched ERC-20 transfer cannot move the ephemeral's tokens. This also means the existing Base partner-split path is broken — documented as a new open checklist item in fee-integrity.md (separate fix).

Notes for reviewers

  • No DB/state-machine seeding needed: phase successors are decided in code (nextPhaseSelector); the seed:phase-metadata script references a deleted file and the CLAUDE.md claim about DB-seeded transitions is stale.
  • validation.ts now classifies distributeFees on Polygon as an EVM tx (was Substrate-by-default outside Base).
  • Zero-fee quotes build no fee transfers and skip the phase entirely — existing corridor flows unchanged.
  • The offramp corridor tests now set world.squidRouter.toTokenDecimals = 6; the previous 18-decimal default shrank the bridged amount to ~0 and the unbounded-subsidy bug silently padded it back, masking fee math.
  • Spec edits will conflict with the codex/security-spec-review-2026-07-24 rewrite of fee-integrity.md — port the Alfredpay section + the new Base Multicall3 item when the branches meet.

Follow-up: broken Multicall3 partner split on Base (fixed here too)

While implementing the Polygon distribution it turned out the existing Base partner-split path was broken: it batched plain ERC-20 transfer calls through Multicall3.aggregate3, which executes with the Multicall3 contract as msg.sender — the batch could only move the contract's (empty) balance and would revert, failing any Base ramp with nonzero partner markup plus a configured partner payout address. Third commit:

  • The Base builder now shares the sequential-transfer implementation with the Polygon builder (one ephemeral-signed USDC transfer per recipient at consecutive nonces); contracts/Multicall3.ts is deleted (no remaining users).
  • The distribute-fees handler broadcasts EVM fee transfers with per-nonce hash idempotency on both networks; the legacy single distributeFeeHash is honored for in-flight ramps.
  • Payout addresses are now only required when a quote actually carries nonzero fees (previously zero-fee quotes also demanded a vortex payout address at registration).
  • Regression test in brl-offramp.scenario.test.ts: a profile-priced partner (markup + vortex fee, 1 USDC each) — both payout addresses receive their exact fee on the fake ledger. The old aggregate3 batch credits nobody, so the assertions fail without the fix.
  • fee-integrity.md: open checklist item resolved; the "Distribution Mechanisms" section now describes the sequential-transfer scheme.

Test plan

  • End-to-end "fee collection" corridor tests (BUY direct + SELL): fee charged in the quote (output reduced), distributeFees in the phase history, and the vortex payout address receives the exact fee on the fake ledger.
  • Full API suite: 664 pass, 0 fail (isolated test DB). bun typecheck and Biome clean.

The Alfredpay onramp/offramp routes build no distributeFees transaction,
so vortex and partner-markup quote components could never be collected
on-chain: the onramp deducted them from the user's output and stranded
the difference on the ephemeral, while the offramp displayed them
without charging anyone. Force both components to zero in the Alfredpay
fee engines so displayed fees match what is actually charged; only the
Alfredpay provider fee applies. Re-introducing these fees requires a
fee-distribution phase for the routes first.
@netlify

netlify Bot commented Jul 24, 2026

Copy link
Copy Markdown

Deploy Preview for vortexfi ready!

Name Link
🔨 Latest commit 8aa8416
🔍 Latest deploy log https://app.netlify.com/projects/vortexfi/deploys/6a6729401c3e170008368d22
😎 Deploy Preview https://deploy-preview-1288--vortexfi.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

@netlify

netlify Bot commented Jul 24, 2026

Copy link
Copy Markdown

Deploy Preview for vortex-sandbox ready!

Name Link
🔨 Latest commit 8aa8416
🔍 Latest deploy log https://app.netlify.com/projects/vortex-sandbox/deploys/6a672940e2597b0008183330
😎 Deploy Preview https://deploy-preview-1288--vortex-sandbox.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

@netlify

netlify Bot commented Jul 24, 2026

Copy link
Copy Markdown

Deploy Preview for vrtx-dashboard canceled.

Name Link
🔨 Latest commit 8aa8416
🔍 Latest deploy log https://app.netlify.com/projects/vrtx-dashboard/deploys/6a67294026489900081ce6b3

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Zeros uncollectable Vortex and partner fees for Alfredpay corridors so quotes match transaction behavior.

Changes:

  • Forces both fee components to zero in Alfredpay fee engines.
  • Adds MXN corridor regression tests.
  • Documents the fee-distribution limitation and follow-up path.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
docs/security-spec/03-ramp-engine/fee-integrity.md Documents Alfredpay fee handling.
apps/api/src/tests/corridors/mxn-onramp.scenario.test.ts Tests onramp fee output.
apps/api/src/tests/corridors/mxn-offramp.scenario.test.ts Tests offramp fee output.
apps/api/src/api/services/quote/engines/fee/onramp-alfredpay-to-evm.ts Zeros onramp platform fees.
apps/api/src/api/services/quote/engines/fee/offramp-evm-to-alfredpay.ts Zeros offramp platform fees.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread apps/api/src/tests/corridors/mxn-onramp.scenario.test.ts Outdated
Comment thread apps/api/src/tests/corridors/mxn-offramp.scenario.test.ts Outdated
Both Alfredpay directions now charge vortex/partner-markup fees and
collect them on-chain, replacing the interim zeroing:

- onramp deducts the components from the Polygon mint (user-facing legs
  move mint minus fees); offramp deducts them from the bridged USD leg
  before pricing the Alfredpay payout
- the fee residual on the Polygon ephemeral is paid out by a new
  distributeFees phase that runs only after the user-facing leg
  succeeded, as plain sequential ephemeral-signed USDT transfers
  (Multicall3 aggregate3 cannot move the caller's tokens); per-nonce
  hashes make retries idempotent
- failure paths never collect: the mint fallback stays full-mint and the
  offramp refund fallback is sized deposit plus fees
- both discount engines subtract the charged fee from the subsidy's
  expected-output target so a discount subsidy cannot refill the fee
- offramp finalSettlementSubsidy tops the ephemeral up to deposit plus
  fees so the fee transfers are always covered
@ebma ebma changed the title Zero uncollected vortex and partner fees on Alfredpay corridors Charge and collect vortex/partner fees on Alfredpay corridors Jul 27, 2026
ebma added 3 commits July 27, 2026 10:18
… base

Multicall3.aggregate3 executes calls with the Multicall3 contract as
msg.sender, so the partner-split path's batched ERC-20 transfers would
move the contract's (empty) balance, not the ephemeral's — the batch
reverts and any Base ramp with nonzero partner markup plus a configured
partner payout address fails at distributeFees. The Base builder now
emits one plain ephemeral-signed transfer per fee recipient at
consecutive nonces via the same implementation as the Alfredpay/Polygon
builder, and the distribute-fees handler broadcasts EVM fee transfers
with per-nonce hash idempotency on both networks (the legacy single
distributeFeeHash is honored for in-flight ramps). Payout addresses are
now only required when a quote carries nonzero fees.
… partner payout

Review findings on the sequential fee distribution:

- updateRamp merged presigned transactions by phase/network/signer only, so
  the two distributeFees transfers of a split-fee quote overwrote each
  other and /start rejected the ramp as incomplete. The nonce is now part
  of the replacement identity, and the Base split-fee corridor test drives
  presigns through the real /v1/ramp/update and /v1/ramp/start flow.
- Alfredpay corridors charged partner markup even when the pricing partner
  had no payout_address_evm, silently stranding the charged portion on the
  Polygon ephemeral. requiresEvmPartnerPayout now covers the Alfredpay
  fiats so such quotes fail closed at creation, and the fee-distribution
  builder throws instead of warn-dropping a charged markup as defense in
  depth.
…lize amounts

Review findings on the sequential fee distribution:

- the distribute-fees handler required the FULL original fee total on the
  ephemeral before consulting per-nonce hashes, so any retry after a
  partial success (or a crash after all transfers) waited on a balance
  that could never return and wedged the phase forever. Paid transfers
  are now reconciled first and only the unpaid amount — decoded from the
  pending presigned transfers — is required (legacy non-transfer presigns
  fall back to the full total).
- the offramp deducted the full-precision preNabla fee while distribution
  moved metadata-derived amounts rounded to 2 fiat decimals, so deposit,
  fallback, settlement target and transfers could disagree by dust. All
  consumers now derive the charged total from one component-wise raw
  computation, and the offramp deduction applies the same rounding and
  price-feed conversion as the persisted metadata: bridged amount equals
  Alfredpay deposit plus distributed fees in exact raw units.
- fee transfers carried the destination-transfer builder's 3x-buffered
  gas fields and the SDK multiplies by 3 again at signing (9x); the
  builder now emits unbuffered estimates so only the SDK buffer applies.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 18 out of 18 changed files in this pull request and generated 3 comments.

Comment on lines +72 to +76
const feeFiatRounded = (ctx.preNabla?.deductibleFeeAmountInFeeCurrency ?? new Big(0)).round(2);
const feeUsd = await priceFeedService.convertCurrency(
feeFiatRounded.toString(),
ctx.preNabla?.feeCurrency ?? (outputCurrency as RampCurrency),
EvmToken.USDC as RampCurrency
Comment on lines +201 to +207
const status = await this.checkEvmTransactionStatus(existingHash, network);
if (status === ExtrinsicStatus.Success) {
logger.info(`Fee distribution transfer at nonce ${feeTx.nonce} already succeeded for ramp ${state.id}, skipping.`);
continue;
}
}
pendingTxs.push(feeTx);
Comment on lines +302 to +306
if (request.rampType === RampDirection.SELL && isAlfredpayToken(request.outputCurrency as FiatToken)) {
return true;
}
if (request.rampType === RampDirection.BUY && isAlfredpayToken(request.inputCurrency as FiatToken)) {
return true;
…arkup quotes

Copilot review findings:

- the offramp deduction rounded the aggregate preNabla fee while the
  persisted metadata rounds vortex and partner components separately, so
  two sub-cent components could deduct half of what distribution moves.
  preNabla now exposes the unrounded components and the Alfredpay
  deduction rounds, converts, and floors each one exactly like
  computeFeeComponentRaws.
- a mined-but-reverted fee transfer consumed its nonce, yet the handler
  rebroadcast the same primary presign forever. A Fail receipt now fails
  the phase unrecoverably for manual intervention via the presigned
  backups; only unknown hashes are rebroadcast.
- the EVM-payout quote guard fired on markupType alone, rejecting legal
  zero-value markup configs that charge nothing; it now also requires a
  positive markupValue (Big-compared — Sequelize DECIMALs are strings).
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.

2 participants