diff --git a/frontend/__tests__/lib/stellar-amounts.test.ts b/frontend/__tests__/lib/stellar-amounts.test.ts new file mode 100644 index 00000000..aa768ea0 --- /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/__tests__/lib/stellar-invocation.test.ts b/frontend/__tests__/lib/stellar-invocation.test.ts new file mode 100644 index 00000000..a946e3f3 --- /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/__tests__/lib/stellar-rpc-url.test.ts b/frontend/__tests__/lib/stellar-rpc-url.test.ts new file mode 100644 index 00000000..0ec70df4 --- /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/__tests__/lib/stellar-scval.test.ts b/frontend/__tests__/lib/stellar-scval.test.ts new file mode 100644 index 00000000..70702872 --- /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 b485f135..e1cc6436 100644 --- a/frontend/components/DisputeModal.tsx +++ b/frontend/components/DisputeModal.tsx @@ -3,7 +3,7 @@ import { useState, useCallback, useRef } 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"; import { useAccessibleDialog } from "@/hooks/useAccessibleDialog"; @@ -104,7 +104,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 6d1b651a..3db01a0e 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 3f3bea5d..f38b2ac1 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 6826b819..daab1b28 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 370c0f19..dff18d53 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 0b6a4910..a6134a37 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. @@ -81,7 +119,7 @@ export function stellarExpertContractUrl(contractId: string): string { export interface SorobanInvokeParams { contractId: string; method: string; - args: unknown[]; + args: xdr.ScVal[]; signerAddress: string; } @@ -162,33 +200,13 @@ function simulationErrorText(simResult: unknown): string { export async function buildSorobanInvocation( params: SorobanInvokeParams ): Promise { - const { Operation, SorobanRpc, TransactionBuilder, Timeout, nativeToScVal, addressToScVal, xdr: sdkXdr } = - await loadSdk(); - const server = new SorobanRpc.Server(SOROBAN_RPC_URL, { - allowHttp: true, - }); + const { Operation, SorobanRpc, TransactionBuilder, Timeout } = await loadSdk(); + const server = new SorobanRpc.Server(SOROBAN_RPC_URL, rpcServerOptions()); 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); - } - if (arg !== null && typeof arg === "object" && "_arm" in arg) { - return arg as xdr.ScVal; - } - return nativeToScVal(arg); - }), + args: params.args, }, auth: [], }); @@ -219,8 +237,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(); } @@ -239,9 +258,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, @@ -323,35 +340,135 @@ 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" }); +} + +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"; - return xlm.toString(); + constructor(reason: InvalidAmountError["reason"], message: string) { + super(message); + this.name = "InvalidAmountError"; + this.reason = reason; + } } /** - * Converts a human-readable XLM string to stroops (bigint). + * 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), 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"); + } + + if (input.startsWith("-")) { + throw new InvalidAmountError("negative", "Amount cannot be negative"); } - const stroops = integer * BigInt(10000000) + BigInt(fractional); + 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; }