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
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
- Extend
AssetInfo with decimals: number (default 7 only for isNative: true, required/explicit otherwise).
- 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.
- Change
bigintToDisplayAmount(raw: unknown, decimals: number = 7) to accept an explicit decimals parameter, and update all call sites to pass pool.asset.decimals.
- 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
Overview
src/lib/soroban-parsers.ts'sAssetInfotype carries no precision information at all:Every single place this codebase converts between on-chain raw units and display units hardcodes
7decimals (Stellar classic/native precision), with no reference to the actual asset being displayed:and identically in
soroban.ts'sgetPoolHistory/getPoolDepositors(amountDisplay = amount / 10_000_000),amountToStroops's default parameter (decimals = 7,soroban.ts:180), and the standaloneunlockAssetswrapper'sMath.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 — butPoolInfo/AssetInfo(soroban-parsers.ts:9-25) explicitly model a per-pool, potentially non-native asset (asset_code/asset_issuerfields, anisNativeflag), 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 anydecimalsvalue via their owndecimals()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 viaamountToStroops(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
decimals: numberfield toAssetInfo(and thread it throughPoolInfo), sourced from the pool contract's actual returned asset metadata if available, or from an explicit on-chain lookup of the asset token contract'sdecimals()if the pool contract doesn't already surface it.10_000_000/decimals = 7inbigintToDisplayAmount,getPoolHistory,getPoolDepositors,amountToStroops's default, and theunlockAssetswrapper's float conversion with the actual per-asset decimals value, passed explicitly rather than defaulted.Acceptance Criteria
AssetInfo/PoolInfocarry adecimalsfield, populated from real contract data (not defaulted to 7 for non-native assets).bigintToDisplayAmount(or its call sites) accepts the relevant asset'sdecimalsand uses it instead of a hardcoded10_000_000.amountToStroops's caller-side usage inlockAssets/unlockAssetspasses the pool's actual asset decimals, not the implicit default of 7.decimals: 18demonstrates 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.Additional Notes
More precise references
src/lib/soroban-parsers.ts:9-13(AssetInfo) and:15-25(PoolInfo) — confirmed nodecimalsfield exists in either type.src/lib/soroban-parsers.ts:51-59(bigintToDisplayAmount) — confirmed the hardcoded10_000_000ndivisor.src/lib/soroban.ts:180-205(amountToStroops) — confirmeddecimals = 7is the function's default parameter, and confirmed viagrep -n "amountToStroops(" src/lib/soroban.tsthat 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 literal10_000_000with no asset-awareness.src/lib/soroban.ts:1692(unlockAssetswrapper) — confirmedMath.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 parsesasset_code/asset_issuer/is_nativefrom contract data (i.e. the plumbing for "this pool's asset isn't necessarily native XLM" already exists structurally), making the absence of adecimalsfield alongside those fields a clear, specific gap rather than an unconsidered feature.Additional edge cases
isNative/asset_code/asset_issuerper 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 anAssetInfoparameter already ((amount: string, asset: AssetInfo)) but only uses it forasset.codein the display string — it's an existing call site that could immediately consume a newdecimalsfield once added, with minimal additional plumbing.Implementation sketch
AssetInfowithdecimals: number(default7only forisNative: true, required/explicit otherwise).parsePoolEntry(soroban-parsers.ts:73-112) to read adecimals/asset_decimalsfield from the contract's pool struct if present; if the pool contract doesn't expose it directly, add aSorobanServicemethod that queries the underlying asset token contract'sdecimals()view function once per unique asset and caches it.bigintToDisplayAmount(raw: unknown, decimals: number = 7)to accept an explicit decimals parameter, and update all call sites to passpool.asset.decimals.amountToStroopscall sites inbuildLockAssetsTransaction/unlockAssetsto 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)→ assert100n * 10n**18n, not100n * 10n**7n.PoolInfo.asset.decimals === 18; driveDepositModal/useLockFlowthrough a deposit of"100"; assert the constructed contract-call argument reflects 18-decimal raw units.Cross-references