Skip to content

[Cross-Layer] Stablecoin Decimal and Currency-Conversion Consistency (USDC 7-dp) Across Contract, Backend, and Frontend #243

Description

@david87131

Task Classification: Cross-layer money-handling correctness (units and rounding policy)
Affected Layers: contract, backend, frontend
Affected Paths: contract/contracts/payment/, contract/contracts/rent_obligation/, contract/contracts/escrow/, backend/src/modules/payments/, backend/src/modules/rent/, backend/src/modules/transactions/, backend/src/database/, backend/src/migrations/, frontend/lib/format.ts, frontend/lib/stellar.ts
Severity: High
Estimated Window: 48-72 hours

Technical Context & Monorepo Integration Failure

USDC on Stellar carries 7 decimal places. On-chain, amounts move as i128 stroops (integer base units, 10^7 per USDC). The backend persists amounts in TypeORM numeric/decimal columns whose precision and scale are declared per entity, then applies currency conversion sourced from seed:currencies. The frontend formats amounts for display in lib/format.ts and constructs transaction operations in lib/stellar.ts. Each layer currently defines its own decimal count, rounding mode, and integer/decimal boundary. There is no shared policy and no round-trip invariant tying the three representations together.

The gap exists because the three representations were built independently: Soroban arithmetic is exact integer i128; TypeORM numeric is exact decimal only if scale is declared and never coerced through JavaScript number; the frontend routinely parses through IEEE-754 float. Where any layer rounds differently or assumes a different scale, the same logical amount diverges. A single-layer fix is insufficient because the mismatch is a boundary problem: correcting rounding in lib/format.ts alone does not stop the backend from truncating a numeric(20,2) rent charge before it reaches the contract, and hardening the contract helper does not stop the frontend from sending a pre-rounded stroop count that already lost precision.

Concrete drift walkthrough (converted-amount mismatch and dust):

  1. A landlord sets monthly rent as 1000.005 USD in a non-USDC display currency; seed:currencies holds a USD-to-USDC rate of 1.000000.
  2. Backend PaymentsService computes the USDC amount and writes it to a payments.amount column declared numeric(20,2), truncating to 1000.00.
  3. StellarService converts to stroops by multiplying by 10^7 through a JavaScript number, yielding 10000000000 but losing the sub-cent component that was already dropped at step 2.
  4. The payment contract records amount_stroops = 10000000000 and emits a payment_recorded event; the on-chain ledger is now authoritative at 1000.0000000 USDC.
  5. Frontend lib/format.ts reads the on-chain stroop value, divides by 10^7 in float, and rounds half-up to 2 dp for display, showing 1000.00, while the tenant's original obligation in rent_obligation was 1000.005.
  6. The 0.005 difference is never charged and never reconciled. Across many obligations this accumulates as dust: rent_obligation balances never reach zero, escrow release checks that compare on-chain stroops to backend numeric fail equality, and disputes open on amounts that are correct in one representation and wrong in another.

The missing control is one decimals-and-rounding policy plus a round-trip invariant: stroops -> backend numeric -> display string -> parsed stroops must return the original stroop value with zero drift.

Core Component Invariants & Code Paths

Smart Contract Infrastructure

  • Add a shared money helper module referenced by the payment, rent_obligation, and escrow crates (place under contract/contracts/payment/src/money.rs or a workspace-shared crate re-exported by each).
  • Constants: USDC_DECIMALS: u32 = 7, USDC_SCALE: i128 = 10_000_000.
  • Functions: to_stroops(whole: i128, fractional_7dp: i128) -> Result<i128, ContractError> and checked_convert(amount: i128, rate_num: i128, rate_den: i128) -> Result<i128, ContractError> using integer i128 math only, no floating point.
  • Invariants: all amounts stored and compared as i128 stroops; conversion uses fixed rational (numerator/denominator) rounding half-even, with remainder tracked so no sub-stroop value is dropped without accounting; add ContractError::AmountScaleMismatch and ContractError::ConversionOverflow typed variants. Every env.events().publish for payment, obligation, and escrow amounts emits stroop i128 only, never a decimal.

Backend/API Layer

  • New backend/src/modules/payments/money/ module (or backend/src/common/money/) exposing a MoneyService and a Stroops value type wrapping bigint. No amount passes through JavaScript number.
  • Conversion reads rates seeded by seed:currencies and applies one rounding mode (half-even) matching the contract helper. Add ConversionService.convert(amount: Stroops, from, to): Stroops with remainder accounting.
  • TypeORM: all money columns declared numeric(28,7) with a ColumnNumericTransformer that serializes to/from bigint stroops; affected entities include payments, rent_obligation/rent_charges, escrow_deposits, transactions. Add a migration (AlignMoneyColumnsToSevenDp) via migration:generate that widens scale to 7 and backfills existing rows by re-scaling, guarded so it is idempotent and reversible with migration:revert.
  • Invariants: DB scale is exactly 7; API DTOs expose amounts as stroop strings, not floats; a validation pipe rejects any inbound amount whose scale exceeds 7. Bull queue jobs in the payments/transactions queues carry stroop strings, not numbers.

Frontend Client

  • Single formatter in frontend/lib/format.ts: formatUsdc(stroops: bigint, opts): string and parseUsdc(input: string): bigint, both using BigInt and a fixed 7-dp scale, with display rounding half-even and an explicit "display-only" flag so a rounded display value is never fed back as an authoritative amount.
  • frontend/lib/stellar.ts: build operation amounts from bigint stroops directly; remove any Number(amount) * 1e7 path. All @stellar/stellar-sdk amount arguments derive from the shared formatter.
  • Invariants: no IEEE-754 float in any money path; the value submitted to a transaction equals the value parsed from user input at 7-dp with zero drift; display components consume formatUsdc exclusively.

Root Configuration & Orchestration

  • Add a shared decimals policy constant surface consumed by tests across layers (a checked-in fixture of round-trip vectors under contract/ and mirrored in backend/test/ and frontend/ test dirs).
  • CI: extend .github/workflows/contract-ci-cd.yml, backend-ci-cd.yml, and frontend-ci-cd.yml to run the new money round-trip tests; gate merge on all three. No NEXT_PUBLIC_* env var encodes decimals; the scale is a compile-time constant per layer, kept identical.

Verification & Acceptance Criteria

  • Contract: contract/check-all.sh passes (build, clippy, test); money helper unit tests cover conversion rounding, overflow, and scale-mismatch errors.
  • Backend: pnpm build and TypeORM migration:run succeed; AlignMoneyColumnsToSevenDp applies and reverts without error on a seeded DB.
  • Frontend: pnpm build and typecheck pass; lib/format.ts and lib/stellar.ts contain no float-based money arithmetic.
  • Unit tests per layer for to_stroops/checked_convert, MoneyService/ConversionService, and formatUsdc/parseUsdc.
  • Integration test: backend records a payment, reads it back from the DB, and asserts stroop equality with the value submitted to the contract mock.
  • End-to-end round-trip test using shared vectors: stroops -> numeric -> display string -> parsed stroops returns the input with zero drift for a table of edge cases (max i128 within policy, 0.0000001 USDC, repeating-decimal conversions).
  • PR attachments: before/after state dump of one payments and one rent_obligation row showing scale change; the shared round-trip vector file; migration up/down logs.

Suggested Execution Path

Phase 1 - Contract money helper (0-16h). Implement money.rs constants and functions in the payment crate, re-export to rent_obligation and escrow, add typed errors and event stroop-only emission. Deliverable: helper plus unit tests. Exit check: contract/check-all.sh green with new tests.

Phase 2 - Backend money and conversion module (16-40h). Add MoneyService, ConversionService, Stroops type, ColumnNumericTransformer, and the AlignMoneyColumnsToSevenDp migration; wire seed:currencies rates. Deliverable: module, migration, DTO validation pipe. Exit check: migration:run/migration:revert succeed and backend integration test asserts stroop equality.

Phase 3 - Frontend formatter unification (40-58h). Replace all money formatting with formatUsdc/parseUsdc; remove float paths from lib/stellar.ts. Deliverable: single formatter, updated display components. Exit check: frontend build/typecheck pass and no float money arithmetic remains.

Phase 4 - Root round-trip vectors and CI gating (58-72h). Commit shared round-trip vector fixtures; wire the three CI workflows to run them; run the full end-to-end drift test. Deliverable: fixtures, CI updates, PR state dumps. Exit check: all three CI pipelines pass and the round-trip test reports zero drift.

Metadata

Metadata

Assignees

No one assigned

    Labels

    GrantFox OSSIssue tracked in GrantFox OSSMaybe RewardedIssue may be eligible for a GrantFox rewardOfficial CampaignCampaign: Official CampaignbugSomething isn't workinghelp wantedExtra attention is needed

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions