Overview
CrowdPay's path payment contribution flow submits a pathPaymentStrictReceive to Stellar but gives contributors zero information beforehand — they do not know the exchange rate, the number of hops, the estimated fee, or what slippage tolerance has been applied. A contributor sending XLM to a USDC campaign is flying blind. If the DEX liquidity shifts between the time they click "Contribute" and the time the transaction is submitted, they may receive a failed transaction with PATH_PAYMENT_OVER_SENDMAX and no explanation. This issue rebuilds the entire contribution flow around a preview-first architecture: the contributor sees the exact conversion details before signing, slippage tolerance is configurable, the best path is selected from multiple Horizon route options, and failed contributions are diagnosed and presented in plain English rather than a raw Stellar result code.
What needs to be built
backend/src/services/pathPaymentPreview.js — Preview engine
-
POST /api/campaigns/:id/contribution/preview — accepts { sourceAsset, sourceAmount?, destinationAmount?, slippageBps: number (default 100) }:
- Calls Horizon
GET /paths/strict-receive (if destinationAmount is specified) or GET /paths/strict-send (if sourceAmount is specified) with the campaign's target asset as the destination
- Fetches all returned paths (up to 5); for each path computes:
- Effective exchange rate:
destinationAmount / sourceAmount
- Total hop count
- Estimated network fee (from Horizon
fee_stats P90)
sendMax value applying the contributor's slippage tolerance: sourceAmount * (1 + slippageBps / 10000)
- Liquidity confidence score: ratio of path's
destination_amount to the requested amount — paths that can only partially fill get a lower score
- Ranks paths by: 1) lowest
sendMax, 2) fewest hops, 3) highest liquidity confidence score
- Returns:
{ paths: [{ rank, hops: [{ asset, issuer }], effectiveRate, sendMax, estimatedFee, liquidityScore }], recommendedPath: number, expiresAt: timestamp (30 seconds) }
- Stores the preview result in Redis with a 30-second TTL keyed by
(campaignId, sourceAsset, destinationAmount, slippageBps) — used to validate the subsequent contribution submission
backend/src/services/contribution.js — Submission with preview validation
-
POST /api/campaigns/:id/contributions — now requires { previewToken, selectedPathIndex, sourceAsset, sourceAmount }:
- Fetches the preview from Redis by
previewToken; if not found or expired, returns 422 PREVIEW_EXPIRED — contributor must re-preview before submitting
- Validates
selectedPathIndex is valid within the stored preview
- Builds the
pathPaymentStrictReceive using the preview's sendMax and exact intermediate path assets — not a fresh Horizon lookup (avoids race conditions)
- If submission returns
PATH_PAYMENT_OVER_SENDMAX: automatically retries once with sendMax * 1.05 (5% buffer); if still fails, returns SLIPPAGE_EXCEEDED with the actual rate at time of failure
- On success: records the actual
sourceAmount, destinationAmount, all intermediate hops, and the effective rate in the contributions table
backend/src/services/contributionDiagnostics.js — Failure diagnosis
-
GET /api/contributions/:id/diagnosis — given a failed contribution ID:
- Fetches the Stellar transaction result from Horizon by tx hash
- Maps every possible
pathPaymentStrictReceive result code to a plain-English explanation with a suggested action:
PATH_PAYMENT_OVER_SENDMAX → "The conversion rate changed while your transaction was being processed. Increase your slippage tolerance to 1.5% or try again."
PATH_PAYMENT_NO_ISSUER → "One of the intermediate assets in the payment path no longer has an active issuer. Choose a different source asset."
PATH_PAYMENT_TOO_FEW_OFFERS → "There is not enough liquidity in this trading pair right now. Try a smaller amount or wait for more market activity."
- Returns:
{ resultCode, explanation, suggestedAction, retryable: boolean, alternativePathsAvailable: boolean }
Frontend — Contribution modal rebuild
-
Step 1 — Asset & Amount:
- Source asset selector: XLM native, or any asset (code + issuer input); contributor types either a source amount ("I want to send") or destination amount ("I want the campaign to receive")
- Slippage tolerance selector: Low (0.5%), Medium (1%), High (2%), Custom (input in bps)
- "Preview Conversion" button — calls
POST /api/campaigns/:id/contribution/preview
-
Step 2 — Route Preview (before any signing):
- Top recommended path displayed prominently:
- Hop chain:
XLM → USDC (Centre) rendered as asset bubbles connected by arrows
- Effective rate:
1 XLM = 0.104 USDC
- Max you'll send:
96.32 XLM (with slippage applied)
- Campaign receives:
10.00 USDC (exact)
- Network fee:
0.00001 XLM
- Preview expires in: live countdown timer (30 seconds)
- "Show all routes" expander — displays all returned paths with their rank, hop count, and rate for comparison
- "Change route" — lets the contributor select a non-recommended path
- "Confirm & Contribute" button — triggers signing and submission
-
Step 3 — Result:
- Success: animated confetti, tx hash with Stellar Expert link, exact amounts received by campaign
- Failure: plain-English error from the diagnosis endpoint, "Retry with higher slippage" button (pre-fills Step 1 with 1.5% tolerance), "Try a different asset" button
Database migrations
-
Alter contributions: add source_asset, source_amount (numeric), path_hops (jsonb array of { asset, issuer }), effective_rate (numeric), slippage_bps (int), send_max (numeric), retry_count (int default 0), diagnosis (jsonb nullable for failed contributions)
Acceptance criteria
Overview
CrowdPay's path payment contribution flow submits a
pathPaymentStrictReceiveto Stellar but gives contributors zero information beforehand — they do not know the exchange rate, the number of hops, the estimated fee, or what slippage tolerance has been applied. A contributor sending XLM to a USDC campaign is flying blind. If the DEX liquidity shifts between the time they click "Contribute" and the time the transaction is submitted, they may receive a failed transaction withPATH_PAYMENT_OVER_SENDMAXand no explanation. This issue rebuilds the entire contribution flow around a preview-first architecture: the contributor sees the exact conversion details before signing, slippage tolerance is configurable, the best path is selected from multiple Horizon route options, and failed contributions are diagnosed and presented in plain English rather than a raw Stellar result code.What needs to be built
backend/src/services/pathPaymentPreview.js— Preview enginePOST /api/campaigns/:id/contribution/preview— accepts{ sourceAsset, sourceAmount?, destinationAmount?, slippageBps: number (default 100) }:GET /paths/strict-receive(ifdestinationAmountis specified) orGET /paths/strict-send(ifsourceAmountis specified) with the campaign's target asset as the destinationdestinationAmount / sourceAmountfee_statsP90)sendMaxvalue applying the contributor's slippage tolerance:sourceAmount * (1 + slippageBps / 10000)destination_amountto the requested amount — paths that can only partially fill get a lower scoresendMax, 2) fewest hops, 3) highest liquidity confidence score{ paths: [{ rank, hops: [{ asset, issuer }], effectiveRate, sendMax, estimatedFee, liquidityScore }], recommendedPath: number, expiresAt: timestamp (30 seconds) }(campaignId, sourceAsset, destinationAmount, slippageBps)— used to validate the subsequent contribution submissionbackend/src/services/contribution.js— Submission with preview validationPOST /api/campaigns/:id/contributions— now requires{ previewToken, selectedPathIndex, sourceAsset, sourceAmount }:previewToken; if not found or expired, returns422 PREVIEW_EXPIRED— contributor must re-preview before submittingselectedPathIndexis valid within the stored previewpathPaymentStrictReceiveusing the preview'ssendMaxand exact intermediate path assets — not a fresh Horizon lookup (avoids race conditions)PATH_PAYMENT_OVER_SENDMAX: automatically retries once withsendMax * 1.05(5% buffer); if still fails, returnsSLIPPAGE_EXCEEDEDwith the actual rate at time of failuresourceAmount,destinationAmount, all intermediate hops, and the effective rate in thecontributionstablebackend/src/services/contributionDiagnostics.js— Failure diagnosisGET /api/contributions/:id/diagnosis— given a failed contribution ID:pathPaymentStrictReceiveresult code to a plain-English explanation with a suggested action:PATH_PAYMENT_OVER_SENDMAX→ "The conversion rate changed while your transaction was being processed. Increase your slippage tolerance to 1.5% or try again."PATH_PAYMENT_NO_ISSUER→ "One of the intermediate assets in the payment path no longer has an active issuer. Choose a different source asset."PATH_PAYMENT_TOO_FEW_OFFERS→ "There is not enough liquidity in this trading pair right now. Try a smaller amount or wait for more market activity."{ resultCode, explanation, suggestedAction, retryable: boolean, alternativePathsAvailable: boolean }Frontend — Contribution modal rebuild
Step 1 — Asset & Amount:
POST /api/campaigns/:id/contribution/previewStep 2 — Route Preview (before any signing):
XLM → USDC (Centre)rendered as asset bubbles connected by arrows1 XLM = 0.104 USDC96.32 XLM(with slippage applied)10.00 USDC(exact)0.00001 XLMStep 3 — Result:
Database migrations
Alter
contributions: addsource_asset,source_amount(numeric),path_hops(jsonb array of{ asset, issuer }),effective_rate(numeric),slippage_bps(int),send_max(numeric),retry_count(int default 0),diagnosis(jsonb nullable for failed contributions)Acceptance criteria
XLM → USDCconversion — the recommended path has the lowestsendMaxamong all returned pathspreviewToken(> 30 seconds old) returns422 PREVIEW_EXPIRED— no Stellar transaction is submittedsendMax * 1.05onPATH_PAYMENT_OVER_SENDMAXsucceeds when the rate has shifted by less than 5% between preview and submissionexplanationandsuggestedActionfor everypathPaymentStrictReceiveresult code documented in the Stellar protocolpath_hopsin thecontributionstable exactly matches the intermediate assets used in the confirmed on-chain transaction — confirmed by decoding the Stellar transaction result