You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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):
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.
Backend PaymentsService computes the USDC amount and writes it to a payments.amount column declared numeric(20,2), truncating to 1000.00.
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.
The payment contract records amount_stroops = 10000000000 and emits a payment_recorded event; the on-chain ledger is now authoritative at 1000.0000000 USDC.
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.
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).
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.
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.tsSeverity: High
Estimated Window: 48-72 hours
Technical Context & Monorepo Integration Failure
USDC on Stellar carries 7 decimal places. On-chain, amounts move as
i128stroops (integer base units, 10^7 per USDC). The backend persists amounts in TypeORMnumeric/decimalcolumns whose precision and scale are declared per entity, then applies currency conversion sourced fromseed:currencies. The frontend formats amounts for display inlib/format.tsand constructs transaction operations inlib/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; TypeORMnumericis exact decimal only if scale is declared and never coerced through JavaScriptnumber; the frontend routinely parses through IEEE-754float. 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 inlib/format.tsalone does not stop the backend from truncating anumeric(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):
seed:currenciesholds a USD-to-USDC rate of 1.000000.PaymentsServicecomputes the USDC amount and writes it to apayments.amountcolumn declarednumeric(20,2), truncating to 1000.00.StellarServiceconverts to stroops by multiplying by 10^7 through a JavaScriptnumber, yielding 10000000000 but losing the sub-cent component that was already dropped at step 2.paymentcontract recordsamount_stroops = 10000000000and emits apayment_recordedevent; the on-chain ledger is now authoritative at 1000.0000000 USDC.lib/format.tsreads 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 inrent_obligationwas 1000.005.rent_obligationbalances never reach zero, escrow release checks that compare on-chain stroops to backendnumericfail 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 stroopsmust return the original stroop value with zero drift.Core Component Invariants & Code Paths
Smart Contract Infrastructure
moneyhelper module referenced by thepayment,rent_obligation, andescrowcrates (place undercontract/contracts/payment/src/money.rsor a workspace-shared crate re-exported by each).USDC_DECIMALS: u32 = 7,USDC_SCALE: i128 = 10_000_000.to_stroops(whole: i128, fractional_7dp: i128) -> Result<i128, ContractError>andchecked_convert(amount: i128, rate_num: i128, rate_den: i128) -> Result<i128, ContractError>using integeri128math only, no floating point.i128stroops; conversion uses fixed rational (numerator/denominator) rounding half-even, with remainder tracked so no sub-stroop value is dropped without accounting; addContractError::AmountScaleMismatchandContractError::ConversionOverflowtyped variants. Everyenv.events().publishfor payment, obligation, and escrow amounts emits stroopi128only, never a decimal.Backend/API Layer
backend/src/modules/payments/money/module (orbackend/src/common/money/) exposing aMoneyServiceand aStroopsvalue type wrappingbigint. No amount passes through JavaScriptnumber.seed:currenciesand applies one rounding mode (half-even) matching the contract helper. AddConversionService.convert(amount: Stroops, from, to): Stroopswith remainder accounting.numeric(28,7)with aColumnNumericTransformerthat serializes to/frombigintstroops; affected entities includepayments,rent_obligation/rent_charges,escrow_deposits,transactions. Add a migration (AlignMoneyColumnsToSevenDp) viamigration:generatethat widens scale to 7 and backfills existing rows by re-scaling, guarded so it is idempotent and reversible withmigration:revert.payments/transactionsqueues carry stroop strings, not numbers.Frontend Client
frontend/lib/format.ts:formatUsdc(stroops: bigint, opts): stringandparseUsdc(input: string): bigint, both usingBigIntand 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 frombigintstroops directly; remove anyNumber(amount) * 1e7path. All@stellar/stellar-sdkamount arguments derive from the shared formatter.formatUsdcexclusively.Root Configuration & Orchestration
contract/and mirrored inbackend/test/andfrontend/test dirs)..github/workflows/contract-ci-cd.yml,backend-ci-cd.yml, andfrontend-ci-cd.ymlto run the new money round-trip tests; gate merge on all three. NoNEXT_PUBLIC_*env var encodes decimals; the scale is a compile-time constant per layer, kept identical.Verification & Acceptance Criteria
contract/check-all.shpasses (build, clippy, test);moneyhelper unit tests cover conversion rounding, overflow, and scale-mismatch errors.pnpm buildand TypeORMmigration:runsucceed;AlignMoneyColumnsToSevenDpapplies and reverts without error on a seeded DB.pnpm buildand typecheck pass;lib/format.tsandlib/stellar.tscontain no float-based money arithmetic.to_stroops/checked_convert,MoneyService/ConversionService, andformatUsdc/parseUsdc.stroops -> numeric -> display string -> parsed stroopsreturns the input with zero drift for a table of edge cases (maxi128within policy, 0.0000001 USDC, repeating-decimal conversions).paymentsand onerent_obligationrow 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.rsconstants and functions in thepaymentcrate, re-export torent_obligationandescrow, add typed errors and event stroop-only emission. Deliverable: helper plus unit tests. Exit check:contract/check-all.shgreen with new tests.Phase 2 - Backend money and conversion module (16-40h). Add
MoneyService,ConversionService,Stroopstype,ColumnNumericTransformer, and theAlignMoneyColumnsToSevenDpmigration; wireseed:currenciesrates. Deliverable: module, migration, DTO validation pipe. Exit check:migration:run/migration:revertsucceed and backend integration test asserts stroop equality.Phase 3 - Frontend formatter unification (40-58h). Replace all money formatting with
formatUsdc/parseUsdc; remove float paths fromlib/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.