Labels / Complexity: bug · High — 600 points
Problem
A Campaign has a single raisedAmount Decimal column (prisma/schema.prisma), yet it can accept multiple assets: acceptedAssets is parsed into { assetType: 'native' } | { assetType: 'credit', code, issuer } entries (parseAcceptedAssets in src/campaigns/campaigns.service.ts) and Donation.assetCode/assetIssuer store whatever asset the donor sent. Two code paths then collapse those distinct assets into one number:
recalculateCampaignStats (src/campaigns/campaigns.service.ts) sums donation amount across every confirmed donation with no asset filter:
const agg = await tx.donation.aggregate({
where: { campaignId, status: 'CONFIRMED' },
_sum: { amount: true }, // ← sums XLM + USDC + ... into one Decimal
});
getContractBalance (src/campaigns/campaigns.service.ts) does the same with on-chain balances and then overwrites the stored value when the numbers differ:
let onChainTotal = 0;
for (const b of balances) {
onChainTotal += parseFloat(b.balance); // ← native + every credit asset summed together
}
...
if (discrepancyDetected) {
await this.prisma.campaign.update({ where: { id: campaignId }, data: { raisedAmount: onChainTotal } });
}
Consequence: for a campaign that accepts XLM and USDC, raisedAmount, progressPercentage, totalRaised, and the "most funded" sort (orderBy: { raisedAmount: 'desc' } in browseCampaigns) become a meaningless sum of unlike units. The auto-correction in getContractBalance actively persists that corruption, so a 100 XLM + 50 USDC campaign reports a "raised" total of 150 with no unit. Donors and creators see a number that does not correspond to any real monetary value.
Root cause
// src/campaigns/campaigns.service.ts — recalculateCampaignStats
_sum: { amount: true } // ← no asset dimension in the WHERE or the aggregation
// src/campaigns/campaigns.service.ts — getContractBalance
onChainTotal += parseFloat(b.balance); // ← XLM and USDC balances added arithmetically
Why this is architecturally hard
- The shortcut — dropping multi-asset support — contradicts the existing
acceptedAssets/assetCode model. The real decision is the denomination strategy: (a) denominate raisedAmount in a single base asset (native XLM) with per-asset breakdown stored separately, or (b) keep per-asset amounts and compute progress against a per-asset goal. This changes the data model, not just one line.
- Any correct sum needs an exchange-rate source for cross-asset conversion (the README already documents that the USD-equivalent column was removed for exactly this reason and awaits a price-oracle integration). The fix must decide whether conversion is in-scope or whether the UI must show per-asset totals instead.
getContractBalance writes back to raisedAmount automatically; that side-effect must be removed or made asset-aware, otherwise it keeps re-corrupting whatever the data model settles on.
- The change touches the campaign browse sort,
getCampaignStats, getUserActivitySummary, and profile totalRaised/totalDonated computations, all of which currently reduce mixed assets to one parseFloat.
Proposed design
Store per-asset amounts alongside (or instead of) the single raisedAmount — for example a raisedByAsset JSON column keyed by assetCode/issuer — and derive any single-number summary only for a declared base asset. Change getContractBalance to return a per-asset balance list and never auto-write a mixed-unit total. Offer the tradeoff explicitly: either introduce a price oracle now, or present per-asset totals in the API and let the frontend render them.
Downstream impact
This changes a public API contract: raisedAmount, progressPercentage, totalRaised, and CampaignStats are consumed by the sibling MilestoneX-Frontend (campaign cards, analytics, and the Project type in types/api.ts). The frontend must be updated to render per-asset totals or the chosen base-asset summary. Not repo-local; no generated bindings, but the frontend types need to match the new shape.
Acceptance criteria
Service
Data
Tests
Docs
Out of scope
Integrating a live price oracle for fiat conversion is separate; this issue establishes the asset-aware data representation the oracle can later feed.
Getting started
Files in scope: src/campaigns/campaigns.service.ts, src/donations/donations.service.ts, prisma/schema.prisma.
npm run build
npm test
npx prisma validate
Good first files to read: src/campaigns/campaigns.service.ts, src/donations/donations.service.ts, prisma/schema.prisma.
Labels / Complexity: bug · High — 600 points
Problem
A
Campaignhas a singleraisedAmount Decimalcolumn (prisma/schema.prisma), yet it can accept multiple assets:acceptedAssetsis parsed into{ assetType: 'native' } | { assetType: 'credit', code, issuer }entries (parseAcceptedAssetsinsrc/campaigns/campaigns.service.ts) andDonation.assetCode/assetIssuerstore whatever asset the donor sent. Two code paths then collapse those distinct assets into one number:recalculateCampaignStats(src/campaigns/campaigns.service.ts) sums donationamountacross every confirmed donation with no asset filter:getContractBalance(src/campaigns/campaigns.service.ts) does the same with on-chain balances and then overwrites the stored value when the numbers differ:Consequence: for a campaign that accepts XLM and USDC,
raisedAmount,progressPercentage,totalRaised, and the "most funded" sort (orderBy: { raisedAmount: 'desc' }inbrowseCampaigns) become a meaningless sum of unlike units. The auto-correction ingetContractBalanceactively persists that corruption, so a 100 XLM + 50 USDC campaign reports a "raised" total of 150 with no unit. Donors and creators see a number that does not correspond to any real monetary value.Root cause
Why this is architecturally hard
acceptedAssets/assetCodemodel. The real decision is the denomination strategy: (a) denominateraisedAmountin a single base asset (native XLM) with per-asset breakdown stored separately, or (b) keep per-asset amounts and compute progress against a per-asset goal. This changes the data model, not just one line.getContractBalancewrites back toraisedAmountautomatically; that side-effect must be removed or made asset-aware, otherwise it keeps re-corrupting whatever the data model settles on.getCampaignStats,getUserActivitySummary, and profiletotalRaised/totalDonatedcomputations, all of which currently reduce mixed assets to oneparseFloat.Proposed design
Store per-asset amounts alongside (or instead of) the single
raisedAmount— for example araisedByAssetJSON column keyed byassetCode/issuer— and derive any single-number summary only for a declared base asset. ChangegetContractBalanceto return a per-asset balance list and never auto-write a mixed-unit total. Offer the tradeoff explicitly: either introduce a price oracle now, or present per-asset totals in the API and let the frontend render them.Downstream impact
This changes a public API contract:
raisedAmount,progressPercentage,totalRaised, andCampaignStatsare consumed by the siblingMilestoneX-Frontend(campaign cards, analytics, and theProjecttype intypes/api.ts). The frontend must be updated to render per-asset totals or the chosen base-asset summary. Not repo-local; no generated bindings, but the frontend types need to match the new shape.Acceptance criteria
Service
recalculateCampaignStatsno longer sums amounts across different assets.getContractBalancereturns balances per asset and never overwrites the stored total with a mixed-unit sum.Data
Tests
raisedAmount = 150as a scalar.getContractBalancereports the two balances separately.Docs
Out of scope
Integrating a live price oracle for fiat conversion is separate; this issue establishes the asset-aware data representation the oracle can later feed.
Getting started
Files in scope:
src/campaigns/campaigns.service.ts,src/donations/donations.service.ts,prisma/schema.prisma.npm run build npm test npx prisma validateGood first files to read:
src/campaigns/campaigns.service.ts,src/donations/donations.service.ts,prisma/schema.prisma.