From 743738ece8aa44a1965b6e01b86441f3355daf38 Mon Sep 17 00:00:00 2001 From: leofoxcode-oss Date: Mon, 19 Oct 2026 03:32:02 +0000 Subject: [PATCH 1/4] fix: #1423 remove fake zero signature before wallet signing buildSorobanInvocation attached an all-zero placeholder signature to the unsigned transaction. If the wallet appended its signature instead of replacing it, submission failed, and the XDR looked signed when it was not. Return the transaction unsigned and let the wallet sign it. Closes #1423 --- .../__tests__/lib/stellar-invocation.test.ts | 47 +++++++++++++++++++ frontend/lib/stellar.ts | 3 +- 2 files changed, 49 insertions(+), 1 deletion(-) create mode 100644 frontend/__tests__/lib/stellar-invocation.test.ts diff --git a/frontend/__tests__/lib/stellar-invocation.test.ts b/frontend/__tests__/lib/stellar-invocation.test.ts new file mode 100644 index 000000000..a946e3f33 --- /dev/null +++ b/frontend/__tests__/lib/stellar-invocation.test.ts @@ -0,0 +1,47 @@ +import { buildSorobanInvocation } from "../../lib/stellar"; + +const mockAddSignature = jest.fn(); +const mockToXDR = jest.fn(() => "unsigned-xdr"); +const mockBuild = jest.fn(() => ({ addSignature: mockAddSignature, toXDR: mockToXDR })); + +jest.mock("@stellar/stellar-sdk", () => { + const txBuilder = () => ({ + addOperation: jest.fn(), + setTimeout: jest.fn(), + build: () => mockBuild(), + }); + + return { + Operation: { invokeHostFunction: jest.fn(() => ({})) }, + Timeout: { INFINITE: 0 }, + nativeToScVal: jest.fn(), + addressToScVal: jest.fn(), + TransactionBuilder: jest.fn(txBuilder), + SorobanRpc: { + Server: jest.fn(() => ({ + getAccount: jest.fn(async () => ({})), + simulateTransaction: jest.fn(async () => ({ minResourceFee: "100" })), + })), + Api: { isSimulationSuccess: jest.fn(() => true) }, + assembleTransaction: jest.fn(() => ({ operations: [{}] })), + }, + }; +}); + +describe("buildSorobanInvocation", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("returns the built transaction without adding a placeholder signature", async () => { + const xdr = await buildSorobanInvocation({ + contractId: "CONTRACT", + method: "place_bet", + args: [], + signerAddress: "GSIGNER", + }); + + expect(xdr).toBe("unsigned-xdr"); + expect(mockAddSignature).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/lib/stellar.ts b/frontend/lib/stellar.ts index ec0d8f2f4..461a37790 100644 --- a/frontend/lib/stellar.ts +++ b/frontend/lib/stellar.ts @@ -209,8 +209,9 @@ export async function buildSorobanInvocation( withFee.addOperation(preparedTx.operations[0]); withFee.setTimeout(Timeout.INFINITE); + // Returned unsigned on purpose: the wallet adds the real signature, so no + // placeholder signature is attached here. const finalTx = withFee.build(); - finalTx.addSignature(params.signerAddress, Buffer.alloc(64).fill(0)); return finalTx.toXDR(); } From baaacabab7f13c2c7e77afbaee6b439b68e6a55b Mon Sep 17 00:00:00 2001 From: leofoxcode-oss Date: Mon, 19 Oct 2026 03:33:18 +0000 Subject: [PATCH 2/4] feat: #1424 add typed ScVal argument helpers Arguments were guessed at runtime: any string was tried as an address and otherwise coerced with Number(), so amounts above 2^53 lost precision and u64, i128 and u32 could not be chosen. Add explicit helpers (i128, u64, u32, address, bytes, sym, str), type SorobanInvokeParams.args as xdr.ScVal[], and migrate the invoking hooks and DisputeModal to them. Closes #1424 --- frontend/__tests__/lib/stellar-scval.test.ts | 48 ++++++++++++ frontend/components/DisputeModal.tsx | 4 +- frontend/hooks/useClaimRefund.ts | 4 +- frontend/hooks/useClaimWinnings.ts | 4 +- frontend/hooks/useCreateMarket.ts | 35 +++++---- frontend/hooks/usePlaceBet.ts | 4 +- frontend/lib/stellar.ts | 80 +++++++++++++++----- 7 files changed, 138 insertions(+), 41 deletions(-) create mode 100644 frontend/__tests__/lib/stellar-scval.test.ts diff --git a/frontend/__tests__/lib/stellar-scval.test.ts b/frontend/__tests__/lib/stellar-scval.test.ts new file mode 100644 index 000000000..707028726 --- /dev/null +++ b/frontend/__tests__/lib/stellar-scval.test.ts @@ -0,0 +1,48 @@ +import { Keypair, scValToNative } from "@stellar/stellar-sdk"; +import { address, bytes, i128, str, sym, u32, u64 } from "../../lib/stellar"; + +describe("typed ScVal helpers", () => { + it("i128 keeps amounts above Number.MAX_SAFE_INTEGER exact", async () => { + const amount = BigInt(Number.MAX_SAFE_INTEGER) * 1000n + 7n; + + expect(scValToNative(await i128(amount))).toBe(amount); + }); + + it("i128 keeps the largest signed 128-bit value exact", async () => { + const max = 2n ** 127n - 1n; + + expect(scValToNative(await i128(max))).toBe(max); + }); + + it("u64 round-trips a bigint", async () => { + expect(scValToNative(await u64(1_700_000_000n))).toBe(1_700_000_000n); + }); + + it("u32 round-trips a number", async () => { + expect(scValToNative(await u32(42))).toBe(42); + }); + + it("address encodes a strkey account", async () => { + const account = Keypair.random().publicKey(); + + expect(scValToNative(await address(account))).toBe(account); + }); + + it("bytes encodes a hex string", async () => { + expect(Buffer.from(scValToNative(await bytes("0aff")))).toEqual(Buffer.from([0x0a, 0xff])); + }); + + it("bytes rejects odd-length or non-hex input", async () => { + await expect(bytes("abc")).rejects.toThrow(TypeError); + await expect(bytes("zz")).rejects.toThrow(TypeError); + }); + + it("sym and str keep the text they were given", async () => { + expect(scValToNative(await sym("FighterA"))).toBe("FighterA"); + expect(scValToNative(await str("20-0"))).toBe("20-0"); + }); + + it("a numeric string is never coerced into a number", async () => { + expect(scValToNative(await str("12345678901234567890"))).toBe("12345678901234567890"); + }); +}); diff --git a/frontend/components/DisputeModal.tsx b/frontend/components/DisputeModal.tsx index a46b2792f..0e66967d8 100644 --- a/frontend/components/DisputeModal.tsx +++ b/frontend/components/DisputeModal.tsx @@ -3,7 +3,7 @@ import { useState, useEffect, useCallback } from "react"; import { createPortal } from "react-dom"; import { Market, Bet } from "@/lib/api"; import { CountdownTimer } from "@/components/CountdownTimer"; -import { buildSorobanInvocation, submitTransaction } from "@/lib/stellar"; +import { buildSorobanInvocation, submitTransaction, str } from "@/lib/stellar"; import { useWallet } from "@/hooks/useWallet"; import { useToast } from "@/components/ToastProvider"; @@ -99,7 +99,7 @@ export function DisputeModal({ const xdr = await buildSorobanInvocation({ contractId: market.id, method: "dispute_resolution", - args: [reason.trim()], + args: [await str(reason.trim())], signerAddress: address, }); diff --git a/frontend/hooks/useClaimRefund.ts b/frontend/hooks/useClaimRefund.ts index 6d1b651a0..3db01a0e7 100644 --- a/frontend/hooks/useClaimRefund.ts +++ b/frontend/hooks/useClaimRefund.ts @@ -1,7 +1,7 @@ "use client"; import { useState } from "react"; import type { ClaimReceipt } from "@/components/ClaimButton"; -import { buildSorobanInvocation, submitTransaction, decodeScVal } from "@/lib/stellar"; +import { buildSorobanInvocation, submitTransaction, decodeScVal, bytes } from "@/lib/stellar"; import { fetchMarketById, fetchMarketBets } from "@/lib/api"; import { useWallet } from "@/hooks/useWallet"; @@ -49,7 +49,7 @@ export function useClaimRefund(): UseClaimRefundResult { const xdr = await buildSorobanInvocation({ contractId: market_id, method: "claim_refund", - args: [bet_id], + args: [await bytes(bet_id)], signerAddress: address, }); diff --git a/frontend/hooks/useClaimWinnings.ts b/frontend/hooks/useClaimWinnings.ts index 3f3bea5dc..f38b2ac12 100644 --- a/frontend/hooks/useClaimWinnings.ts +++ b/frontend/hooks/useClaimWinnings.ts @@ -1,7 +1,7 @@ "use client"; import { useState } from "react"; import type { ClaimReceipt } from "@/components/ClaimButton"; -import { buildSorobanInvocation, submitTransaction, decodeScVal } from "@/lib/stellar"; +import { buildSorobanInvocation, submitTransaction, decodeScVal, bytes } from "@/lib/stellar"; import { fetchMarketById, fetchMarketBets } from "@/lib/api"; import { useWallet } from "@/hooks/useWallet"; @@ -56,7 +56,7 @@ export function useClaimWinnings(): UseClaimWinningsResult { const xdr = await buildSorobanInvocation({ contractId: market_id, method, - args: [bet_id], + args: [await bytes(bet_id)], signerAddress: address, }); diff --git a/frontend/hooks/useCreateMarket.ts b/frontend/hooks/useCreateMarket.ts index 6826b8192..daab1b287 100644 --- a/frontend/hooks/useCreateMarket.ts +++ b/frontend/hooks/useCreateMarket.ts @@ -1,7 +1,14 @@ "use client"; import { useState } from "react"; import { CreateMarketFormData } from "@/components/CreateMarketForm"; -import { buildSorobanInvocation, submitTransaction, decodeScVal } from "@/lib/stellar"; +import { + buildSorobanInvocation, + submitTransaction, + decodeScVal, + str, + u64, + address, +} from "@/lib/stellar"; import { useWallet } from "@/hooks/useWallet"; export interface UseCreateMarketResult { @@ -35,19 +42,19 @@ export function useCreateMarket(): UseCreateMarketResult { const xdr = await buildSorobanInvocation({ contractId: "MARKET_FACTORY_ID", method: "create_market", - args: [ - data.fighterAName, - data.fighterARecord, - data.fighterANationality, - data.fighterAWeightClass, - data.fighterBName, - data.fighterBRecord, - data.fighterBNationality, - data.fighterBWeightClass, - new Date(data.scheduledAt).getTime() / 1000, - new Date(data.bettingEndsAt).getTime() / 1000, - data.oracleAddress, - ], + args: await Promise.all([ + str(data.fighterAName), + str(data.fighterARecord), + str(data.fighterANationality), + str(data.fighterAWeightClass), + str(data.fighterBName), + str(data.fighterBRecord), + str(data.fighterBNationality), + str(data.fighterBWeightClass), + u64(BigInt(Math.floor(new Date(data.scheduledAt).getTime() / 1000))), + u64(BigInt(Math.floor(new Date(data.bettingEndsAt).getTime() / 1000))), + address(data.oracleAddress), + ]), signerAddress: address, }); diff --git a/frontend/hooks/usePlaceBet.ts b/frontend/hooks/usePlaceBet.ts index 370c0f19f..dff18d534 100644 --- a/frontend/hooks/usePlaceBet.ts +++ b/frontend/hooks/usePlaceBet.ts @@ -1,7 +1,7 @@ "use client"; import { useState } from "react"; import { Bet, BetSide } from "@/lib/api"; -import { buildSorobanInvocation, submitTransaction } from "@/lib/stellar"; +import { buildSorobanInvocation, submitTransaction, i128, sym } from "@/lib/stellar"; import { useWallet } from "@/hooks/useWallet"; export interface UsePlaceBetResult { @@ -35,7 +35,7 @@ export function usePlaceBet(market_id: string): UsePlaceBetResult { const xdr = await buildSorobanInvocation({ contractId: market_id, method: "place_bet", - args: [side, amount], + args: await Promise.all([sym(side), i128(amount)]), signerAddress: address, }); diff --git a/frontend/lib/stellar.ts b/frontend/lib/stellar.ts index 461a37790..19dd0a6f4 100644 --- a/frontend/lib/stellar.ts +++ b/frontend/lib/stellar.ts @@ -1,4 +1,4 @@ -import type { Transaction } from "@stellar/stellar-sdk"; +import type { Transaction, xdr } from "@stellar/stellar-sdk"; import { ContractError, ContractErrorException, parseContractErrorCode } from "./errors"; // The SDK is large; load it only when a transaction is actually built or decoded @@ -74,7 +74,7 @@ export function stellarExpertTxUrl(txHash: string): string { export interface SorobanInvokeParams { contractId: string; method: string; - args: unknown[]; + args: xdr.ScVal[]; signerAddress: string; } @@ -155,8 +155,7 @@ function simulationErrorText(simResult: unknown): string { export async function buildSorobanInvocation( params: SorobanInvokeParams ): Promise { - const { Operation, SorobanRpc, TransactionBuilder, Timeout, nativeToScVal, addressToScVal } = - await loadSdk(); + const { Operation, SorobanRpc, TransactionBuilder, Timeout } = await loadSdk(); const server = new SorobanRpc.Server(SOROBAN_RPC_URL, { allowHttp: true, }); @@ -164,21 +163,7 @@ export async function buildSorobanInvocation( const operation = Operation.invokeHostFunction({ hostFunction: { functionName: params.method, - args: params.args.map((arg) => { - if (typeof arg === "bigint") return nativeToScVal(arg); - if (typeof arg === "string") { - try { - return addressToScVal(arg); - } catch { - return nativeToScVal(Number(arg)); - } - } - if (typeof arg === "number") return nativeToScVal(arg); - if (Array.isArray(arg)) { - return nativeToScVal(arg); - } - return arg as any; - }), + args: params.args, }, auth: [], }); @@ -314,6 +299,63 @@ export async function decodeScVal(scVal: unknown): Promise { } } +// ─── SCVAL ARGUMENT HELPERS ─────────────────────────────────────────────────── +// Each helper states the Soroban type explicitly, so no value is guessed at +// runtime. They are async because the SDK is loaded lazily. + +/** Soroban `i128`. Keeps amounts above Number.MAX_SAFE_INTEGER exact. */ +export async function i128(value: bigint): Promise { + const { nativeToScVal } = await loadSdk(); + + return nativeToScVal(value, { type: "i128" }); +} + +/** Soroban `u64`. */ +export async function u64(value: bigint): Promise { + const { nativeToScVal } = await loadSdk(); + + return nativeToScVal(value, { type: "u64" }); +} + +/** Soroban `u32`. */ +export async function u32(value: number): Promise { + const { nativeToScVal } = await loadSdk(); + + return nativeToScVal(value, { type: "u32" }); +} + +/** Soroban `Address` from a G… account or C… contract strkey. */ +export async function address(strkey: string): Promise { + const { Address } = await loadSdk(); + + return new Address(strkey).toScVal(); +} + +/** Soroban `Bytes` from a hex string. Rejects odd length and non-hex characters. */ +export async function bytes(hex: string): Promise { + if (!/^([0-9a-fA-F]{2})*$/.test(hex)) { + throw new TypeError("bytes() expects an even-length hex string"); + } + + const { xdr } = await loadSdk(); + + return xdr.ScVal.scvBytes(Buffer.from(hex, "hex")); +} + +/** Soroban `Symbol`. */ +export async function sym(value: string): Promise { + const { nativeToScVal } = await loadSdk(); + + return nativeToScVal(value, { type: "symbol" }); +} + +/** Soroban `String`. */ +export async function str(value: string): Promise { + const { nativeToScVal } = await loadSdk(); + + return nativeToScVal(value, { type: "string" }); +} + /** * Converts a XLM amount in stroops (bigint) to a human-readable string. * e.g. 10_000_000n -> "1" From 7096e467400045508c5389392df0cc5d17f13d9c Mon Sep 17 00:00:00 2001 From: leofoxcode-oss Date: Mon, 19 Oct 2026 03:33:39 +0000 Subject: [PATCH 3/4] fix: #1425 only allow insecure RPC URLs in local development Both Soroban RPC clients were built with allowHttp: true, so a NEXT_PUBLIC_SOROBAN_RPC_URL set to http:// was accepted in production and transactions could be tampered with in transit. allowHttp is now enabled only for localhost/127.0.0.1 outside production and off mainnet. Any other non-HTTPS RPC URL is rejected with an error. Closes #1425 --- .../__tests__/lib/stellar-rpc-url.test.ts | 39 ++++++++++++++++ frontend/lib/stellar.ts | 46 ++++++++++++++++--- 2 files changed, 79 insertions(+), 6 deletions(-) create mode 100644 frontend/__tests__/lib/stellar-rpc-url.test.ts diff --git a/frontend/__tests__/lib/stellar-rpc-url.test.ts b/frontend/__tests__/lib/stellar-rpc-url.test.ts new file mode 100644 index 000000000..0ec70df4b --- /dev/null +++ b/frontend/__tests__/lib/stellar-rpc-url.test.ts @@ -0,0 +1,39 @@ +import { allowHttpForRpc } from "../../lib/stellar"; + +describe("allowHttpForRpc", () => { + const development = { production: false, network: "testnet" }; + + it("allows HTTPS RPC URLs without enabling allowHttp", () => { + expect(allowHttpForRpc("https://soroban-testnet.stellar.org", development)).toBe(false); + expect(allowHttpForRpc("https://rpc.example.com", { production: true, network: "mainnet" })).toBe( + false + ); + }); + + it("allows plain HTTP only for localhost outside production", () => { + expect(allowHttpForRpc("http://localhost:8000", development)).toBe(true); + expect(allowHttpForRpc("http://127.0.0.1:8000", development)).toBe(true); + }); + + it("rejects plain HTTP to a non-local host", () => { + expect(() => allowHttpForRpc("http://rpc.example.com", development)).toThrow( + "HTTPS required" + ); + }); + + it("rejects plain HTTP to localhost in production", () => { + expect(() => + allowHttpForRpc("http://localhost:8000", { production: true, network: "testnet" }) + ).toThrow("HTTPS required"); + }); + + it("rejects plain HTTP to localhost on mainnet", () => { + expect(() => + allowHttpForRpc("http://localhost:8000", { production: false, network: "mainnet" }) + ).toThrow("HTTPS required"); + }); + + it("rejects a malformed URL", () => { + expect(() => allowHttpForRpc("not a url", development)).toThrow(); + }); +}); diff --git a/frontend/lib/stellar.ts b/frontend/lib/stellar.ts index 19dd0a6f4..2626f8548 100644 --- a/frontend/lib/stellar.ts +++ b/frontend/lib/stellar.ts @@ -53,6 +53,44 @@ export function isSameNetwork(a: string | null | undefined, b: string | null | u export const SOROBAN_RPC_URL = process.env.NEXT_PUBLIC_SOROBAN_RPC_URL ?? "https://soroban-testnet.stellar.org"; +const LOCAL_HOSTNAMES = new Set(["localhost", "127.0.0.1"]); + +/** + * Decides whether the Soroban RPC client may use plain HTTP for `url`. + * Plain HTTP is allowed only for localhost/127.0.0.1 outside production and + * off mainnet. Any other non-HTTPS URL throws, so transactions cannot be sent + * unencrypted by a misconfigured environment variable. + */ +export function allowHttpForRpc( + url: string, + options: { production?: boolean; network?: string } = {} +): boolean { + const production = options.production ?? process.env.NODE_ENV === "production"; + const network = (options.network ?? NETWORK).toLowerCase(); + const parsed = new URL(url); + + if (parsed.protocol === "https:") { + return false; + } + + const isLocalDevelopment = + parsed.protocol === "http:" && + LOCAL_HOSTNAMES.has(parsed.hostname) && + !production && + network !== "mainnet"; + + if (isLocalDevelopment) { + return true; + } + + throw new Error(`Insecure Soroban RPC URL rejected (HTTPS required): ${url}`); +} + +/** Options for `new SorobanRpc.Server`, validated against the configured RPC URL. */ +function rpcServerOptions(): { allowHttp: boolean } { + return { allowHttp: allowHttpForRpc(SOROBAN_RPC_URL) }; +} + /** * Base URL for the stellar.expert explorer, derived from the configured network. * Used to link a transaction hash to its on-chain record. @@ -156,9 +194,7 @@ export async function buildSorobanInvocation( params: SorobanInvokeParams ): Promise { const { Operation, SorobanRpc, TransactionBuilder, Timeout } = await loadSdk(); - const server = new SorobanRpc.Server(SOROBAN_RPC_URL, { - allowHttp: true, - }); + const server = new SorobanRpc.Server(SOROBAN_RPC_URL, rpcServerOptions()); const operation = Operation.invokeHostFunction({ hostFunction: { @@ -215,9 +251,7 @@ export async function submitTransaction( onStatus?: (update: TransactionStatusUpdate) => void ): Promise { const { SorobanRpc, TransactionBuilder } = await loadSdk(); - const server = new SorobanRpc.Server(SOROBAN_RPC_URL, { - allowHttp: true, - }); + const server = new SorobanRpc.Server(SOROBAN_RPC_URL, rpcServerOptions()); const transaction = TransactionBuilder.fromXDR( signedXdr, From e593cc8644c516e8bc8268b6851c3fed2b91e90c Mon Sep 17 00:00:00 2001 From: leofoxcode-oss Date: Mon, 19 Oct 2026 03:34:01 +0000 Subject: [PATCH 4/4] fix: #1426 make stroops/XLM conversion exact and validate input stroopsToXlm divided through Number, which loses precision for large values and renders float artefacts. xlmToStroops threw a raw SyntaxError for input like "abc" or "1e3", accepted negatives, and silently truncated to 7 decimals. Both directions now use BigInt only. xlmToStroops throws InvalidAmountError with a reason for empty, negative, non-numeric, over-precise (more than 7 significant decimals) or out-of-range input. Closes #1426 --- .../__tests__/lib/stellar-amounts.test.ts | 69 ++++++++++++++++ frontend/lib/stellar.ts | 79 ++++++++++++++----- 2 files changed, 130 insertions(+), 18 deletions(-) create mode 100644 frontend/__tests__/lib/stellar-amounts.test.ts diff --git a/frontend/__tests__/lib/stellar-amounts.test.ts b/frontend/__tests__/lib/stellar-amounts.test.ts new file mode 100644 index 000000000..aa768ea0f --- /dev/null +++ b/frontend/__tests__/lib/stellar-amounts.test.ts @@ -0,0 +1,69 @@ +import { InvalidAmountError, stroopsToXlm, xlmToStroops } from "../../lib/stellar"; + +const MAX_I128 = 2n ** 127n - 1n; + +describe("stroopsToXlm", () => { + it("renders 0 stroops as 0", () => { + expect(stroopsToXlm(0n)).toBe("0"); + }); + + it("renders a single stroop exactly", () => { + expect(stroopsToXlm(1n)).toBe("0.0000001"); + }); + + it("renders the largest i128 value without float error", () => { + expect(stroopsToXlm(MAX_I128)).toBe("17014118346046923173168730371588.4105727"); + }); + + it("drops trailing zeros in the fraction", () => { + expect(stroopsToXlm(15_000_000n)).toBe("1.5"); + expect(stroopsToXlm(10_000_000n)).toBe("1"); + }); + + it("renders negative amounts with a sign", () => { + expect(stroopsToXlm(-15_000_000n)).toBe("-1.5"); + }); +}); + +describe("xlmToStroops", () => { + it("converts 0 to 0", () => { + expect(xlmToStroops("0")).toBe(0n); + }); + + it("converts one stroop", () => { + expect(xlmToStroops("0.0000001")).toBe(1n); + }); + + it("accepts trailing zeros beyond 7 decimals", () => { + expect(xlmToStroops("1.00000000")).toBe(10_000_000n); + expect(xlmToStroops("2.5000000000")).toBe(25_000_000n); + }); + + it("converts the largest i128 value exactly", () => { + expect(xlmToStroops("17014118346046923173168730371588.4105727")).toBe(MAX_I128); + }); + + it("round-trips through stroopsToXlm", () => { + expect(xlmToStroops(stroopsToXlm(123_456_789n))).toBe(123_456_789n); + }); + + it.each([ + ["", "empty"], + [" ", "empty"], + ["-1", "negative"], + ["-0.5", "negative"], + ["abc", "non-numeric"], + ["1e3", "non-numeric"], + ["1,5", "non-numeric"], + ["0.00000001", "over-precision"], + ["17014118346046923173168730371588.4105728", "out-of-range"], + ])("rejects %j with reason %s", (input, reason) => { + try { + xlmToStroops(input); + throw new Error("expected xlmToStroops to throw"); + } catch (error) { + expect(error).toBeInstanceOf(InvalidAmountError); + expect((error as InvalidAmountError).reason).toBe(reason); + } + }); +}); diff --git a/frontend/lib/stellar.ts b/frontend/lib/stellar.ts index 2626f8548..bef70bfb9 100644 --- a/frontend/lib/stellar.ts +++ b/frontend/lib/stellar.ts @@ -390,35 +390,78 @@ export async function str(value: string): Promise { return nativeToScVal(value, { type: "string" }); } +const STROOPS_PER_XLM = 10_000_000n; +const STELLAR_DECIMALS = 7; +const I128_MAX = 2n ** 127n - 1n; + /** - * Converts a XLM amount in stroops (bigint) to a human-readable string. - * e.g. 10_000_000n -> "1" + * Thrown by `xlmToStroops` when user input cannot be converted exactly. + * `reason` lets callers pick a message without parsing the text. */ -export function stroopsToXlm(stroops: bigint): string { - const xlm = Number(stroops) / 10000000; +export class InvalidAmountError extends Error { + readonly reason: "empty" | "negative" | "non-numeric" | "over-precision" | "out-of-range"; + + constructor(reason: InvalidAmountError["reason"], message: string) { + super(message); + this.name = "InvalidAmountError"; + this.reason = reason; + } +} - return xlm.toString(); +/** + * Converts a XLM amount in stroops (bigint) to an exact decimal string. + * Uses BigInt arithmetic only, so no float artefacts appear. + * e.g. 10_000_000n -> "1", 1n -> "0.0000001", -15_000_000n -> "-1.5" + */ +export function stroopsToXlm(stroops: bigint): string { + const sign = stroops < 0n ? "-" : ""; + const magnitude = stroops < 0n ? -stroops : stroops; + const whole = magnitude / STROOPS_PER_XLM; + const fraction = (magnitude % STROOPS_PER_XLM) + .toString() + .padStart(STELLAR_DECIMALS, "0") + .replace(/0+$/, ""); + + return `${sign}${whole}${fraction ? `.${fraction}` : ""}`; } /** - * Converts a human-readable XLM string to stroops (bigint). + * Converts a human-readable XLM string to stroops (bigint), exactly. * e.g. "1.5" -> 15_000_000n + * Throws `InvalidAmountError` for empty, negative, non-numeric, over-precise + * (more than 7 significant decimals) or out-of-range input. */ export function xlmToStroops(xlm: string): bigint { - const parts = xlm.split("."); - const integer = BigInt(parts[0] || "0"); - let fractional = "0"; - - if (parts.length > 1) { - fractional = parts[1]; - if (fractional.length > 7) { - fractional = fractional.slice(0, 7); - } else if (fractional.length < 7) { - fractional = fractional.padEnd(7, "0"); - } + const input = xlm.trim(); + + if (input === "") { + throw new InvalidAmountError("empty", "Amount is required"); } - const stroops = integer * BigInt(10000000) + BigInt(fractional); + if (input.startsWith("-")) { + throw new InvalidAmountError("negative", "Amount cannot be negative"); + } + + if (!/^(\d+\.?\d*|\.\d+)$/.test(input)) { + throw new InvalidAmountError("non-numeric", `"${input}" is not a valid amount`); + } + + const [whole, fraction = ""] = input.split("."); + + if (/[1-9]/.test(fraction.slice(STELLAR_DECIMALS))) { + throw new InvalidAmountError( + "over-precision", + `Amount supports at most ${STELLAR_DECIMALS} decimal places` + ); + } + + const stroops = + BigInt(whole || "0") * STROOPS_PER_XLM + + BigInt(fraction.slice(0, STELLAR_DECIMALS).padEnd(STELLAR_DECIMALS, "0")); + + if (stroops > I128_MAX) { + throw new InvalidAmountError("out-of-range", "Amount is too large"); + } return stroops; }