From cff8b51c48d4c370716272047c6f3cc2b9bde670 Mon Sep 17 00:00:00 2001 From: Jeesun Kim Date: Thu, 23 Jul 2026 10:13:58 -0700 Subject: [PATCH] enable token units AND base units --- .../components/InvokeContract.tsx | 26 ++- .../components/InvokeContractForm.tsx | 10 +- .../JsonSchemaRenderer.tsx | 6 + .../TokenAmountInput.tsx | 159 ++++++++++++++++++ .../renderArrayType.tsx | 6 + .../SmartContractJsonSchema/renderOneOf.tsx | 4 + .../renderPrimitivesType.tsx | 99 ++++++++++- .../renderTupleType.tsx | 4 + src/constants/sep41AmountArgs.ts | 29 ++++ src/helpers/tokenAmount.ts | 108 ++++++++++++ src/query/useGetTokenInfoFromRpc.ts | 148 ++++++++++++++++ src/types/types.ts | 12 ++ src/validate/index.ts | 2 + src/validate/methods/getTokenAmountError.ts | 60 +++++++ tests/unit/getTokenAmountError.test.ts | 74 ++++++++ tests/unit/tokenAmount.test.ts | 103 ++++++++++++ 16 files changed, 847 insertions(+), 3 deletions(-) create mode 100644 src/components/SmartContractJsonSchema/TokenAmountInput.tsx create mode 100644 src/constants/sep41AmountArgs.ts create mode 100644 src/helpers/tokenAmount.ts create mode 100644 src/query/useGetTokenInfoFromRpc.ts create mode 100644 src/validate/methods/getTokenAmountError.ts create mode 100644 tests/unit/getTokenAmountError.test.ts create mode 100644 tests/unit/tokenAmount.test.ts diff --git a/src/app/(sidebar)/smart-contracts/contract-explorer/components/InvokeContract.tsx b/src/app/(sidebar)/smart-contracts/contract-explorer/components/InvokeContract.tsx index e866ea7af..7fdf1b509 100644 --- a/src/app/(sidebar)/smart-contracts/contract-explorer/components/InvokeContract.tsx +++ b/src/app/(sidebar)/smart-contracts/contract-explorer/components/InvokeContract.tsx @@ -7,8 +7,16 @@ import { useStore } from "@/store/useStore"; import { Box } from "@/components/layout/Box"; +import { getNetworkHeaders } from "@/helpers/getNetworkHeaders"; +import { useGetTokenInfoFromRpc } from "@/query/useGetTokenInfoFromRpc"; + import { InvokeContractForm, SigningMethod } from "./InvokeContractForm"; +// SEP-41 core functions used to decide whether a contract is token-shaped +// before spending a simulation on `decimals()`. Requiring all three avoids +// firing on non-token contracts that coincidentally expose a `decimals`. +const TOKEN_SHAPE_FUNCS = ["decimals", "transfer", "balance"]; + export const InvokeContract = ({ isLoading, contractId, @@ -20,9 +28,24 @@ export const InvokeContract = ({ contractSpec: contract.Spec; contractClientError: Error | null | undefined; }) => { - const { walletKit } = useStore(); + const { network, walletKit } = useStore(); const [signingMethod, setSigningMethod] = useState("wallet"); + const funcNames = new Set( + contractSpec?.funcs()?.map((func) => func.name().toString()) || [], + ); + const isTokenShaped = TOKEN_SHAPE_FUNCS.every((fn) => funcNames.has(fn)); + + // Resolve decimals/symbol once per contract. When this returns null (not a + // token, or simulation failed), amount fields render as plain integers. + const { data: tokenInfo } = useGetTokenInfoFromRpc({ + contractId, + networkPassphrase: network.passphrase, + rpcUrl: network.rpcUrl, + headers: getNetworkHeaders(network, "rpc"), + enabled: isTokenShaped && Boolean(network.rpcUrl), + }); + const renderFunctionCard = () => { const invokeContractSpecFuncs = contractSpec?.funcs(); @@ -38,6 +61,7 @@ export const InvokeContract = ({ contractId={contractId} funcName={funcName} signingMethod={signingMethod} + tokenInfo={tokenInfo || undefined} /> ); }); diff --git a/src/app/(sidebar)/smart-contracts/contract-explorer/components/InvokeContractForm.tsx b/src/app/(sidebar)/smart-contracts/contract-explorer/components/InvokeContractForm.tsx index e93642bd2..bacc058c3 100644 --- a/src/app/(sidebar)/smart-contracts/contract-explorer/components/InvokeContractForm.tsx +++ b/src/app/(sidebar)/smart-contracts/contract-explorer/components/InvokeContractForm.tsx @@ -50,7 +50,12 @@ import { dereferenceSchema } from "@/helpers/dereferenceSchema"; import { getNetworkHeaders } from "@/helpers/getNetworkHeaders"; import { getTxnToSimulate } from "@/helpers/sorobanUtils"; -import { SorobanInvokeValue, XdrFormatType, AnyObject } from "@/types/types"; +import { + SorobanInvokeValue, + XdrFormatType, + AnyObject, + TokenInfo, +} from "@/types/types"; import { trackEvent, TrackingEvent } from "@/metrics/tracking"; @@ -71,11 +76,13 @@ export const InvokeContractForm = ({ funcName, contractSpec, signingMethod = "wallet", + tokenInfo, }: { contractId: string; funcName: string; contractSpec: contract.Spec; signingMethod?: SigningMethod; + tokenInfo?: TokenInfo; }) => { const { network, walletKit } = useStore(); const { resetAll, setBuildParams, setBuildSorobanOperation } = @@ -626,6 +633,7 @@ export const InvokeContractForm = ({ schema={dereferencedSchema as JSONSchema7} onChange={handleChange} parsedSorobanOperation={formValue} + tokenInfo={tokenInfo} /> )} diff --git a/src/components/SmartContractJsonSchema/JsonSchemaRenderer.tsx b/src/components/SmartContractJsonSchema/JsonSchemaRenderer.tsx index f71d202ab..28543b5d9 100644 --- a/src/components/SmartContractJsonSchema/JsonSchemaRenderer.tsx +++ b/src/components/SmartContractJsonSchema/JsonSchemaRenderer.tsx @@ -21,6 +21,7 @@ export const JsonSchemaRenderer = ({ parsedSorobanOperation, formError, setFormError, + tokenInfo, }: JsonSchemaFormProps) => { const schemaType = jsonSchema.getSchemaType(schema); @@ -64,6 +65,7 @@ export const JsonSchemaRenderer = ({ parsedSorobanOperation={parsedSorobanOperation} formError={formError} setFormError={setFormError} + tokenInfo={tokenInfo} /> @@ -81,6 +83,7 @@ export const JsonSchemaRenderer = ({ parsedSorobanOperation={parsedSorobanOperation} formError={formError} setFormError={setFormError} + tokenInfo={tokenInfo} /> ); }, @@ -98,6 +101,7 @@ export const JsonSchemaRenderer = ({ renderer: JsonSchemaRenderer, formError, setFormError, + tokenInfo, }); } @@ -111,6 +115,7 @@ export const JsonSchemaRenderer = ({ renderer: JsonSchemaRenderer, formError, setFormError, + tokenInfo, }); } @@ -123,5 +128,6 @@ export const JsonSchemaRenderer = ({ onChange, formError, setFormError, + tokenInfo, }); }; diff --git a/src/components/SmartContractJsonSchema/TokenAmountInput.tsx b/src/components/SmartContractJsonSchema/TokenAmountInput.tsx new file mode 100644 index 000000000..0cef9a081 --- /dev/null +++ b/src/components/SmartContractJsonSchema/TokenAmountInput.tsx @@ -0,0 +1,159 @@ +import React, { useState } from "react"; +import { Input, RadioButton } from "@stellar/design-system"; + +import { + baseUnitsToTokenAmount, + tokenAmountToBaseUnits, +} from "@/helpers/tokenAmount"; +import { validate } from "@/validate"; + +import { Box } from "@/components/layout/Box"; + +/** + * Decimals-aware amount input for SEP-41 token amount args (i128/u128). + * + * Two modes, toggled per field: + * - `tokens` — the user types a human-readable amount (e.g. `5.5`); the + * component scales it by the token's decimals and stores the raw base-unit + * integer. The exact value that will be submitted is always shown below. + * - `raw` — identical to a plain integer field, with a live "= 5 JAAA" note. + * + * The canonical stored value (via `onRawChange`) is *always* the raw base-unit + * integer string, so simulation/submission paths are untouched. The + * human-units string is local component state only. + */ +export const TokenAmountInput = ({ + id, + label, + value, + decimals, + symbol, + isSigned, + error, + onRawChange, + onError, +}: { + id: string; + label: React.ReactNode; + /** Canonical raw base-unit integer string held in the form store. */ + value: string; + decimals: number; + symbol?: string; + /** `true` for i128 (negatives allowed), `false` for u128. */ + isSigned: boolean; + error?: string; + onRawChange: (raw: string) => void; + onError: (error: string | false) => void; +}) => { + const tokenLabel = symbol || "tokens"; + + const safeBaseToToken = (raw: string) => { + try { + return raw ? baseUnitsToTokenAmount(raw, decimals) : ""; + } catch { + return ""; + } + }; + + const [mode, setMode] = useState<"tokens" | "raw">("tokens"); + const [tokensDisplay, setTokensDisplay] = useState(() => + safeBaseToToken(value), + ); + + const handleTokensChange = (next: string) => { + setTokensDisplay(next); + + const err = validate.getTokenAmountError({ + value: next, + decimals, + isSigned, + }); + onError(err); + + if (!err && next) { + onRawChange(tokenAmountToBaseUnits(next, decimals)); + } else { + // Clear the canonical value so simulate/submit stays disabled until the + // token-units entry is valid again. + onRawChange(""); + } + }; + + const handleRawChange = (next: string) => { + const err = isSigned + ? validate.getI128Error(next) + : validate.getU128Error(next); + onError(err); + onRawChange(next); + }; + + const switchMode = (nextMode: "tokens" | "raw") => { + if (nextMode === mode) { + return; + } + + if (nextMode === "tokens") { + setTokensDisplay(safeBaseToToken(value)); + } + + // Clear stale errors from the previous mode; the counterpart value is a + // valid representation of the same stored raw integer. + onError(false); + setMode(nextMode); + }; + + const tokensNote = value + ? `Will submit: ${value} (${decimals} ${ + decimals === 1 ? "decimal" : "decimals" + })` + : `Enter the amount in ${tokenLabel}; it’s scaled by ${decimals} ${ + decimals === 1 ? "decimal" : "decimals" + }.`; + + const rawTokenEquivalent = safeBaseToToken(value); + const rawNote = rawTokenEquivalent + ? `= ${rawTokenEquivalent} ${tokenLabel} (${decimals} ${ + decimals === 1 ? "decimal" : "decimals" + })` + : undefined; + + return ( + + + switchMode("tokens")} + /> + switchMode("raw")} + /> + + + + mode === "tokens" + ? handleTokensChange(e.target.value) + : handleRawChange(e.target.value) + } + rightElement={mode === "tokens" ? tokenLabel : undefined} + note={mode === "tokens" ? tokensNote : rawNote} + /> + + ); +}; diff --git a/src/components/SmartContractJsonSchema/renderArrayType.tsx b/src/components/SmartContractJsonSchema/renderArrayType.tsx index 11540e658..bdc2d6038 100644 --- a/src/components/SmartContractJsonSchema/renderArrayType.tsx +++ b/src/components/SmartContractJsonSchema/renderArrayType.tsx @@ -12,6 +12,7 @@ import type { AnyObject, JsonSchemaFormProps, SorobanInvokeValue, + TokenInfo, } from "@/types/types"; export const renderArrayType = ({ @@ -22,6 +23,7 @@ export const renderArrayType = ({ onChange, formError, setFormError, + tokenInfo, }: { schema: JSONSchema7; path: string[]; @@ -30,6 +32,7 @@ export const renderArrayType = ({ onChange: (value: SorobanInvokeValue) => void; formError: AnyObject; setFormError: (error: AnyObject) => void; + tokenInfo?: TokenInfo; }) => { const name = path.join("."); const invokeContractBaseProps = { @@ -58,6 +61,7 @@ export const renderArrayType = ({ onChange, formError, setFormError, + tokenInfo, }); }); } @@ -102,6 +106,7 @@ export const renderArrayType = ({ onChange, formError, setFormError, + tokenInfo, }); })} @@ -116,6 +121,7 @@ export const renderArrayType = ({ onChange, formError, setFormError, + tokenInfo, })} )} diff --git a/src/components/SmartContractJsonSchema/renderOneOf.tsx b/src/components/SmartContractJsonSchema/renderOneOf.tsx index f174628be..adb485013 100644 --- a/src/components/SmartContractJsonSchema/renderOneOf.tsx +++ b/src/components/SmartContractJsonSchema/renderOneOf.tsx @@ -10,6 +10,7 @@ import { AnyObject, JsonSchemaFormProps, SorobanInvokeValue, + TokenInfo, } from "@/types/types"; import { renderTupleType } from "./renderTupleType"; @@ -24,6 +25,7 @@ export const renderOneOf = ({ onChange, formError, setFormError, + tokenInfo, }: { name: string; schema: JSONSchema7; @@ -33,6 +35,7 @@ export const renderOneOf = ({ onChange: (value: SorobanInvokeValue) => void; formError: AnyObject; setFormError: (error: AnyObject) => void; + tokenInfo?: TokenInfo; }) => { if (!schema?.oneOf) { return null; @@ -162,6 +165,7 @@ export const renderOneOf = ({ renderer, formError, setFormError, + tokenInfo, }) : null} diff --git a/src/components/SmartContractJsonSchema/renderPrimitivesType.tsx b/src/components/SmartContractJsonSchema/renderPrimitivesType.tsx index ad85417f6..b127f87cf 100644 --- a/src/components/SmartContractJsonSchema/renderPrimitivesType.tsx +++ b/src/components/SmartContractJsonSchema/renderPrimitivesType.tsx @@ -5,14 +5,18 @@ import { get } from "lodash"; import { jsonSchema } from "@/helpers/jsonSchema"; import { convertSpecTypeToScValType } from "@/helpers/sorobanUtils"; +import { baseUnitsToTokenAmount } from "@/helpers/tokenAmount"; import { validate } from "@/validate"; import { PositiveIntPicker } from "@/components/FormElements/PositiveIntPicker"; import { ColorTypePill } from "@/components/ColorTypePill"; import { SignerSelector } from "@/components/SignerSelector"; +import { TokenAmountInput } from "@/components/SmartContractJsonSchema/TokenAmountInput"; -import type { AnyObject, SorobanInvokeValue } from "@/types/types"; +import { isSep41AmountArg } from "@/constants/sep41AmountArgs"; + +import type { AnyObject, SorobanInvokeValue, TokenInfo } from "@/types/types"; /** * Address Input with SignerSelector for selecting from saved keypairs or @@ -59,6 +63,7 @@ export const renderPrimitivesType = ({ onChange, formError, setFormError, + tokenInfo, }: { name: string; schema: Partial; @@ -67,6 +72,7 @@ export const renderPrimitivesType = ({ onChange: (value: SorobanInvokeValue) => void; formError: AnyObject; setFormError: (error: AnyObject) => void; + tokenInfo?: TokenInfo; }) => { const { description } = schema; @@ -183,6 +189,61 @@ export const renderPrimitivesType = ({ ); + // Set or clear a single field's error (mirrors handleValidate's behavior). + const setFieldError = (error: string | false) => { + if (error) { + setFormError({ ...formError, [formErrorKey]: error }); + } else { + setFormError((prev: AnyObject) => { + const newFormError = { ...prev }; + delete newFormError[formErrorKey]; + return newFormError; + }); + } + }; + + // Write a raw value directly (used by decimals-aware token entry, which + // computes the scaled base-unit integer itself). + const setRawValue = (raw: string, schemaType: string) => { + handleChange( + { target: { value: raw } } as React.ChangeEvent, + schemaType, + ); + }; + + // Phase 2 passive note: for i128/u128 fields on a token contract, show the + // human-readable token equivalent of whatever raw value is entered. + const tokenUnitsNote = (rawValue: string) => { + if (!tokenInfo || tokenInfo.decimals === 0) { + return undefined; + } + + if (!rawValue || !/^-?\d+$/.test(rawValue)) { + return undefined; + } + + try { + const label = tokenInfo.symbol || "tokens"; + const amount = baseUnitsToTokenAmount(rawValue, tokenInfo.decimals); + + return `= ${amount} ${label} (${tokenInfo.decimals} ${ + tokenInfo.decimals === 1 ? "decimal" : "decimals" + })`; + } catch { + return undefined; + } + }; + + // Phase 3: allowlisted top-level SEP-41 amount args get the token-units + // entry mode. A direct function argument reaches here with a single-segment + // path (e.g. ["amount"]); nested amounts (structs/vecs, path.length > 1) get + // the passive note only. + const isTokenAmountArg = + Boolean(tokenInfo) && + (tokenInfo?.decimals ?? 0) > 0 && + path.length <= 1 && + isSep41AmountArg(parsedSorobanOperation.function_name, name); + switch (schemaType) { case "Address": return ( @@ -259,11 +320,29 @@ export const renderPrimitivesType = ({ /> ); case "U128": + if (isTokenAmountArg && tokenInfo) { + return ( + setRawValue(raw, schemaType)} + onError={setFieldError} + /> + ); + } + return ( { handleChange(e, schemaType); handleValidate(e, schemaType, validate.getU128Error); @@ -309,11 +388,29 @@ export const renderPrimitivesType = ({ /> ); case "I128": + if (isTokenAmountArg && tokenInfo) { + return ( + setRawValue(raw, schemaType)} + onError={setFieldError} + /> + ); + } + return ( { handleChange(e, schemaType); handleValidate(e, schemaType, validate.getI128Error); diff --git a/src/components/SmartContractJsonSchema/renderTupleType.tsx b/src/components/SmartContractJsonSchema/renderTupleType.tsx index 12f6e6858..e56608a54 100644 --- a/src/components/SmartContractJsonSchema/renderTupleType.tsx +++ b/src/components/SmartContractJsonSchema/renderTupleType.tsx @@ -9,6 +9,7 @@ import { AnyObject, JsonSchemaFormProps, SorobanInvokeValue, + TokenInfo, } from "@/types/types"; export const renderTupleType = ({ @@ -19,6 +20,7 @@ export const renderTupleType = ({ renderer, formError, setFormError, + tokenInfo, }: { path: string[]; schema: JSONSchema7; @@ -27,6 +29,7 @@ export const renderTupleType = ({ renderer: (props: JsonSchemaFormProps) => React.ReactNode; formError: AnyObject; setFormError: (error: AnyObject) => void; + tokenInfo?: TokenInfo; }) => { const getKeyName = get(parsedSorobanOperation.args, path.join(".")); @@ -58,6 +61,7 @@ export const renderTupleType = ({ onChange, formError, setFormError, + tokenInfo, })} diff --git a/src/constants/sep41AmountArgs.ts b/src/constants/sep41AmountArgs.ts new file mode 100644 index 000000000..2c688af35 --- /dev/null +++ b/src/constants/sep41AmountArgs.ts @@ -0,0 +1,29 @@ +/** + * Map of SEP-41 token function name → argument names that represent a token + * amount (i128/u128 in base units). These are the only args that get the + * "token units" entry mode; every other i128/u128 field gets the passive + * conversion note only. + * + * Deliberately excludes non-amount integer args such as `approve`'s + * `expiration_ledger` (a u32). + * + * @see https://github.com/stellar/stellar-protocol/blob/master/ecosystem/sep-0041.md + */ +export const SEP41_AMOUNT_ARGS: Record = { + transfer: ["amount"], + transfer_from: ["amount"], + approve: ["amount"], + mint: ["amount"], + burn: ["amount"], + burn_from: ["amount"], + clawback: ["amount"], +}; + +/** + * Whether the given `functionName` + `argName` pair is a SEP-41 amount argument + * eligible for token-units entry mode. + */ +export const isSep41AmountArg = ( + functionName: string, + argName: string, +): boolean => Boolean(SEP41_AMOUNT_ARGS[functionName]?.includes(argName)); diff --git a/src/helpers/tokenAmount.ts b/src/helpers/tokenAmount.ts new file mode 100644 index 000000000..bf32e30c6 --- /dev/null +++ b/src/helpers/tokenAmount.ts @@ -0,0 +1,108 @@ +/** + * Pure BigInt string math for converting between human-readable token amounts + * and raw base-unit integers, given a token's `decimals`. + * + * No floats are used anywhere — `parseFloat`/`Number` would lose precision on + * large values (e.g. an 18-decimal token), so everything is string/BigInt math. + * + * Example: a token with 7 decimals represents 5 tokens as the base-unit integer + * `50000000` (5 × 10^7). SolvBTC (8 decimals) → `500000000`; an 18-decimal + * token → `5000000000000000000`. + */ + +const DECIMAL_STRING_PATTERN = /^-?(\d+)(\.\d+)?$/; + +/** + * Convert a human-readable decimal token amount into its raw base-unit integer + * string. + * + * @param amount - Decimal string as typed by the user, e.g. `"5.5"`. May be + * negative (for i128 amounts). Must not use exponent/hex notation. + * @param decimals - Number of decimals the token uses (0–38). + * @returns The raw base-unit integer as a string, e.g. `tokenAmountToBaseUnits("5.5", 7)` → `"55000000"`. + * @throws If `amount` is not a plain decimal string, if it has more fraction + * digits than `decimals`, or if `decimals` is out of range. + */ +export const tokenAmountToBaseUnits = ( + amount: string, + decimals: number, +): string => { + if (!Number.isInteger(decimals) || decimals < 0 || decimals > 38) { + throw new Error(`Invalid decimals: ${decimals}`); + } + + const trimmed = amount.trim(); + + if (!DECIMAL_STRING_PATTERN.test(trimmed)) { + throw new Error(`Invalid token amount: ${amount}`); + } + + const isNegative = trimmed.startsWith("-"); + const unsigned = isNegative ? trimmed.slice(1) : trimmed; + + const [intPart, fracPart = ""] = unsigned.split("."); + + if (fracPart.length > decimals) { + throw new Error( + `This token supports at most ${decimals} decimal ${ + decimals === 1 ? "place" : "places" + }.`, + ); + } + + // Right-pad the fraction to exactly `decimals` digits, then concatenate. + const paddedFraction = fracPart.padEnd(decimals, "0"); + const combined = `${intPart}${paddedFraction}`; + + // Strip leading zeros without using Number (keeps precision). BigInt + // normalizes "007" → "7" and "000" → "0". + const normalized = BigInt(combined).toString(); + + // BigInt() already dropped the sign via the unsigned combined string, so + // "-0" collapses to "0". + return isNegative && normalized !== "0" ? `-${normalized}` : normalized; +}; + +/** + * Convert a raw base-unit integer string back into a human-readable decimal + * token amount, trimming trailing zeros. + * + * @param raw - Raw base-unit integer string, e.g. `"55000000"`. May be negative. + * @param decimals - Number of decimals the token uses (0–38). + * @returns The human-readable amount, e.g. `baseUnitsToTokenAmount("55000000", 7)` → `"5.5"`. + * @throws If `raw` is not a plain integer string or `decimals` is out of range. + */ +export const baseUnitsToTokenAmount = ( + raw: string, + decimals: number, +): string => { + if (!Number.isInteger(decimals) || decimals < 0 || decimals > 38) { + throw new Error(`Invalid decimals: ${decimals}`); + } + + const trimmed = raw.trim(); + + if (!/^-?\d+$/.test(trimmed)) { + throw new Error(`Invalid base-unit value: ${raw}`); + } + + const isNegative = trimmed.startsWith("-"); + const unsigned = (isNegative ? trimmed.slice(1) : trimmed).replace( + /^0+(?=\d)/, + "", + ); + + if (decimals === 0) { + const normalized = unsigned === "0" ? "0" : unsigned; + return isNegative && normalized !== "0" ? `-${normalized}` : normalized; + } + + // Left-pad so there is at least one integer digit before the fraction. + const padded = unsigned.padStart(decimals + 1, "0"); + const intPart = padded.slice(0, padded.length - decimals); + const fracPart = padded.slice(padded.length - decimals).replace(/0+$/, ""); + + const result = fracPart ? `${intPart}.${fracPart}` : intPart; + + return isNegative && BigInt(unsigned) !== BigInt(0) ? `-${result}` : result; +}; diff --git a/src/query/useGetTokenInfoFromRpc.ts b/src/query/useGetTokenInfoFromRpc.ts new file mode 100644 index 000000000..b0ce20c9c --- /dev/null +++ b/src/query/useGetTokenInfoFromRpc.ts @@ -0,0 +1,148 @@ +import { useQuery } from "@tanstack/react-query"; +import { + Account, + BASE_FEE, + Contract, + TransactionBuilder, + scValToNative, + xdr, +} from "@stellar/stellar-sdk"; + +import { NetworkHeaders, TokenInfo } from "@/types/types"; + +// Placeholder public key used to build a throwaway transaction for a read-only +// simulation. It doesn't need to exist or be funded — simulation never touches +// the source account for a view call. +const SIMULATION_PLACEHOLDER_SOURCE = + "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF"; + +/** + * Simulate a no-argument, read-only contract call and return its decoded native + * result. Returns `null` on any failure (network error, malformed response, + * contract without the function) so callers can treat missing metadata as + * "feature off". + */ +const simulateNoArgCall = async ({ + contractId, + functionName, + networkPassphrase, + rpcUrl, + headers, +}: { + contractId: string; + functionName: string; + networkPassphrase: string; + rpcUrl: string; + headers: NetworkHeaders; +}): Promise => { + try { + const account = new Account(SIMULATION_PLACEHOLDER_SOURCE, "0"); + const tx = new TransactionBuilder(account, { + fee: BASE_FEE, + networkPassphrase, + }) + .addOperation(new Contract(contractId).call(functionName)) + .setTimeout(30) + .build(); + + const res = await fetch(rpcUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + ...headers, + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "simulateTransaction", + params: { + xdrFormat: "base64", + transaction: tx.toXDR(), + }, + }), + }); + + const json = await res.json(); + const resultXdr: string | undefined = json?.result?.results?.[0]?.xdr; + + if (json?.result?.error || !resultXdr) { + return null; + } + + return scValToNative(xdr.ScVal.fromXDR(resultXdr, "base64")); + } catch { + return null; + } +}; + +/** + * Fetch a token contract's `decimals` (and best-effort `symbol`) via read-only + * simulation. Works for both wasm tokens and Stellar Asset Contracts (SACs) — + * simulation doesn't require the wasm, so a SAC still resolves `decimals = 7`. + * + * Returns `null` when `decimals` can't be resolved or is out of the sane + * 0–38 range (the value is self-reported by the contract and can lie), which + * keeps the decimals-aware UI purely additive: everything falls back to plain + * integer inputs. + */ +export const useGetTokenInfoFromRpc = ({ + contractId, + networkPassphrase, + rpcUrl, + headers = {}, + enabled = false, +}: { + contractId: string; + networkPassphrase: string; + rpcUrl: string; + headers?: NetworkHeaders; + enabled?: boolean; +}) => { + return useQuery({ + queryKey: ["tokenInfo", contractId, rpcUrl, networkPassphrase, headers], + queryFn: async () => { + if (!contractId || !rpcUrl) { + return null; + } + + const [decimalsRaw, symbolRaw] = await Promise.all([ + simulateNoArgCall({ + contractId, + functionName: "decimals", + networkPassphrase, + rpcUrl, + headers, + }), + simulateNoArgCall({ + contractId, + functionName: "symbol", + networkPassphrase, + rpcUrl, + headers, + }), + ]); + + const decimals = Number(decimalsRaw); + + // Guardrail: reject non-integer / out-of-range self-reported decimals. + if ( + decimalsRaw === null || + !Number.isInteger(decimals) || + decimals < 0 || + decimals > 38 + ) { + return null; + } + + const symbol = + typeof symbolRaw === "string" && symbolRaw.length > 0 + ? symbolRaw + : undefined; + + return { decimals, symbol }; + }, + enabled: enabled && Boolean(contractId) && Boolean(rpcUrl), + // Decimals are immutable in practice; a page reload refetches. + staleTime: Infinity, + }); +}; diff --git a/src/types/types.ts b/src/types/types.ts index 6eb3241e5..edba256f9 100644 --- a/src/types/types.ts +++ b/src/types/types.ts @@ -289,6 +289,17 @@ export type ScValPrimitiveType = | "DataUrl" | "Bool"; +/** + * Token metadata resolved via read-only simulation of a SEP-41 token's + * `decimals()` / `symbol()`. Present only when the invoked contract is + * token-shaped and the simulation succeeded; otherwise amount inputs behave + * exactly as plain integers. + */ +export type TokenInfo = { + decimals: number; + symbol?: string; +}; + export type JsonSchemaFormProps = { name: string; schema: JSONSchema7; @@ -299,6 +310,7 @@ export type JsonSchemaFormProps = { parsedSorobanOperation: SorobanInvokeValue; formError: AnyObject; setFormError: (error: AnyObject) => void; + tokenInfo?: TokenInfo; }; // ============================================================================= // RPC diff --git a/src/validate/index.ts b/src/validate/index.ts index 679005e2b..733c2ce30 100644 --- a/src/validate/index.ts +++ b/src/validate/index.ts @@ -22,6 +22,7 @@ import { getPublicKeyError } from "./methods/getPublicKeyError"; import { getRevokeSponsorshipError } from "./methods/getRevokeSponsorshipError"; import { getSecretKeyError } from "./methods/getSecretKeyError"; import { getTimeBoundsError } from "./methods/getTimeBoundsError"; +import { getTokenAmountError } from "./methods/getTokenAmountError"; import { getTransactionHashError } from "./methods/getTransactionHashError"; import { getXdrError } from "./methods/getXdrError"; import { getU32Error } from "./methods/getU32Error"; @@ -61,6 +62,7 @@ export const validate = { getRevokeSponsorshipError, getSecretKeyError, getTimeBoundsError, + getTokenAmountError, getTransactionHashError, getXdrError, getU32Error, diff --git a/src/validate/methods/getTokenAmountError.ts b/src/validate/methods/getTokenAmountError.ts new file mode 100644 index 000000000..b568b27b1 --- /dev/null +++ b/src/validate/methods/getTokenAmountError.ts @@ -0,0 +1,60 @@ +import { validate } from "@/validate"; +import { tokenAmountToBaseUnits } from "@/helpers/tokenAmount"; + +/** + * Validate a human-readable token amount (as typed in "token units" mode of a + * decimals-aware i128/u128 field) and, when valid, confirm the scaled base-unit + * integer still satisfies the underlying i128/u128 range checks. + * + * Follows the `get*Error()` convention: returns an error message string, or + * `false` when the value is valid. + * + * @param value - The decimal string the user typed, e.g. `"5.5"`. + * @param decimals - The token's decimals (0–38). + * @param isSigned - `true` for i128 amounts (negatives allowed), `false` for u128. + * @param isRequired - Whether an empty value is an error. + */ +export const getTokenAmountError = ({ + value, + decimals, + isSigned, + isRequired, +}: { + value: string; + decimals: number; + isSigned: boolean; + isRequired?: boolean; +}): string | false => { + if (!value) { + return isRequired ? "This field is required." : false; + } + + const trimmed = value.trim(); + const pattern = isSigned ? /^-?(\d+)(\.\d+)?$/ : /^(\d+)(\.\d+)?$/; + + if (!pattern.test(trimmed)) { + return isSigned + ? "Enter a valid decimal amount (e.g. 5.5)." + : "Enter a valid positive decimal amount (e.g. 5.5)."; + } + + const unsigned = trimmed.startsWith("-") ? trimmed.slice(1) : trimmed; + const fraction = unsigned.split(".")[1] ?? ""; + + if (fraction.length > decimals) { + return `This token supports at most ${decimals} decimal ${ + decimals === 1 ? "place" : "places" + }.`; + } + + let raw: string; + + try { + raw = tokenAmountToBaseUnits(trimmed, decimals); + } catch { + return "Enter a valid decimal amount (e.g. 5.5)."; + } + + // Reuse the existing range/length checks on the scaled base-unit integer. + return isSigned ? validate.getI128Error(raw) : validate.getU128Error(raw); +}; diff --git a/tests/unit/getTokenAmountError.test.ts b/tests/unit/getTokenAmountError.test.ts new file mode 100644 index 000000000..168a4477c --- /dev/null +++ b/tests/unit/getTokenAmountError.test.ts @@ -0,0 +1,74 @@ +// Jest globals (describe, expect, it) are available globally +import { getTokenAmountError } from "../../src/validate/methods/getTokenAmountError"; + +describe("getTokenAmountError", () => { + it("accepts valid amounts", () => { + expect( + getTokenAmountError({ value: "5", decimals: 7, isSigned: false }), + ).toBe(false); + expect( + getTokenAmountError({ value: "5.5", decimals: 7, isSigned: false }), + ).toBe(false); + expect( + getTokenAmountError({ value: "0.0000001", decimals: 7, isSigned: false }), + ).toBe(false); + expect( + getTokenAmountError({ value: "-5.5", decimals: 7, isSigned: true }), + ).toBe(false); + }); + + it("treats empty as required/optional", () => { + expect( + getTokenAmountError({ value: "", decimals: 7, isSigned: false }), + ).toBe(false); + expect( + getTokenAmountError({ + value: "", + decimals: 7, + isSigned: false, + isRequired: true, + }), + ).toBe("This field is required."); + }); + + it("rejects negatives for unsigned (u128)", () => { + expect( + getTokenAmountError({ value: "-5", decimals: 7, isSigned: false }), + ).toBeTruthy(); + }); + + it("rejects too many fraction digits", () => { + expect( + getTokenAmountError({ value: "0.00000001", decimals: 7, isSigned: false }), + ).toBe("This token supports at most 7 decimal places."); + expect( + getTokenAmountError({ value: "5.5", decimals: 0, isSigned: false }), + ).toBe("This token supports at most 0 decimal places."); + }); + + it("uses singular copy for 1 decimal", () => { + expect( + getTokenAmountError({ value: "5.55", decimals: 1, isSigned: false }), + ).toBe("This token supports at most 1 decimal place."); + }); + + it("rejects malformed input", () => { + expect( + getTokenAmountError({ value: "1e5", decimals: 7, isSigned: false }), + ).toBeTruthy(); + expect( + getTokenAmountError({ value: "abc", decimals: 7, isSigned: false }), + ).toBeTruthy(); + expect( + getTokenAmountError({ value: "5.", decimals: 7, isSigned: false }), + ).toBeTruthy(); + }); + + it("enforces the underlying u128 range after scaling", () => { + // 10^39 tokens at 0 decimals exceeds u128 max (~3.4 × 10^38). + const tooBig = "1" + "0".repeat(39); + expect( + getTokenAmountError({ value: tooBig, decimals: 0, isSigned: false }), + ).toBeTruthy(); + }); +}); diff --git a/tests/unit/tokenAmount.test.ts b/tests/unit/tokenAmount.test.ts new file mode 100644 index 000000000..d11a3bf83 --- /dev/null +++ b/tests/unit/tokenAmount.test.ts @@ -0,0 +1,103 @@ +// Jest globals (describe, expect, it) are available globally +import { + tokenAmountToBaseUnits, + baseUnitsToTokenAmount, +} from "../../src/helpers/tokenAmount"; + +describe("tokenAmountToBaseUnits", () => { + it("scales a whole number", () => { + expect(tokenAmountToBaseUnits("5", 7)).toBe("50000000"); + expect(tokenAmountToBaseUnits("5", 8)).toBe("500000000"); // SolvBTC + expect(tokenAmountToBaseUnits("5", 18)).toBe("5000000000000000000"); // deJAAA + }); + + it("scales a fractional number", () => { + expect(tokenAmountToBaseUnits("5.5", 7)).toBe("55000000"); + expect(tokenAmountToBaseUnits("0.5", 7)).toBe("5000000"); + expect(tokenAmountToBaseUnits("0.0000001", 7)).toBe("1"); + }); + + it("handles zero and zero-ish values", () => { + expect(tokenAmountToBaseUnits("0", 7)).toBe("0"); + expect(tokenAmountToBaseUnits("0.0", 7)).toBe("0"); + expect(tokenAmountToBaseUnits("-0", 7)).toBe("0"); + expect(tokenAmountToBaseUnits("0", 0)).toBe("0"); + }); + + it("preserves negatives (i128 amounts)", () => { + expect(tokenAmountToBaseUnits("-5.5", 7)).toBe("-55000000"); + expect(tokenAmountToBaseUnits("-0.0000001", 7)).toBe("-1"); + }); + + it("handles 0 and 38 decimals", () => { + expect(tokenAmountToBaseUnits("5", 0)).toBe("5"); + expect(tokenAmountToBaseUnits("1", 38)).toBe(`1${"0".repeat(38)}`); + }); + + it("throws when fraction digits exceed decimals", () => { + expect(() => tokenAmountToBaseUnits("0.00000001", 7)).toThrow(); + expect(() => tokenAmountToBaseUnits("5.5", 0)).toThrow(); + }); + + it("rejects malformed input (no exponent/hex/empty)", () => { + expect(() => tokenAmountToBaseUnits("1e5", 7)).toThrow(); + expect(() => tokenAmountToBaseUnits("0x10", 7)).toThrow(); + expect(() => tokenAmountToBaseUnits("", 7)).toThrow(); + expect(() => tokenAmountToBaseUnits(".", 7)).toThrow(); + expect(() => tokenAmountToBaseUnits("5.", 7)).toThrow(); + expect(() => tokenAmountToBaseUnits("abc", 7)).toThrow(); + }); + + it("rejects out-of-range decimals", () => { + expect(() => tokenAmountToBaseUnits("5", -1)).toThrow(); + expect(() => tokenAmountToBaseUnits("5", 39)).toThrow(); + }); +}); + +describe("baseUnitsToTokenAmount", () => { + it("converts whole token values", () => { + expect(baseUnitsToTokenAmount("50000000", 7)).toBe("5"); + expect(baseUnitsToTokenAmount("500000000", 8)).toBe("5"); + expect(baseUnitsToTokenAmount("5000000000000000000", 18)).toBe("5"); + }); + + it("converts fractional values and trims trailing zeros", () => { + expect(baseUnitsToTokenAmount("55000000", 7)).toBe("5.5"); + expect(baseUnitsToTokenAmount("5000000", 7)).toBe("0.5"); + expect(baseUnitsToTokenAmount("1", 7)).toBe("0.0000001"); + }); + + it("handles zero", () => { + expect(baseUnitsToTokenAmount("0", 7)).toBe("0"); + expect(baseUnitsToTokenAmount("0", 0)).toBe("0"); + }); + + it("handles negatives", () => { + expect(baseUnitsToTokenAmount("-55000000", 7)).toBe("-5.5"); + expect(baseUnitsToTokenAmount("-1", 7)).toBe("-0.0000001"); + }); + + it("handles 0 decimals as identity", () => { + expect(baseUnitsToTokenAmount("500", 0)).toBe("500"); + }); + + it("round-trips with tokenAmountToBaseUnits", () => { + const cases: Array<[string, number]> = [ + ["5.5", 7], + ["0.0000001", 7], + ["12345.6789", 18], + ["-9.99", 8], + ["1000000", 6], + ]; + for (const [amount, decimals] of cases) { + const raw = tokenAmountToBaseUnits(amount, decimals); + expect(baseUnitsToTokenAmount(raw, decimals)).toBe(amount); + } + }); + + it("rejects malformed base-unit input", () => { + expect(() => baseUnitsToTokenAmount("5.5", 7)).toThrow(); + expect(() => baseUnitsToTokenAmount("abc", 7)).toThrow(); + expect(() => baseUnitsToTokenAmount("", 7)).toThrow(); + }); +});