diff --git a/frontend/src/hooks/useMarketplace.ts b/frontend/src/hooks/useMarketplace.ts index 5aa860c..4d2d366 100644 --- a/frontend/src/hooks/useMarketplace.ts +++ b/frontend/src/hooks/useMarketplace.ts @@ -48,7 +48,7 @@ export function useMintTierBot() { export function useListings() { return useQuery({ queryKey: ["listings"], - queryFn: getActiveListings, + queryFn: () => getActiveListings(), refetchInterval: 30000, // Poll every 30 seconds staleTime: 15000, }); diff --git a/frontend/src/lib/__tests__/stellar.test.ts b/frontend/src/lib/__tests__/stellar.test.ts new file mode 100644 index 0000000..643860f --- /dev/null +++ b/frontend/src/lib/__tests__/stellar.test.ts @@ -0,0 +1,133 @@ +/** + * Tests for connectFreighter and simulateContractCall in stellar.ts. + * + * Both @stellar/freighter-api and @stellar/stellar-sdk are fully mocked so the + * tests exercise only the wrapper logic (error normalization, simulation + * success/failure handling). + */ + +// ── @stellar/stellar-sdk mock ─────────────────────────────────────────────── +const mockGetAccount = jest.fn(); +const mockSimulateTransaction = jest.fn(); +const mockIsSimulationError = jest.fn(); +const mockScValToNative = jest.fn(); + +jest.mock("@stellar/stellar-sdk", () => ({ + __esModule: true, + SorobanRpc: { + Server: jest.fn().mockImplementation(() => ({ + getAccount: mockGetAccount, + simulateTransaction: mockSimulateTransaction, + })), + Api: { + isSimulationError: (...args: unknown[]) => mockIsSimulationError(...args), + }, + }, + Contract: jest.fn().mockImplementation(() => ({ + call: jest.fn(() => ({ op: true })), + })), + TransactionBuilder: jest.fn().mockImplementation(() => ({ + addOperation: jest.fn().mockReturnThis(), + setTimeout: jest.fn().mockReturnThis(), + build: jest.fn(() => ({ tx: true })), + })), + scValToNative: (...args: unknown[]) => mockScValToNative(...args), + nativeToScVal: jest.fn(() => ({ scv: true })), + xdr: {}, +})); + +// ── @stellar/freighter-api mock ───────────────────────────────────────────── +jest.mock("@stellar/freighter-api", () => ({ + __esModule: true, + isConnected: jest.fn(), + requestAccess: jest.fn(), + getNetwork: jest.fn(), +})); + +import { + isConnected, + requestAccess, + getNetwork, +} from "@stellar/freighter-api"; +import { connectFreighter, simulateContractCall } from "../stellar"; + +const mockIsConnected = isConnected as jest.Mock; +const mockRequestAccess = requestAccess as jest.Mock; +const mockGetNetwork = getNetwork as jest.Mock; + +beforeEach(() => { + jest.clearAllMocks(); +}); + +describe("connectFreighter", () => { + it("returns publicKey and network on success", async () => { + mockIsConnected.mockResolvedValue({ isConnected: true }); + mockRequestAccess.mockResolvedValue({ address: "GABC123" }); + mockGetNetwork.mockResolvedValue({ + network: "TESTNET", + networkPassphrase: "Test SDF Network ; September 2015", + }); + + const result = await connectFreighter(); + expect(result).toEqual({ publicKey: "GABC123", network: "TESTNET" }); + }); + + it("throws when the extension is not installed", async () => { + mockIsConnected.mockResolvedValue({ isConnected: false }); + await expect(connectFreighter()).rejects.toThrow(/not installed|not be detected/i); + }); + + it("throws a locked-wallet error when access returns a lock error", async () => { + mockIsConnected.mockResolvedValue({ isConnected: true }); + mockRequestAccess.mockResolvedValue({ + address: "", + error: { code: -1, message: "Wallet is locked" }, + }); + await expect(connectFreighter()).rejects.toThrow(/locked/i); + }); + + it("throws a rejection error when the user rejects the request", async () => { + mockIsConnected.mockResolvedValue({ isConnected: true }); + mockRequestAccess.mockResolvedValue({ + address: "", + error: { code: -2, message: "User rejected the request" }, + }); + await expect(connectFreighter()).rejects.toThrow(/rejected/i); + }); +}); + +describe("simulateContractCall", () => { + beforeEach(() => { + mockGetAccount.mockResolvedValue({ accountId: () => "GSRC" }); + }); + + it("returns the decoded native value on success", async () => { + mockSimulateTransaction.mockResolvedValue({ + result: { retval: { xdr: true } }, + }); + mockIsSimulationError.mockReturnValue(false); + mockScValToNative.mockReturnValue(42); + + const value = await simulateContractCall("CCONTRACT", "total_users", [], "GSRC"); + expect(value).toBe(42); + expect(mockScValToNative).toHaveBeenCalledWith({ xdr: true }); + }); + + it("throws when the simulation reports an error", async () => { + mockSimulateTransaction.mockResolvedValue({ error: "boom" }); + mockIsSimulationError.mockReturnValue(true); + + await expect( + simulateContractCall("CCONTRACT", "balance", [], "GSRC") + ).rejects.toThrow(/Simulation failed/i); + }); + + it("throws when there is no return value", async () => { + mockSimulateTransaction.mockResolvedValue({ result: {} }); + mockIsSimulationError.mockReturnValue(false); + + await expect( + simulateContractCall("CCONTRACT", "balance", [], "GSRC") + ).rejects.toThrow(/No return value/i); + }); +}); diff --git a/frontend/src/lib/stellar.ts b/frontend/src/lib/stellar.ts index b5fbc99..9693441 100644 --- a/frontend/src/lib/stellar.ts +++ b/frontend/src/lib/stellar.ts @@ -1,21 +1,16 @@ import { + Contract, SorobanRpc, - nativeToScVal, - Address, TransactionBuilder, scValToNative, xdr, } from "@stellar/stellar-sdk"; -import { signTransaction } from "@stellar/freighter-api"; import { - SOROBAN_RPC_URL, - NETWORK_PASSPHRASE, - TX_TIMEOUT, - BASE_FEE, - POLL_INTERVAL_MS, -} from "./constants"; - -type ScVal = xdr.ScVal; + isConnected as freighterIsConnected, + requestAccess as freighterRequestAccess, + getNetwork as freighterGetNetwork, +} from "@stellar/freighter-api"; +import { SOROBAN_RPC_URL, STELLAR_NETWORK_PASSPHRASE } from "./constants"; /** * Module-level singleton — created once, reused on every subsequent call. @@ -42,133 +37,132 @@ export function getServer(): SorobanRpc.Server { return _server; } -// --------------------------------------------------------------------------- -// ScVal conversion helpers (#126) -// --------------------------------------------------------------------------- - -export function addressToScVal(address: string): ScVal { - return Address.fromString(address).toScVal(); -} - -export function u64ToScVal(value: bigint): ScVal { - return nativeToScVal(value, { type: "u64" }); -} - -export function u32ToScVal(value: number): ScVal { - return nativeToScVal(value, { type: "u32" }); -} - -export function i128ToScVal(value: bigint): ScVal { - return nativeToScVal(value, { type: "i128" }); -} - -export function stringToScVal(value: string): ScVal { - return nativeToScVal(value, { type: "string" }); -} - -export function boolToScVal(value: boolean): ScVal { - return nativeToScVal(value, { type: "bool" }); -} - -// --------------------------------------------------------------------------- -// invokeContractCall (#128) -// --------------------------------------------------------------------------- - -export interface InvokeCallResult { - hash: string; - status: "SUCCESS" | "FAILED"; - result?: unknown; +/** + * Heuristic for turning a Freighter API error (or thrown value) into a + * user-facing message. Freighter v3 returns `{ error: { code, message } }` + * objects rather than throwing, but older paths / the extension bridge can + * still throw, so we handle both. + */ +function describeFreighterError(message: string): string { + const lower = message.toLowerCase(); + if (lower.includes("lock")) { + return "Your Freighter wallet is locked. Please unlock it and try again."; + } + if ( + lower.includes("reject") || + lower.includes("denied") || + lower.includes("declined") || + lower.includes("cancel") + ) { + return "Connection request was rejected in Freighter."; + } + return message; } /** - * Build, simulate, assemble, sign (via Freighter), submit, and poll a - * Soroban contract invocation transaction. + * Trigger the Freighter authorization popup and return the connected wallet's + * public key and network. * - * @param source - The Stellar public key of the transaction source account. - * @param buildOp - A callback that receives a {@link TransactionBuilder} and - * should return it after adding the desired operations. - * @returns The transaction hash and parsed result. + * Throws descriptive {@link Error}s for the common failure modes so the UI can + * catch and display them: + * - Freighter extension not installed / not detected + * - wallet locked + * - user rejected the access request + * + * @returns the connected account's `publicKey` and its `network` label */ -export async function invokeContractCall( - source: string, - buildOp: (builder: TransactionBuilder) => TransactionBuilder, -): Promise { - const server = getServer(); - const account = await server.getAccount(source); - - let tx = buildOp( - new TransactionBuilder(account, { - fee: BASE_FEE, - networkPassphrase: NETWORK_PASSPHRASE, - }), - ) - .setTimeout(TX_TIMEOUT) - .build(); - - const simResult = await server.simulateTransaction(tx); - if (SorobanRpc.Api.isSimulationError(simResult)) { +export async function connectFreighter(): Promise<{ + publicKey: string; + network: string; +}> { + // 1. Detect the extension. `isConnected` reports whether Freighter is + // installed and reachable in the current browser. + let connected: { isConnected: boolean; error?: { message: string } }; + try { + connected = await freighterIsConnected(); + } catch { throw new Error( - `Simulation error: ${simResult.error ?? JSON.stringify(simResult)}`, + "Freighter wallet extension is not installed or could not be detected." ); } - - tx = SorobanRpc.assembleTransaction(tx, simResult).build(); - - const txXdr = tx.toXDR(); - const { signedTxXdr, error: signError } = await signTransaction(txXdr, { - networkPassphrase: NETWORK_PASSPHRASE, - }); - if (signError) { - throw new Error(`Freighter signing error: ${signError}`); + if (connected?.error) { + throw new Error( + "Freighter wallet extension is not installed or could not be detected." + ); } - - const signedTx = TransactionBuilder.fromXDR( - signedTxXdr, - NETWORK_PASSPHRASE, - ); - - const sendResult: any = await server.sendTransaction(signedTx); - if (sendResult.error) { - throw new Error(`Send error: ${sendResult.error}`); + if (!connected?.isConnected) { + throw new Error( + "Freighter wallet extension is not installed or could not be detected." + ); } - const hash: string = sendResult.hash; - const statusEnum = SorobanRpc.Api.GetTransactionStatus; - let getResult: any; - - while (true) { - await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS)); - getResult = await server.getTransaction(hash); - - if ( - getResult.status === statusEnum.SUCCESS || - getResult.status === statusEnum.FAILED - ) { - break; - } + // 2. Request access — this opens the authorization popup. + let access: { address: string; error?: { message: string } }; + try { + access = await freighterRequestAccess(); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + throw new Error(describeFreighterError(msg)); } - - if (getResult.status === statusEnum.FAILED) { - throw new Error(`Transaction ${hash} failed`); + if (access?.error) { + throw new Error(describeFreighterError(access.error.message)); + } + if (!access?.address) { + throw new Error("Freighter did not return a public key."); } - const result = - getResult.result?.retval !== undefined - ? scValToNative(getResult.result.retval) - : undefined; + // 3. Fetch the active network. + let network = ""; + try { + const net = await freighterGetNetwork(); + if (!net?.error && net?.network) { + network = net.network; + } + } catch { + // Network is best-effort; a missing network shouldn't block a successful + // connection. Leave it empty rather than failing the whole flow. + } - return { hash, status: getResult.status as "SUCCESS", result }; + return { publicKey: access.address, network }; } -// --------------------------------------------------------------------------- -// truncateAddress (#129) -// --------------------------------------------------------------------------- - /** - * Shorten a Stellar public key for display, e.g. "GABC...XYZ". - * Returns the full address if it is shorter than the truncated form. + * Read-only contract simulation helper. + * + * Builds a transaction that invokes `method(...args)` on `contractId`, submits + * it to the RPC's `simulateTransaction`, and decodes the return value to a + * native JS value. No signing or submission occurs, so `sourceAddress` only + * needs to be a real (loadable) account — it never signs anything. + * + * @throws Error when the simulation fails or returns no value. */ -export function truncateAddress(address: string): string { - if (address.length <= 10) return address; - return `${address.slice(0, 4)}...${address.slice(-3)}`; +export async function simulateContractCall( + contractId: string, + method: string, + args: xdr.ScVal[], + sourceAddress: string +): Promise { + const server = getServer(); + const contract = new Contract(contractId); + const account = await server.getAccount(sourceAddress); + + const tx = new TransactionBuilder(account, { + fee: "100", + networkPassphrase: STELLAR_NETWORK_PASSPHRASE, + }) + .addOperation(contract.call(method, ...args)) + .setTimeout(30) + .build(); + + const result = await server.simulateTransaction(tx); + + if (SorobanRpc.Api.isSimulationError(result)) { + throw new Error(`Simulation failed for ${method}: ${result.error}`); + } + + if (!result.result?.retval) { + throw new Error(`No return value from simulation of ${method}`); + } + + return scValToNative(result.result.retval); }