Skip to content

Every amount-conversion function hardcodes 7 decimals — PoolInfo/AssetInfo has no decimals field, so any non-7-decimal pool asset displays and submits silently wrong amounts #136

Description

@prodbycorne

Overview

src/lib/soroban-parsers.ts's AssetInfo type carries no precision information at all:

export interface AssetInfo {
  code: string;
  issuer?: string;
  isNative?: boolean;
}

Every single place this codebase converts between on-chain raw units and display units hardcodes 7 decimals (Stellar classic/native precision), with no reference to the actual asset being displayed:

// soroban-parsers.ts:51-59
export function bigintToDisplayAmount(raw: unknown): string {
  if (typeof raw === 'bigint') {
    const stroops = raw < 0n ? 0n : raw;
    const whole = stroops / 10_000_000n;   // hardcoded 7-decimal divisor
    const frac = stroops % 10_000_000n;
    return `${whole}.${String(frac).padStart(7, '0')}`;
  }
  return String(raw ?? '0');
}

and identically in soroban.ts's getPoolHistory/getPoolDepositors (amountDisplay = amount / 10_000_000), amountToStroops's default parameter (decimals = 7, soroban.ts:180), and the standalone unlockAssets wrapper's Math.round(parseFloat(amount) * 10_000_000) (soroban.ts:1692). This assumption is safe for the Stellar-native SAC (Stellar Asset Contract) wrapper of classic XLM/classic assets, which always uses 7 decimals — but PoolInfo/AssetInfo (soroban-parsers.ts:9-25) explicitly model a per-pool, potentially non-native asset (asset_code/asset_issuer fields, an isNative flag), which is exactly the shape a multi-pool farming platform would use to support arbitrary Soroban tokens, not just one fixed asset. Generic Soroban token contracts (the SEP-41 token interface) can declare any decimals value via their own decimals() view function — 6, 9, 18, or anything else — and nothing in this codebase ever calls it, stores it, or threads it through. If any deployed pool is ever backed by a token with decimals other than 7, every amount this app displays for that pool (TVL, daily rate, user position, credits, depositor amounts) is off by a power of ten from the true value, and, far more seriously, every transaction this app submits for that pool via amountToStroops(amount, 7) sends a raw unit amount computed at the wrong scale — a user typing "100" intending 100 tokens could have a transaction built for 100 × 10^7 raw units against a contract that actually expects 100 × 10^18, i.e. a catastrophically wrong on-chain amount, not just a wrong display.

Requirements

  • Add a decimals: number field to AssetInfo (and thread it through PoolInfo), sourced from the pool contract's actual returned asset metadata if available, or from an explicit on-chain lookup of the asset token contract's decimals() if the pool contract doesn't already surface it.
  • Replace every hardcoded 10_000_000 / decimals = 7 in bigintToDisplayAmount, getPoolHistory, getPoolDepositors, amountToStroops's default, and the unlockAssets wrapper's float conversion with the actual per-asset decimals value, passed explicitly rather than defaulted.
  • Until real per-asset decimals data is available end-to-end, add an explicit runtime assertion/warning when a pool's asset isn't the native XLM SAC, so a silently-wrong-precision pool doesn't ship unnoticed.

Acceptance Criteria

  • AssetInfo/PoolInfo carry a decimals field, populated from real contract data (not defaulted to 7 for non-native assets).
  • bigintToDisplayAmount (or its call sites) accepts the relevant asset's decimals and uses it instead of a hardcoded 10_000_000.
  • amountToStroops's caller-side usage in lockAssets/unlockAssets passes the pool's actual asset decimals, not the implicit default of 7.
  • A test with a mocked pool asset declaring decimals: 18 demonstrates a "100" input produces the correct 18-decimal raw amount, not a 7-decimal one — and that the reverse (displaying a raw 18-decimal balance) also renders correctly.
  • Existing native-XLM (7-decimal) behavior is unchanged (regression-safe) once the fix lands.

Additional Notes

More precise references

  • src/lib/soroban-parsers.ts:9-13 (AssetInfo) and :15-25 (PoolInfo) — confirmed no decimals field exists in either type.
  • src/lib/soroban-parsers.ts:51-59 (bigintToDisplayAmount) — confirmed the hardcoded 10_000_000n divisor.
  • src/lib/soroban.ts:180-205 (amountToStroops) — confirmed decimals = 7 is the function's default parameter, and confirmed via grep -n "amountToStroops(" src/lib/soroban.ts that every call site (buildLockAssetsTransaction) relies on that default rather than passing a pool-specific value.
  • src/lib/soroban.ts:1339,1400 (getPoolHistory/getPoolDepositors) — confirmed both divide raw event amounts by the literal 10_000_000 with no asset-awareness.
  • src/lib/soroban.ts:1692 (unlockAssets wrapper) — confirmed Math.round(parseFloat(amount) * 10_000_000), the same hardcoded assumption on the unlock/float-conversion side (a separate, already-tracked bug — see cross-references — but sharing this same root assumption).
  • src/lib/soroban-parsers.ts:73-112 (parsePoolEntry) — confirmed the function already parses asset_code/asset_issuer/is_native from contract data (i.e. the plumbing for "this pool's asset isn't necessarily native XLM" already exists structurally), making the absence of a decimals field alongside those fields a clear, specific gap rather than an unconsidered feature.

Additional edge cases

  • Even for assets that are SEP-41-compliant classic-asset SACs (the most likely near-term case for additional pools), Stellar's classic asset precision is fixed at 7 decimals by protocol, so this bug may currently be latent/unreachable if every deployed pool today happens to wrap a classic asset. That doesn't reduce the severity: the codebase's own data model (isNative/asset_code/asset_issuer per pool) explicitly anticipates non-native, and therefore potentially non-7-decimal, Soroban-native tokens, and there is no runtime guard today that would catch or warn about a future pool violating the unstated assumption before real funds are put at risk.
  • formatAssetAmount (soroban.ts:1653-1656) takes an AssetInfo parameter already ((amount: string, asset: AssetInfo)) but only uses it for asset.code in the display string — it's an existing call site that could immediately consume a new decimals field once added, with minimal additional plumbing.

Implementation sketch

  1. Extend AssetInfo with decimals: number (default 7 only for isNative: true, required/explicit otherwise).
  2. Update parsePoolEntry (soroban-parsers.ts:73-112) to read a decimals/asset_decimals field from the contract's pool struct if present; if the pool contract doesn't expose it directly, add a SorobanService method that queries the underlying asset token contract's decimals() view function once per unique asset and caches it.
  3. Change bigintToDisplayAmount(raw: unknown, decimals: number = 7) to accept an explicit decimals parameter, and update all call sites to pass pool.asset.decimals.
  4. Change amountToStroops call sites in buildLockAssetsTransaction/unlockAssets to pass the resolved pool asset's decimals instead of relying on the default.

Test/reproduction plan

  • bigintToDisplayAmount(123456789012345678n, 18) → assert correct 18-decimal display string, distinct from the current always-7-decimal output.
  • amountToStroops("100", 18) → assert 100n * 10n**18n, not 100n * 10n**7n.
  • Integration test: mock a pool whose parsed PoolInfo.asset.decimals === 18; drive DepositModal/useLockFlow through a deposit of "100"; assert the constructed contract-call argument reflects 18-decimal raw units.

Cross-references

Metadata

Metadata

Assignees

Labels

GrantFox OSSIssue tracked in GrantFox OSSMaybe RewardedIssue may be eligible for a GrantFox rewardOfficial CampaignCampaign: Official CampaignOfficial Campaign | FWC26Campaign: Official Campaign | FWC26Stellar WaveIssues in the Stellar wave programThird CampaignCampaign: Third CampaignbugSomething isn't workingsorobanSoroban smart-contract integration (XDR, RPC, transaction building)very hardExtremely hard — deep expertise, careful design, and significant time required

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions