diff --git a/frontend/__tests__/hooks/useCreateMarket.test.ts b/frontend/__tests__/hooks/useCreateMarket.test.ts index 40a50075..116d0936 100644 --- a/frontend/__tests__/hooks/useCreateMarket.test.ts +++ b/frontend/__tests__/hooks/useCreateMarket.test.ts @@ -1,11 +1,27 @@ import { renderHook, act, waitFor } from "@testing-library/react"; +import { scValToNative } from "@stellar/stellar-sdk"; import { useCreateMarket } from "@/hooks/useCreateMarket"; import type { CreateMarketFormData } from "@/components/CreateMarketForm"; import * as stellar from "@/lib/stellar"; +import * as api from "@/lib/api"; import { useWallet } from "@/hooks/useWallet"; -jest.mock("@/lib/stellar"); -jest.mock("@/hooks/useWallet"); +jest.mock("@/hooks/useWallet", () => ({ useWallet: jest.fn() })); +jest.mock("@/lib/config", () => ({ + get MARKET_FACTORY_CONTRACT_ID() { + return mockFactoryId; + }, +})); +jest.mock("@/lib/api", () => ({ createMarketMeta: jest.fn() })); +jest.mock("@/lib/stellar", () => ({ + ...jest.requireActual("@/lib/stellar"), + buildSorobanInvocation: jest.fn(), + submitTransaction: jest.fn(), + decodeScVal: jest.fn(), +})); + +// Read lazily by the config mock, so a test can switch the factory off. +let mockFactoryId: string | undefined = "CFACTORYCONTRACTADDRESS"; const FORM_DATA: CreateMarketFormData = { fighterAName: "Ali", @@ -23,20 +39,22 @@ const FORM_DATA: CreateMarketFormData = { beforeEach(() => { jest.clearAllMocks(); + mockFactoryId = "CFACTORYCONTRACTADDRESS"; (useWallet as jest.Mock).mockReturnValue({ address: "GUSER123", signTransaction: jest.fn().mockResolvedValue("signed-xdr"), }); -}); - -test("happy path: returns new market_id on success", async () => { - (stellar.buildSorobanInvocation as jest.Mock).mockResolvedValue("unsigned-xdr"); (stellar.submitTransaction as jest.Mock).mockResolvedValue({ txHash: "hash123", ledger: 100, returnValue: { type: "Bytes", value: "mkt-new-hex" }, }); - (stellar.decodeScVal as jest.Mock).mockReturnValue("mkt-new-hex"); + (stellar.decodeScVal as jest.Mock).mockResolvedValue("mkt-new-hex"); + (api.createMarketMeta as jest.Mock).mockResolvedValue({ success: true }); +}); + +test("happy path: returns new market_id on success", async () => { + (stellar.buildSorobanInvocation as jest.Mock).mockResolvedValue("unsigned-xdr"); const { result } = renderHook(() => useCreateMarket()); let id: string | undefined; @@ -50,6 +68,73 @@ test("happy path: returns new market_id on success", async () => { expect(result.current.isLoading).toBe(false); }); +test("builds create_market against the configured factory with typed arguments", async () => { + (stellar.buildSorobanInvocation as jest.Mock).mockResolvedValue("unsigned-xdr"); + + const { result } = renderHook(() => useCreateMarket()); + + await act(async () => { + await result.current.createMarket(FORM_DATA); + }); + + const call = (stellar.buildSorobanInvocation as jest.Mock).mock.calls[0][0]; + expect(call.contractId).toBe("CFACTORYCONTRACTADDRESS"); + expect(call.method).toBe("create_market"); + expect(call.signerAddress).toBe("GUSER123"); + + const [caller, fighterA, fighterB, oracle, lockTime, endTime] = call.args; + expect(caller).toBe("GUSER123"); + expect([fighterA, fighterB, oracle]).toEqual(["Ali", "Foreman", "GORACLE"]); + expect(lockTime.switch().name).toBe("scvU64"); + expect(scValToNative(lockTime)).toBe(BigInt(Date.parse(FORM_DATA.bettingEndsAt) / 1000)); + expect(endTime.switch().name).toBe("scvU64"); + expect(scValToNative(endTime)).toBe(BigInt(Date.parse(FORM_DATA.scheduledAt) / 1000)); +}); + +test("sends fighter metadata and the tx hash to the backend after confirmation", async () => { + (stellar.buildSorobanInvocation as jest.Mock).mockResolvedValue("unsigned-xdr"); + + const { result } = renderHook(() => useCreateMarket()); + + await act(async () => { + await result.current.createMarket(FORM_DATA); + }); + + expect(api.createMarketMeta).toHaveBeenCalledWith({ ...FORM_DATA, txHash: "hash123" }); +}); + +test("still resolves with the market_id when the metadata sync fails", async () => { + (stellar.buildSorobanInvocation as jest.Mock).mockResolvedValue("unsigned-xdr"); + (api.createMarketMeta as jest.Mock).mockRejectedValue(new Error("backend down")); + const consoleError = jest.spyOn(console, "error").mockImplementation(() => {}); + + const { result } = renderHook(() => useCreateMarket()); + let id: string | undefined; + + await act(async () => { + id = await result.current.createMarket(FORM_DATA); + }); + + expect(id).toBe("mkt-new-hex"); + expect(result.current.error).toBeNull(); + expect(consoleError).toHaveBeenCalled(); + consoleError.mockRestore(); +}); + +test("throws when no factory contract is configured", async () => { + mockFactoryId = undefined; + + const { result } = renderHook(() => useCreateMarket()); + + await act(async () => { + await expect(result.current.createMarket(FORM_DATA)).rejects.toThrow( + "Market factory contract is not configured" + ); + }); + + expect(stellar.buildSorobanInvocation).not.toHaveBeenCalled(); +}); + test("error path: sets error on submission failure", async () => { const testError = new Error("Submission failed"); (stellar.buildSorobanInvocation as jest.Mock).mockRejectedValue(testError); @@ -81,12 +166,6 @@ test("edge case: isLoading is true during request, false after completion", asyn await waitFor(() => expect(result.current.isLoading).toBe(true)); await act(async () => { - (stellar.submitTransaction as jest.Mock).mockResolvedValue({ - txHash: "hash123", - ledger: 100, - returnValue: "mkt-123", - }); - (stellar.decodeScVal as jest.Mock).mockReturnValue("mkt-123"); resolve("unsigned-xdr"); }); diff --git a/frontend/__tests__/lib/stellar.invocation.test.ts b/frontend/__tests__/lib/stellar.invocation.test.ts new file mode 100644 index 00000000..18ee1234 --- /dev/null +++ b/frontend/__tests__/lib/stellar.invocation.test.ts @@ -0,0 +1,135 @@ +import { + Account, + Address, + Keypair, + Operation, + StrKey, + Transaction, + TransactionBuilder, + scValToNative, +} from "@stellar/stellar-sdk"; +import { + buildSorobanInvocation, + NETWORK_PASSPHRASE, + submitTransaction, + TX_EXPIRED_MESSAGE, + TX_TIMEOUT_SECONDS, +} from "@/lib/stellar"; + +const mockServer = { + getAccount: jest.fn(), + simulateTransaction: jest.fn(), + sendTransaction: jest.fn(), + getTransaction: jest.fn(), +}; + +jest.mock("@stellar/stellar-sdk", () => { + const actual = jest.requireActual("@stellar/stellar-sdk"); + + return { + ...actual, + SorobanRpc: { + ...actual.SorobanRpc, + Server: jest.fn(() => mockServer), + assembleTransaction: (tx: { toEnvelope: () => any }) => ({ + operations: tx.toEnvelope().v1().tx().operations(), + }), + Api: { ...actual.SorobanRpc.Api, isSimulationSuccess: () => true }, + }, + }; +}); + +const SIGNER = Keypair.random().publicKey(); +const CONTRACT_ID = StrKey.encodeContract(Buffer.alloc(32, 1)); + +beforeEach(() => { + jest.clearAllMocks(); + mockServer.getAccount.mockResolvedValue(new Account(SIGNER, "1")); + mockServer.simulateTransaction.mockResolvedValue({ minResourceFee: "100" }); +}); + +async function buildAndDecode(args: unknown[]) { + const xdr = await buildSorobanInvocation({ + contractId: CONTRACT_ID, + method: "place_bet", + args, + signerAddress: SIGNER, + }); + + return TransactionBuilder.fromXDR(xdr, NETWORK_PASSPHRASE) as Transaction; +} + +describe("buildSorobanInvocation encoding", () => { + it("encodes a single invoke-contract operation targeting the given contract", async () => { + const tx = await buildAndDecode([]); + + expect(tx.operations).toHaveLength(1); + expect(tx.operations[0].type).toBe("invokeHostFunction"); + + const invoke = (tx.operations[0] as Operation.InvokeHostFunction).func.invokeContract(); + expect(Address.fromScAddress(invoke.contractAddress()).toString()).toBe(CONTRACT_ID); + }); + + it("uses the method name as the invoked function name", async () => { + const tx = await buildAndDecode([]); + + const invoke = (tx.operations[0] as Operation.InvokeHostFunction).func.invokeContract(); + expect(invoke.functionName().toString()).toBe("place_bet"); + }); + + it("rejects a contract id that is not a C-strkey before touching the network", async () => { + await expect( + buildSorobanInvocation({ + contractId: "market-1", + method: "place_bet", + args: [], + signerAddress: SIGNER, + }) + ).rejects.toThrow("Invalid contract address: market-1"); + + expect(mockServer.getAccount).not.toHaveBeenCalled(); + }); + + it("converts each argument into the matching ScVal type", async () => { + const tx = await buildAndDecode([SIGNER, 100n]); + + const invoke = (tx.operations[0] as Operation.InvokeHostFunction).func.invokeContract(); + const [addressArg, amountArg] = invoke.args(); + + expect(addressArg.switch().name).toBe("scvAddress"); + expect(Address.fromScVal(addressArg).toString()).toBe(SIGNER); + expect(scValToNative(amountArg)).toBe(100n); + }); +}); + +describe("transaction validity window", () => { + it("sets maxTime to the bounded timeout from the moment of building", async () => { + const before = Math.floor(Date.now() / 1000); + const tx = await buildAndDecode([]); + const after = Math.floor(Date.now() / 1000); + + const maxTime = Number(tx.timeBounds?.maxTime); + expect(maxTime).toBeGreaterThanOrEqual(before + TX_TIMEOUT_SECONDS); + expect(maxTime).toBeLessThanOrEqual(after + TX_TIMEOUT_SECONDS); + }); + + it("maps a txTooLate rejection to the friendly expiry message", async () => { + const signedXdr = await buildSorobanInvocation({ + contractId: CONTRACT_ID, + method: "place_bet", + args: [], + signerAddress: SIGNER, + }); + + mockServer.sendTransaction.mockResolvedValue({ + status: "ERROR", + errorResult: { result: () => ({ switch: () => ({ name: "txTooLate" }) }) }, + }); + + const onStatus = jest.fn(); + await expect(submitTransaction(signedXdr, onStatus)).rejects.toThrow(TX_EXPIRED_MESSAGE); + expect(onStatus).toHaveBeenCalledWith( + expect.objectContaining({ status: "failed", error: TX_EXPIRED_MESSAGE }) + ); + }); +}); diff --git a/frontend/__tests__/useClaimRefund.test.ts b/frontend/__tests__/useClaimRefund.test.ts new file mode 100644 index 00000000..799e83b3 --- /dev/null +++ b/frontend/__tests__/useClaimRefund.test.ts @@ -0,0 +1,51 @@ +import { renderHook, act } from "@testing-library/react"; +import { useClaimRefund } from "@/hooks/useClaimRefund"; +import { useWallet } from "@/hooks/useWallet"; +import * as stellar from "@/lib/stellar"; +import * as api from "@/lib/api"; + +jest.mock("@/hooks/useWallet", () => ({ useWallet: jest.fn() })); +jest.mock("@/lib/stellar"); +jest.mock("@/lib/api", () => ({ fetchMarketById: jest.fn(), fetchMarketBets: jest.fn() })); + +const mockUseWallet = useWallet as jest.Mock; +const mockBuildSorobanInvocation = stellar.buildSorobanInvocation as jest.Mock; +const mockFetchMarketById = api.fetchMarketById as jest.Mock; +const mockFetchMarketBets = api.fetchMarketBets as jest.Mock; + +const ADDRESS = "GBETTOR"; +const CONTRACT_ADDRESS = "CMARKETCONTRACTADDRESS"; + +beforeEach(() => { + jest.clearAllMocks(); + mockUseWallet.mockReturnValue({ + address: ADDRESS, + isNetworkMismatched: false, + signTransaction: jest.fn().mockResolvedValue("signed-xdr"), + }); + mockFetchMarketById.mockResolvedValue({ + id: "market-1", + contractAddress: CONTRACT_ADDRESS, + status: "Cancelled", + outcome: null, + }); + mockFetchMarketBets.mockResolvedValue([{ id: "bet-1" }]); + mockBuildSorobanInvocation.mockResolvedValue("unsigned-xdr"); + (stellar.submitTransaction as jest.Mock).mockResolvedValue({ txHash: "h", ledger: 1, returnValue: null }); + (stellar.decodeScVal as jest.Mock).mockResolvedValue(50n); +}); + +it("builds claim_refund against the market contract address", async () => { + const { result } = renderHook(() => useClaimRefund()); + + await act(async () => { + await result.current.claimRefund("bet-1", "market-1"); + }); + + expect(mockBuildSorobanInvocation).toHaveBeenCalledWith({ + contractId: CONTRACT_ADDRESS, + method: "claim_refund", + args: ["bet-1"], + signerAddress: ADDRESS, + }); +}); diff --git a/frontend/components/BetForm.tsx b/frontend/components/BetForm.tsx index b72c1e86..90fee44d 100644 --- a/frontend/components/BetForm.tsx +++ b/frontend/components/BetForm.tsx @@ -52,7 +52,7 @@ export function BetForm({ market, onBetPlaced }: BetFormProps): JSX.Element { const [estimate, setEstimate] = useState(null); const [isEstimating, setIsEstimating] = useState(false); - const { placeBet, isLoading } = usePlaceBet(market.id); + const { placeBet, isLoading } = usePlaceBet(market.id, market.contractAddress); const isLocked = market.status !== "Open"; const numericAmount = parseFloat(amount); diff --git a/frontend/components/BettingInterface.tsx b/frontend/components/BettingInterface.tsx index ece999fe..654243d5 100644 --- a/frontend/components/BettingInterface.tsx +++ b/frontend/components/BettingInterface.tsx @@ -244,7 +244,7 @@ export function BettingInterface({ market, onBetPlaced }: BettingInterfaceProps) const [debouncedAmount, setDebouncedAmount] = useState(""); const [sheetOpen, setSheetOpen] = useState(false); - const { placeBet, isLoading } = usePlaceBet(market.id); + const { placeBet, isLoading } = usePlaceBet(market.id, market.contractAddress); const { addToast: showToast } = useToast(); const sheetRef = useRef(null); diff --git a/frontend/components/DisputeModal.f105-f108.test.tsx b/frontend/components/DisputeModal.f105-f108.test.tsx index bbe9e37f..42629319 100644 --- a/frontend/components/DisputeModal.f105-f108.test.tsx +++ b/frontend/components/DisputeModal.f105-f108.test.tsx @@ -1,39 +1,156 @@ import React from "react"; -import { render, screen, fireEvent } from "@testing-library/react"; +import { render, screen, fireEvent, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import { DisputeModal } from "./DisputeModal"; -import { Market } from "@/lib/api"; +import { Bet, Market } from "@/lib/api"; +import * as stellar from "@/lib/stellar"; -const market = { - id: "market-123", +jest.mock("@/hooks/useWallet", () => ({ + useWallet: () => ({ + address: "GDISPUTER", + isNetworkMismatched: false, + signTransaction: jest.fn().mockResolvedValue("signed-xdr"), + }), +})); +jest.mock("@/lib/stellar"); +jest.mock("@/components/ToastProvider", () => ({ + useToast: () => ({ addToast: jest.fn() }), +})); + +const now = Date.now(); + +const userBets: Bet[] = [{ id: "bet-1" } as Bet]; + +const buildMarket = (overrides: Partial = {}): Market => ({ + id: "m1", + contractAddress: "CA1", + fighterA: { name: "Alice", record: "10-0", nationality: "USA", weightClass: "LW" }, + fighterB: { name: "Bob", record: "9-1", nationality: "UK", weightClass: "LW" }, + // scheduledAt acts as resolvedAt proxy; 1 hour ago = within 24h window + scheduledAt: new Date(now - 1 * 60 * 60 * 1000).toISOString(), + bettingEndsAt: new Date(now - 2 * 60 * 60 * 1000).toISOString(), status: "Resolved", -} as unknown as Market; + outcome: "FighterA", + poolA: "5000000000", + poolB: "3000000000", + totalPool: "8000000000", + oracleAddress: "ORA1", + createdBy: "0xabc", + ...overrides, +}); -describe("DisputeModal accessibility (F-106)", () => { - it("renders as a labelled modal dialog", () => { - render(); +describe("DisputeModal", () => { + it("renders nothing when market is not Resolved", () => { + const { container } = render( + + ); + expect(container).toBeEmptyDOMElement(); + }); - const dialog = screen.getByRole("dialog", { name: /dispute/i }); - expect(dialog).toHaveAttribute("aria-modal", "true"); + it("renders nothing when outside dispute window (resolved > 24h ago)", () => { + const { container } = render( + + ); + expect(container).toBeEmptyDOMElement(); }); - it("moves focus into the dialog on open", () => { - render(); + it("renders nothing when isOpen is false", () => { + const { container } = render( + + ); + expect(container).toBeEmptyDOMElement(); + }); - expect(screen.getByRole("dialog")).toContainElement(document.activeElement as HTMLElement); + it("renders modal title and textarea when open and within window", () => { + render( + + ); + expect(screen.getByText("Dispute This Result")).toBeInTheDocument(); + expect(screen.getByRole("textbox")).toBeInTheDocument(); }); - it("closes on Escape and returns focus to the trigger", () => { - const onClose = jest.fn(); - render(); + it("renders in document.body portal", () => { + render( + + ); + const dialog = document.body.querySelector('[role="dialog"]'); + expect(dialog).toBeTruthy(); + }); - fireEvent.keyDown(screen.getByRole("dialog"), { key: "Escape" }); + it("shows validation error when reason is too short", async () => { + const user = userEvent.setup(); + render( + + ); + await user.type(screen.getByRole("textbox"), "Too short"); + await user.click(screen.getByRole("button", { name: /Submit Dispute/i })); + expect(screen.getByText(/at least 20 characters/i)).toBeInTheDocument(); + }); + it("calls onDisputed when reason is valid and form submitted", async () => { + const user = userEvent.setup(); + const onDisputed = jest.fn(); + render( + + ); + await user.type(screen.getByRole("textbox"), "This is a valid reason with enough characters."); + await user.click(screen.getByRole("button", { name: /Submit Dispute/i })); + await waitFor(() => expect(onDisputed).toHaveBeenCalledTimes(1)); + }); + + it("builds dispute_resolution against the market contract address", async () => { + const user = userEvent.setup(); + render( + + ); + await user.type(screen.getByRole("textbox"), "This is a valid reason with enough characters."); + await user.click(screen.getByRole("button", { name: /Submit Dispute/i })); + await waitFor(() => + expect(stellar.buildSorobanInvocation).toHaveBeenCalledWith( + expect.objectContaining({ contractId: "CA1", method: "dispute_resolution" }) + ) + ); + }); + + it("closes on backdrop click", async () => { + const user = userEvent.setup(); + const onClose = jest.fn(); + render(); + // Click the backdrop overlay (the outermost fixed div) + const backdrop = document.querySelector(".fixed.inset-0") as HTMLElement; + await user.click(backdrop); expect(onClose).toHaveBeenCalledTimes(1); }); - it("announces status changes through an aria-live region", () => { - render(); + it("closes on Escape key press", () => { + const onClose = jest.fn(); + render(); + fireEvent.keyDown(document, { key: "Escape" }); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it("matches snapshot when open within window", () => { + const market = buildMarket(); + const { baseElement } = render( + + ); + expect(document.body.querySelector('[role="dialog"]')).toMatchSnapshot(); + }); - expect(screen.getByRole("status")).toHaveAttribute("aria-live", "polite"); + it("matches snapshot when not visible (outside window)", () => { + const { container } = render( + + ); + expect(container).toMatchSnapshot(); }); }); diff --git a/frontend/components/DisputeModal.tsx b/frontend/components/DisputeModal.tsx index e1cc6436..ced0d9d5 100644 --- a/frontend/components/DisputeModal.tsx +++ b/frontend/components/DisputeModal.tsx @@ -102,7 +102,7 @@ export function DisputeModal({ try { // Call dispute_resolution on-chain const xdr = await buildSorobanInvocation({ - contractId: market.id, + contractId: market.contractAddress, method: "dispute_resolution", args: [await str(reason.trim())], signerAddress: address, diff --git a/frontend/hooks/useClaimRefund.ts b/frontend/hooks/useClaimRefund.ts index 3db01a0e..83ce5d9f 100644 --- a/frontend/hooks/useClaimRefund.ts +++ b/frontend/hooks/useClaimRefund.ts @@ -47,7 +47,7 @@ export function useClaimRefund(): UseClaimRefundResult { // Build and submit claim_refund transaction const xdr = await buildSorobanInvocation({ - contractId: market_id, + contractId: market.contractAddress, method: "claim_refund", args: [await bytes(bet_id)], signerAddress: address, diff --git a/frontend/hooks/useClaimWinnings.ts b/frontend/hooks/useClaimWinnings.ts index f38b2ac1..e020f29f 100644 --- a/frontend/hooks/useClaimWinnings.ts +++ b/frontend/hooks/useClaimWinnings.ts @@ -54,7 +54,7 @@ export function useClaimWinnings(): UseClaimWinningsResult { // Build and submit transaction const xdr = await buildSorobanInvocation({ - contractId: market_id, + contractId: market.contractAddress, method, args: [await bytes(bet_id)], signerAddress: address, diff --git a/frontend/hooks/useCreateMarket.ts b/frontend/hooks/useCreateMarket.ts index daab1b28..9aad5edd 100644 --- a/frontend/hooks/useCreateMarket.ts +++ b/frontend/hooks/useCreateMarket.ts @@ -1,14 +1,9 @@ "use client"; import { useState } from "react"; import { CreateMarketFormData } from "@/components/CreateMarketForm"; -import { - buildSorobanInvocation, - submitTransaction, - decodeScVal, - str, - u64, - address, -} from "@/lib/stellar"; +import { createMarketMeta } from "@/lib/api"; +import { MARKET_FACTORY_CONTRACT_ID } from "@/lib/config"; +import { buildSorobanInvocation, submitTransaction, decodeScVal, toU64ScVal } from "@/lib/stellar"; import { useWallet } from "@/hooks/useWallet"; export interface UseCreateMarketResult { @@ -17,8 +12,29 @@ export interface UseCreateMarketResult { error: Error | null; } +/** Converts an ISO date string to whole Unix seconds, the unit u64 timestamps use. */ +function toUnixSeconds(iso: string): bigint { + return BigInt(Math.floor(new Date(iso).getTime() / 1000)); +} + +/** + * Sends the fighter metadata the contract does not store to the backend. + * The market already exists on-chain at this point, so a failure here is logged + * rather than reported as a failed creation. + */ +async function syncMarketMeta(txHash: string, data: CreateMarketFormData): Promise { + try { + await createMarketMeta({ ...data, txHash }); + } catch (err) { + console.error("Market created on-chain but metadata sync failed", err); + } +} + /** * Builds and submits a create_market() transaction to the MarketFactory contract. + * The contract takes the caller, both fighter names, the oracle and the lock and + * end timestamps. The remaining fighter metadata is sent to the backend once the + * transaction is confirmed. * Signs via the connected wallet and waits for ledger confirmation. * Returns the new market_id (hex string) on success. */ @@ -34,34 +50,33 @@ export function useCreateMarket(): UseCreateMarketResult { if (isNetworkMismatched) { throw new Error("Wallet is connected to the wrong network"); } + if (!MARKET_FACTORY_CONTRACT_ID) { + throw new Error("Market factory contract is not configured"); + } setIsLoading(true); setError(null); try { const xdr = await buildSorobanInvocation({ - contractId: "MARKET_FACTORY_ID", + contractId: MARKET_FACTORY_CONTRACT_ID, method: "create_market", - 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), - ]), + args: [ + address, + data.fighterAName, + data.fighterBName, + data.oracleAddress, + await toU64ScVal(toUnixSeconds(data.bettingEndsAt)), + await toU64ScVal(toUnixSeconds(data.scheduledAt)), + ], signerAddress: address, }); const signedXdr = await signTransaction(xdr); const result = await submitTransaction(signedXdr); - + const marketId = (await decodeScVal(result.returnValue)) as string; + await syncMarketMeta(result.txHash, data); return marketId; } catch (err) { const error = err instanceof Error ? err : new Error("Unknown error"); diff --git a/frontend/hooks/usePlaceBet.ts b/frontend/hooks/usePlaceBet.ts index dff18d53..34ad4c77 100644 --- a/frontend/hooks/usePlaceBet.ts +++ b/frontend/hooks/usePlaceBet.ts @@ -15,7 +15,7 @@ export interface UsePlaceBetResult { * Handles transaction building, fee bump, wallet signing, and submission to Stellar. * Returns the confirmed Bet object on success. */ -export function usePlaceBet(market_id: string): UsePlaceBetResult { +export function usePlaceBet(market_id: string, contractAddress: string): UsePlaceBetResult { const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); const { address, signTransaction, isNetworkMismatched } = useWallet(); @@ -33,7 +33,7 @@ export function usePlaceBet(market_id: string): UsePlaceBetResult { try { const xdr = await buildSorobanInvocation({ - contractId: market_id, + contractId: contractAddress, method: "place_bet", args: await Promise.all([sym(side), i128(amount)]), signerAddress: address, diff --git a/frontend/lib/config.ts b/frontend/lib/config.ts index 455e85b6..32ca5f61 100644 --- a/frontend/lib/config.ts +++ b/frontend/lib/config.ts @@ -19,6 +19,12 @@ const envSchema = z.object({ .default("testnet"), NEXT_PUBLIC_SOROBAN_RPC_URL: z.string().url().optional(), NEXT_PUBLIC_MARKET_CONTRACT_ID: z.string().optional(), + NEXT_PUBLIC_MARKET_FACTORY_CONTRACT_ID: z + .string() + .regex(/^C[A-Z2-7]{55}$/, { + message: "NEXT_PUBLIC_MARKET_FACTORY_CONTRACT_ID must be a Soroban contract address (C...)", + }) + .optional(), NEXT_PUBLIC_SITE_URL: z.string().url().optional(), }); @@ -30,6 +36,7 @@ function loadConfig(): EnvConfig { NEXT_PUBLIC_STELLAR_NETWORK: process.env.NEXT_PUBLIC_STELLAR_NETWORK, NEXT_PUBLIC_SOROBAN_RPC_URL: process.env.NEXT_PUBLIC_SOROBAN_RPC_URL, NEXT_PUBLIC_MARKET_CONTRACT_ID: process.env.NEXT_PUBLIC_MARKET_CONTRACT_ID, + NEXT_PUBLIC_MARKET_FACTORY_CONTRACT_ID: process.env.NEXT_PUBLIC_MARKET_FACTORY_CONTRACT_ID, NEXT_PUBLIC_SITE_URL: process.env.NEXT_PUBLIC_SITE_URL, }); @@ -62,6 +69,8 @@ export const STELLAR_NETWORK: EnvConfig["NEXT_PUBLIC_STELLAR_NETWORK"] = export const SOROBAN_RPC_URL: string | undefined = config.NEXT_PUBLIC_SOROBAN_RPC_URL; export const MARKET_CONTRACT_ID: string | undefined = config.NEXT_PUBLIC_MARKET_CONTRACT_ID; +export const MARKET_FACTORY_CONTRACT_ID: string | undefined = + config.NEXT_PUBLIC_MARKET_FACTORY_CONTRACT_ID; export const SITE_URL: string = config.NEXT_PUBLIC_SITE_URL || (process.env.NEXT_PUBLIC_VERCEL_URL diff --git a/frontend/lib/stellar.ts b/frontend/lib/stellar.ts index a6134a37..93a3a739 100644 --- a/frontend/lib/stellar.ts +++ b/frontend/lib/stellar.ts @@ -50,6 +50,16 @@ export function isSameNetwork(a: string | null | undefined, b: string | null | u return a.toLowerCase() === b.toLowerCase(); } +/** + * How long a built transaction stays valid, in seconds. Kept short so a signed + * XDR that is logged, copied or left in a wallet popup cannot be submitted much + * later against stale market state. + */ +export const TX_TIMEOUT_SECONDS = 120; + +/** User-facing message for a transaction that expired before it was submitted. */ +export const TX_EXPIRED_MESSAGE = "This transaction expired before it was submitted. Please try again."; + export const SOROBAN_RPC_URL = process.env.NEXT_PUBLIC_SOROBAN_RPC_URL ?? "https://soroban-testnet.stellar.org"; @@ -176,6 +186,14 @@ function decodeContractError(error: unknown): unknown { return error; } +/** + * Reports whether the network rejected a transaction because its validity + * window had already closed (`txTooLate`). + */ +function isTransactionTooLate(errorResult?: xdr.TransactionResult): boolean { + return errorResult?.result().switch().name === "txTooLate"; +} + /** * Extracts the raw error string from a failed simulation result. */ @@ -192,6 +210,31 @@ function simulationErrorText(simResult: unknown): string { // ─── FUNCTIONS ──────────────────────────────────────────────────────────────── +type StellarSdk = typeof import("@stellar/stellar-sdk"); + +/** + * Converts a contract call argument into an ScVal. Values that are already + * ScVals (for example a u64 built with an explicit type) pass through unchanged. + */ +function toScVal(sdk: StellarSdk, arg: unknown): xdr.ScVal { + if (arg instanceof sdk.xdr.ScVal) return arg; + if (typeof arg === "string" && (sdk.StrKey.isValidEd25519PublicKey(arg) || sdk.StrKey.isValidContract(arg))) { + return sdk.Address.fromString(arg).toScVal(); + } + return sdk.nativeToScVal(arg as any); +} + +/** + * Encodes an unsigned 64-bit integer, such as a Unix timestamp in seconds, as a + * u64 ScVal. Plain bigints default to i128, so contract arguments declared as u64 + * must be encoded with this helper. + */ +export async function toU64ScVal(value: bigint): Promise { + const { nativeToScVal } = await loadSdk(); + + return nativeToScVal(value, { type: "u64" }); +} + /** * Builds a Soroban contract invocation XDR string ready for wallet signing. * Fetches the current account sequence number from Horizon. @@ -200,16 +243,17 @@ function simulationErrorText(simResult: unknown): string { export async function buildSorobanInvocation( params: SorobanInvokeParams ): Promise { - const { Operation, SorobanRpc, TransactionBuilder, Timeout } = await loadSdk(); + const sdk = await loadSdk(); + const { Contract, SorobanRpc, StrKey, TransactionBuilder } = sdk; + + if (!StrKey.isValidContract(params.contractId)) { + throw new Error(`Invalid contract address: ${params.contractId}`); + } + const server = new SorobanRpc.Server(SOROBAN_RPC_URL, rpcServerOptions()); - const operation = Operation.invokeHostFunction({ - hostFunction: { - functionName: params.method, - args: params.args, - }, - auth: [], - }); + const contract = new Contract(params.contractId); + const operation = contract.call(params.method, ...params.args.map((arg) => toScVal(sdk, arg))); const account = await server.getAccount(params.signerAddress); @@ -219,7 +263,7 @@ export async function buildSorobanInvocation( }); transaction.addOperation(operation); - transaction.setTimeout(Timeout.INFINITE); + transaction.setTimeout(TX_TIMEOUT_SECONDS); const tx = transaction.build(); @@ -235,13 +279,10 @@ export async function buildSorobanInvocation( }); withFee.addOperation(preparedTx.operations[0]); - withFee.setTimeout(Timeout.INFINITE); + withFee.setTimeout(TX_TIMEOUT_SECONDS); - // Returned unsigned on purpose: the wallet adds the real signature, so no - // placeholder signature is attached here. - const finalTx = withFee.build(); - - return finalTx.toXDR(); + // Left unsigned: the connected wallet signs the XDR returned here. + return withFee.build().toXDR(); } throw decodeContractError(new Error(simulationErrorText(simResult))); @@ -271,6 +312,11 @@ export async function submitTransaction( const response = await server.sendTransaction(transaction); if (response.status !== "PENDING") { + if (isTransactionTooLate(response.errorResult)) { + onStatus?.({ status: "failed", txHash, error: TX_EXPIRED_MESSAGE }); + throw new Error(TX_EXPIRED_MESSAGE); + } + const error = `Transaction submission failed with status: ${response.status}`; onStatus?.({ status: "failed", txHash, error }); throw decodeContractError(new Error(error)); diff --git a/frontend/package.json b/frontend/package.json index 8e2cb4af..4aa0e272 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -27,7 +27,8 @@ "next": "14.2.0", "react": "^18.3.0", "react-dom": "^18.3.0", - "recharts": "^2.12.0" + "recharts": "^2.12.0", + "zod": "^3.25.76" }, "devDependencies": { "@next/bundle-analyzer": "14.2.0",