Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions frontend/__tests__/lib/stellar-amounts.test.ts
Original file line number Diff line number Diff line change
@@ -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);
}
});
});
47 changes: 47 additions & 0 deletions frontend/__tests__/lib/stellar-invocation.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
39 changes: 39 additions & 0 deletions frontend/__tests__/lib/stellar-rpc-url.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
48 changes: 48 additions & 0 deletions frontend/__tests__/lib/stellar-scval.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
4 changes: 2 additions & 2 deletions frontend/components/DisputeModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
});

Expand Down
4 changes: 2 additions & 2 deletions frontend/hooks/useClaimRefund.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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,
});

Expand Down
4 changes: 2 additions & 2 deletions frontend/hooks/useClaimWinnings.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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,
});

Expand Down
35 changes: 21 additions & 14 deletions frontend/hooks/useCreateMarket.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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,
});

Expand Down
4 changes: 2 additions & 2 deletions frontend/hooks/usePlaceBet.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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,
});

Expand Down
Loading