From d138dab2ce7b707dd87c94870616298476c08449 Mon Sep 17 00:00:00 2001 From: liamscroxx-svg Date: Mon, 19 Oct 2026 05:32:36 +0000 Subject: [PATCH 1/4] fix: #1421 build invocations with Contract.call against the given contract buildSorobanInvocation passed a plain object as the host function and ignored params.contractId. It now builds the operation with new Contract(contractId).call(method, ...args), and arguments that are already ScVals pass through unchanged. Also replaces the nonexistent Timeout.INFINITE with TimeoutInfinite and drops the all-zero placeholder signature, which the SDK rejects as an invalid signature. The wallet signs the returned XDR. --- .../__tests__/lib/stellar.invocation.test.ts | 84 +++++++++++++++++++ frontend/lib/stellar.ts | 53 +++++------- 2 files changed, 107 insertions(+), 30 deletions(-) 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..6129aa3d8 --- /dev/null +++ b/frontend/__tests__/lib/stellar.invocation.test.ts @@ -0,0 +1,84 @@ +import { + Account, + Address, + Keypair, + Operation, + StrKey, + Transaction, + TransactionBuilder, + scValToNative, +} from "@stellar/stellar-sdk"; +import { buildSorobanInvocation, NETWORK_PASSPHRASE } 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("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); + }); +}); diff --git a/frontend/lib/stellar.ts b/frontend/lib/stellar.ts index ec0d8f2f4..c92d4ca2b 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 @@ -147,6 +147,20 @@ 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); +} + /** * Builds a Soroban contract invocation XDR string ready for wallet signing. * Fetches the current account sequence number from Horizon. @@ -155,33 +169,14 @@ function simulationErrorText(simResult: unknown): string { export async function buildSorobanInvocation( params: SorobanInvokeParams ): Promise { - const { Operation, SorobanRpc, TransactionBuilder, Timeout, nativeToScVal, addressToScVal } = - await loadSdk(); + const sdk = await loadSdk(); + const { Contract, SorobanRpc, TransactionBuilder, TimeoutInfinite } = sdk; const server = new SorobanRpc.Server(SOROBAN_RPC_URL, { allowHttp: true, }); - 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; - }), - }, - 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); @@ -191,7 +186,7 @@ export async function buildSorobanInvocation( }); transaction.addOperation(operation); - transaction.setTimeout(Timeout.INFINITE); + transaction.setTimeout(TimeoutInfinite); const tx = transaction.build(); @@ -207,12 +202,10 @@ export async function buildSorobanInvocation( }); withFee.addOperation(preparedTx.operations[0]); - withFee.setTimeout(Timeout.INFINITE); - - const finalTx = withFee.build(); - finalTx.addSignature(params.signerAddress, Buffer.alloc(64).fill(0)); + withFee.setTimeout(TimeoutInfinite); - return finalTx.toXDR(); + // Left unsigned: the connected wallet signs the XDR returned here. + return withFee.build().toXDR(); } throw decodeContractError(new Error(simulationErrorText(simResult))); From f0f570cc46fcc705053be1dce18a45d1ade30b2d Mon Sep 17 00:00:00 2001 From: liamscroxx-svg Date: Mon, 19 Oct 2026 05:32:55 +0000 Subject: [PATCH 2/4] fix: #1422 bound transaction validity to 120s and map expired txs Built transactions used Timeout.INFINITE, so a signed XDR stayed valid indefinitely. They now use TX_TIMEOUT_SECONDS (120). A txTooLate rejection from submission is reported as TX_EXPIRED_MESSAGE, asking the user to try again. --- .../__tests__/lib/stellar.invocation.test.ts | 40 ++++++++++++++++++- frontend/lib/stellar.ts | 29 ++++++++++++-- 2 files changed, 65 insertions(+), 4 deletions(-) diff --git a/frontend/__tests__/lib/stellar.invocation.test.ts b/frontend/__tests__/lib/stellar.invocation.test.ts index 6129aa3d8..da98d343f 100644 --- a/frontend/__tests__/lib/stellar.invocation.test.ts +++ b/frontend/__tests__/lib/stellar.invocation.test.ts @@ -8,7 +8,13 @@ import { TransactionBuilder, scValToNative, } from "@stellar/stellar-sdk"; -import { buildSorobanInvocation, NETWORK_PASSPHRASE } from "@/lib/stellar"; +import { + buildSorobanInvocation, + NETWORK_PASSPHRASE, + submitTransaction, + TX_EXPIRED_MESSAGE, + TX_TIMEOUT_SECONDS, +} from "@/lib/stellar"; const mockServer = { getAccount: jest.fn(), @@ -82,3 +88,35 @@ describe("buildSorobanInvocation encoding", () => { 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/lib/stellar.ts b/frontend/lib/stellar.ts index c92d4ca2b..870356ab5 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"; @@ -131,6 +141,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. */ @@ -170,7 +188,7 @@ export async function buildSorobanInvocation( params: SorobanInvokeParams ): Promise { const sdk = await loadSdk(); - const { Contract, SorobanRpc, TransactionBuilder, TimeoutInfinite } = sdk; + const { Contract, SorobanRpc, TransactionBuilder } = sdk; const server = new SorobanRpc.Server(SOROBAN_RPC_URL, { allowHttp: true, }); @@ -186,7 +204,7 @@ export async function buildSorobanInvocation( }); transaction.addOperation(operation); - transaction.setTimeout(TimeoutInfinite); + transaction.setTimeout(TX_TIMEOUT_SECONDS); const tx = transaction.build(); @@ -202,7 +220,7 @@ export async function buildSorobanInvocation( }); withFee.addOperation(preparedTx.operations[0]); - withFee.setTimeout(TimeoutInfinite); + withFee.setTimeout(TX_TIMEOUT_SECONDS); // Left unsigned: the connected wallet signs the XDR returned here. return withFee.build().toXDR(); @@ -237,6 +255,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)); From 77a942e52a7cad3610a1972ce00a07e0e9a80ee6 Mon Sep 17 00:00:00 2001 From: liamscroxx-svg Date: Mon, 19 Oct 2026 05:34:55 +0000 Subject: [PATCH 3/4] fix: #1420 target the market's contract address for bet and claim calls usePlaceBet, useClaimWinnings, useClaimRefund and DisputeModal passed the market id (a 32-byte hash) as the invocation contract. They now use the market's contractAddress; usePlaceBet takes it as a second argument. buildSorobanInvocation rejects a contractId that is not a C-strkey before building anything. Tests assert the target contract of each invocation. --- .../__tests__/lib/stellar.invocation.test.ts | 13 +++++ frontend/__tests__/useClaimRefund.test.ts | 51 +++++++++++++++++++ frontend/__tests__/useClaimWinnings.test.ts | 10 ++-- frontend/__tests__/usePlaceBet.test.ts | 21 ++++---- frontend/components/BetForm.tsx | 2 +- frontend/components/BettingInterface.tsx | 2 +- frontend/components/DisputeModal.test.tsx | 35 +++++++++++-- frontend/components/DisputeModal.tsx | 2 +- frontend/hooks/useClaimRefund.ts | 2 +- frontend/hooks/useClaimWinnings.ts | 2 +- frontend/hooks/usePlaceBet.ts | 4 +- frontend/lib/stellar.ts | 7 ++- 12 files changed, 125 insertions(+), 26 deletions(-) create mode 100644 frontend/__tests__/useClaimRefund.test.ts diff --git a/frontend/__tests__/lib/stellar.invocation.test.ts b/frontend/__tests__/lib/stellar.invocation.test.ts index da98d343f..18ee1234b 100644 --- a/frontend/__tests__/lib/stellar.invocation.test.ts +++ b/frontend/__tests__/lib/stellar.invocation.test.ts @@ -77,6 +77,19 @@ describe("buildSorobanInvocation encoding", () => { 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]); diff --git a/frontend/__tests__/useClaimRefund.test.ts b/frontend/__tests__/useClaimRefund.test.ts new file mode 100644 index 000000000..799e83b3b --- /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/__tests__/useClaimWinnings.test.ts b/frontend/__tests__/useClaimWinnings.test.ts index 95962f411..6f6d7164f 100644 --- a/frontend/__tests__/useClaimWinnings.test.ts +++ b/frontend/__tests__/useClaimWinnings.test.ts @@ -4,9 +4,9 @@ import { useWallet } from "@/hooks/useWallet"; import * as stellar from "@/lib/stellar"; import * as api from "@/lib/api"; -jest.mock("@/hooks/useWallet"); +jest.mock("@/hooks/useWallet", () => ({ useWallet: jest.fn() })); jest.mock("@/lib/stellar"); -jest.mock("@/lib/api"); +jest.mock("@/lib/api", () => ({ fetchMarketById: jest.fn(), fetchMarketBets: jest.fn() })); const mockUseWallet = useWallet as jest.Mock; const mockBuildSorobanInvocation = stellar.buildSorobanInvocation as jest.Mock; @@ -20,7 +20,7 @@ describe("useClaimWinnings", () => { const mockMarket = { id: "market-1", - contractAddress: "market-1", + contractAddress: "CMARKETCONTRACTADDRESS", fighterA: { name: "Fighter A", record: "10-0", @@ -123,7 +123,7 @@ describe("useClaimWinnings", () => { }); expect(mockBuildSorobanInvocation).toHaveBeenCalledWith({ - contractId: "market-1", + contractId: "CMARKETCONTRACTADDRESS", method: "claim_winnings", args: ["bet-1"], signerAddress: mockAddress, @@ -149,7 +149,7 @@ describe("useClaimWinnings", () => { }); expect(mockBuildSorobanInvocation).toHaveBeenCalledWith({ - contractId: "market-1", + contractId: "CMARKETCONTRACTADDRESS", method: "claim_refund", args: ["bet-1"], signerAddress: mockAddress, diff --git a/frontend/__tests__/usePlaceBet.test.ts b/frontend/__tests__/usePlaceBet.test.ts index ecc6af551..0df7b7bc1 100644 --- a/frontend/__tests__/usePlaceBet.test.ts +++ b/frontend/__tests__/usePlaceBet.test.ts @@ -3,10 +3,11 @@ import { usePlaceBet } from "@/hooks/usePlaceBet"; import { useWallet } from "@/hooks/useWallet"; import * as stellar from "@/lib/stellar"; -jest.mock("@/hooks/useWallet"); +jest.mock("@/hooks/useWallet", () => ({ useWallet: jest.fn() })); jest.mock("@/lib/stellar"); const mockUseWallet = useWallet as jest.Mock; +const CONTRACT_ADDRESS = "CMARKETCONTRACTADDRESS"; const mockBuildSorobanInvocation = stellar.buildSorobanInvocation as jest.Mock; const mockSubmitTransaction = stellar.submitTransaction as jest.Mock; @@ -33,7 +34,7 @@ describe("usePlaceBet", () => { signTransaction: jest.fn(), }); - const { result } = renderHook(() => usePlaceBet("market-1")); + const { result } = renderHook(() => usePlaceBet("market-1", CONTRACT_ADDRESS)); await expect( act(async () => { @@ -52,7 +53,7 @@ describe("usePlaceBet", () => { signTransaction: jest.fn(), }); - const { result } = renderHook(() => usePlaceBet("market-1")); + const { result } = renderHook(() => usePlaceBet("market-1", CONTRACT_ADDRESS)); await expect( act(async () => { @@ -71,14 +72,14 @@ describe("usePlaceBet", () => { returnValue: null, }); - const { result } = renderHook(() => usePlaceBet("market-1")); + const { result } = renderHook(() => usePlaceBet("market-1", CONTRACT_ADDRESS)); await act(async () => { await result.current.placeBet("FighterA", 100n); }); expect(mockBuildSorobanInvocation).toHaveBeenCalledWith({ - contractId: "market-1", + contractId: CONTRACT_ADDRESS, method: "place_bet", args: ["FighterA", 100n], signerAddress: mockAddress, @@ -102,7 +103,7 @@ describe("usePlaceBet", () => { returnValue: null, }); - const { result } = renderHook(() => usePlaceBet("market-1")); + const { result } = renderHook(() => usePlaceBet("market-1", CONTRACT_ADDRESS)); await act(async () => { await result.current.placeBet("FighterA", 100n); @@ -119,7 +120,7 @@ describe("usePlaceBet", () => { returnValue: null, }); - const { result } = renderHook(() => usePlaceBet("market-1")); + const { result } = renderHook(() => usePlaceBet("market-1", CONTRACT_ADDRESS)); await act(async () => { await result.current.placeBet("FighterA", 100n); @@ -136,7 +137,7 @@ describe("usePlaceBet", () => { returnValue: null, }); - const { result } = renderHook(() => usePlaceBet("market-1")); + const { result } = renderHook(() => usePlaceBet("market-1", CONTRACT_ADDRESS)); let bet: any; await act(async () => { @@ -163,7 +164,7 @@ describe("usePlaceBet", () => { returnValue: null, }); - const { result } = renderHook(() => usePlaceBet("market-1")); + const { result } = renderHook(() => usePlaceBet("market-1", CONTRACT_ADDRESS)); expect(result.current.isLoading).toBe(false); @@ -175,7 +176,7 @@ describe("usePlaceBet", () => { }); it("initializes with null error", () => { - const { result } = renderHook(() => usePlaceBet("market-1")); + const { result } = renderHook(() => usePlaceBet("market-1", CONTRACT_ADDRESS)); expect(result.current.error).toBeNull(); }); diff --git a/frontend/components/BetForm.tsx b/frontend/components/BetForm.tsx index b72c1e86c..90fee44d5 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 ece999fef..654243d5d 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.test.tsx b/frontend/components/DisputeModal.test.tsx index 01817d672..42629319a 100644 --- a/frontend/components/DisputeModal.test.tsx +++ b/frontend/components/DisputeModal.test.tsx @@ -2,10 +2,25 @@ import React from "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"; + +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", @@ -70,7 +85,7 @@ describe("DisputeModal", () => { 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 })); @@ -81,13 +96,27 @@ describe("DisputeModal", () => { 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(); diff --git a/frontend/components/DisputeModal.tsx b/frontend/components/DisputeModal.tsx index a46b2792f..82973c56e 100644 --- a/frontend/components/DisputeModal.tsx +++ b/frontend/components/DisputeModal.tsx @@ -97,7 +97,7 @@ export function DisputeModal({ try { // Call dispute_resolution on-chain const xdr = await buildSorobanInvocation({ - contractId: market.id, + contractId: market.contractAddress, method: "dispute_resolution", args: [reason.trim()], signerAddress: address, diff --git a/frontend/hooks/useClaimRefund.ts b/frontend/hooks/useClaimRefund.ts index 6d1b651a0..4c165c668 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: [bet_id], signerAddress: address, diff --git a/frontend/hooks/useClaimWinnings.ts b/frontend/hooks/useClaimWinnings.ts index 3f3bea5dc..eafabf735 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: [bet_id], signerAddress: address, diff --git a/frontend/hooks/usePlaceBet.ts b/frontend/hooks/usePlaceBet.ts index 370c0f19f..2d33e6e54 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: [side, amount], signerAddress: address, diff --git a/frontend/lib/stellar.ts b/frontend/lib/stellar.ts index 870356ab5..f0be2a765 100644 --- a/frontend/lib/stellar.ts +++ b/frontend/lib/stellar.ts @@ -188,7 +188,12 @@ export async function buildSorobanInvocation( params: SorobanInvokeParams ): Promise { const sdk = await loadSdk(); - const { Contract, SorobanRpc, TransactionBuilder } = sdk; + 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, { allowHttp: true, }); From a958f4d31945cebb8cc6d9c935e5eafb2e63eb5f Mon Sep 17 00:00:00 2001 From: liamscroxx-svg Date: Mon, 19 Oct 2026 05:36:35 +0000 Subject: [PATCH 4/4] fix: #1419 call create_market with the configured factory and typed args useCreateMarket sent a placeholder contract id and 11 arguments, while create_market takes (caller, fighter_a, fighter_b, oracle, lock_time, end_time). It now reads the factory id from the validated config (NEXT_PUBLIC_MARKET_FACTORY_CONTRACT_ID), passes the connected address as caller, and encodes both timestamps as u64 via toU64ScVal. Fighter metadata the contract does not store (records, nationality, weight class) is sent to the backend with the tx hash once the transaction is confirmed. A failure there is logged and does not fail the creation, since the market already exists on-chain. zod is declared as a frontend dependency; lib/config.ts imports it but it was only present through the backend. --- .../__tests__/hooks/useCreateMarket.test.ts | 105 +++++++++++++++--- frontend/hooks/useCreateMarket.ts | 44 ++++++-- frontend/lib/config.ts | 9 ++ frontend/lib/stellar.ts | 11 ++ frontend/package.json | 3 +- 5 files changed, 147 insertions(+), 25 deletions(-) diff --git a/frontend/__tests__/hooks/useCreateMarket.test.ts b/frontend/__tests__/hooks/useCreateMarket.test.ts index 40a50075b..116d0936c 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/hooks/useCreateMarket.ts b/frontend/hooks/useCreateMarket.ts index 6826b8192..9aad5edd8 100644 --- a/frontend/hooks/useCreateMarket.ts +++ b/frontend/hooks/useCreateMarket.ts @@ -1,7 +1,9 @@ "use client"; import { useState } from "react"; import { CreateMarketFormData } from "@/components/CreateMarketForm"; -import { buildSorobanInvocation, submitTransaction, decodeScVal } 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 { @@ -10,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. */ @@ -27,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: [ + address, 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, + 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/lib/config.ts b/frontend/lib/config.ts index 7af82ca77..3c560e90f 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(), }); export type EnvConfig = z.infer; @@ -29,6 +35,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, }); if (!parsed.success) { @@ -60,3 +67,5 @@ 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; diff --git a/frontend/lib/stellar.ts b/frontend/lib/stellar.ts index f0be2a765..2b38c5e9c 100644 --- a/frontend/lib/stellar.ts +++ b/frontend/lib/stellar.ts @@ -179,6 +179,17 @@ function toScVal(sdk: StellarSdk, arg: unknown): xdr.ScVal { 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. diff --git a/frontend/package.json b/frontend/package.json index 70d026abc..a98ae5bf2 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -25,7 +25,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",