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
100 changes: 100 additions & 0 deletions __tests__/horizon.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { describe, it, expect, vi, beforeEach } from "vitest";

const mockLoadAccount = vi.fn();

vi.mock("@stellar/stellar-sdk", () => ({
Horizon: {
Server: class MockServer {
constructor() {
// no-op
}
loadAccount = mockLoadAccount;
},
},
}));

import { fetchAccountBalances, fetchXlmBalance } from "../lib/stellar/horizon";

describe("fetchAccountBalances", () => {
beforeEach(() => {
vi.clearAllMocks();
});

it("returns parsed XLM and token balances", async () => {
mockLoadAccount.mockResolvedValue({
balances: [
{
balance: "100.0000000",
asset_type: "native",
},
{
balance: "50.0000000",
asset_type: "credit_alphanum4",
asset_code: "USDC",
asset_issuer:
"GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN",
},
],
});

const result = await fetchAccountBalances("GABC123", "testnet");

expect(result.accountId).toBe("GABC123");
expect(result.balances).toHaveLength(2);

expect(result.balances[0]).toEqual({
assetType: "native",
assetCode: "XLM",
assetIssuer: null,
balance: "100.0000000",
});

expect(result.balances[1]).toEqual({
assetType: "credit_alphanum4",
assetCode: "USDC",
assetIssuer:
"GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVN",
balance: "50.0000000",
});
});

it("throws on account not found (404)", async () => {
const error = new Error("Not Found");
Object.assign(error, { response: { status: 404 } });
mockLoadAccount.mockRejectedValue(error);

await expect(
fetchAccountBalances("GNOTFOUND", "testnet")
).rejects.toThrow("Account not found: GNOTFOUND");
});

it("throws on network failure", async () => {
mockLoadAccount.mockRejectedValue(new Error("Network error"));

await expect(
fetchAccountBalances("GABC123", "testnet")
).rejects.toThrow("Failed to fetch balances: Network error");
});
});

describe("fetchXlmBalance", () => {
beforeEach(() => {
vi.clearAllMocks();
});

it("returns the native XLM balance", async () => {
mockLoadAccount.mockResolvedValue({
balances: [{ balance: "250.5000000", asset_type: "native" }],
});

const balance = await fetchXlmBalance("GABC123", "testnet");
expect(balance).toBe("250.5000000");
});

it("returns '0' if no native balance found", async () => {
mockLoadAccount.mockResolvedValue({ balances: [] });

const balance = await fetchXlmBalance("GABC123", "testnet");
expect(balance).toBe("0");
});
});
107 changes: 107 additions & 0 deletions __tests__/useFreighter.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { describe, it, expect, vi, beforeEach } from "vitest";

// Mock the wallet module
const mockIsFreighterInstalled = vi.fn();
const mockConnectWallet = vi.fn();
const mockGetPublicKey = vi.fn();

vi.mock("../lib/stellar/wallet", () => ({
isFreighterInstalled: () => mockIsFreighterInstalled(),
connectWallet: () => mockConnectWallet(),
getPublicKey: () => mockGetPublicKey(),
}));

// Minimal React hooks mock for testing outside a component
// We test the logic flow, not React rendering
describe("useFreighter hook logic", () => {
beforeEach(() => {
vi.clearAllMocks();
});

describe("wallet detection", () => {
it("detects when Freighter is installed", async () => {
mockIsFreighterInstalled.mockResolvedValue(true);
mockGetPublicKey.mockResolvedValue(null);

const installed = await mockIsFreighterInstalled();
expect(installed).toBe(true);
});

it("detects when Freighter is not installed", async () => {
mockIsFreighterInstalled.mockResolvedValue(false);

const installed = await mockIsFreighterInstalled();
expect(installed).toBe(false);
});
});

describe("wallet connection", () => {
it("returns public key on successful connect", async () => {
const testKey = "GABC1234567890TESTKEY";
mockConnectWallet.mockResolvedValue(testKey);

const key = await mockConnectWallet();
expect(key).toBe(testKey);
});

it("throws when Freighter is not installed", async () => {
mockConnectWallet.mockRejectedValue(
new Error("Freighter wallet extension is not installed")
);

await expect(mockConnectWallet()).rejects.toThrow(
"Freighter wallet extension is not installed"
);
});

it("throws when user rejects connection", async () => {
mockConnectWallet.mockRejectedValue(
new Error("User rejected wallet connection")
);

await expect(mockConnectWallet()).rejects.toThrow(
"User rejected wallet connection"
);
});
});

describe("state transitions", () => {
it("goes from disconnected to connected on successful connect", async () => {
const testKey = "GABC1234567890TESTKEY";
mockGetPublicKey.mockResolvedValue(null); // initially disconnected
mockConnectWallet.mockResolvedValue(testKey);

// Initial state
let publicKey = await mockGetPublicKey();
expect(publicKey).toBeNull();

// After connect
publicKey = await mockConnectWallet();
expect(publicKey).toBe(testKey);
});

it("goes from connected to disconnected on disconnect", async () => {
const testKey = "GABC1234567890TESTKEY";
mockGetPublicKey.mockResolvedValue(testKey);

let publicKey: string | null = await mockGetPublicKey();
expect(publicKey).toBe(testKey);

// Simulate disconnect (clear state)
publicKey = null;
expect(publicKey).toBeNull();
});

it("restores connection if already allowed", async () => {
const testKey = "GABC1234567890TESTKEY";
mockIsFreighterInstalled.mockResolvedValue(true);
mockGetPublicKey.mockResolvedValue(testKey);

const installed = await mockIsFreighterInstalled();
expect(installed).toBe(true);

const key = await mockGetPublicKey();
expect(key).toBe(testKey);
});
});
});
79 changes: 79 additions & 0 deletions hooks/useFreighter.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,84 @@
"use client";

import { useState, useEffect, useCallback } from "react";
import {
isFreighterInstalled,
connectWallet,
getPublicKey,
} from "@/lib/stellar/wallet";

export interface UseFreighterReturn {
publicKey: string | null;
isConnected: boolean;
isFreighterInstalled: boolean;
isLoading: boolean;
error: string | null;
connect: () => Promise<void>;
disconnect: () => void;
}

export function useFreighter(): UseFreighterReturn {
const [publicKey, setPublicKey] = useState<string | null>(null);
const [installed, setInstalled] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);

// Check installation and restore connection on mount
useEffect(() => {
let cancelled = false;

async function init() {
try {
const extensionInstalled = await isFreighterInstalled();
if (cancelled) return;
setInstalled(extensionInstalled);

if (extensionInstalled) {
const key = await getPublicKey();
if (cancelled) return;
if (key) setPublicKey(key);
}
} catch {
// Silently fail on init — user can connect manually
} finally {
if (!cancelled) setIsLoading(false);
}
}

init();
return () => {
cancelled = true;
};
}, []);

const connect = useCallback(async () => {
setError(null);
setIsLoading(true);
try {
const key = await connectWallet();
setPublicKey(key);
} catch (err) {
const message =
err instanceof Error ? err.message : "Failed to connect wallet";
setError(message);
} finally {
setIsLoading(false);
}
}, []);

const disconnect = useCallback(() => {
setPublicKey(null);
setError(null);
}, []);

return {
publicKey,
isConnected: publicKey !== null,
isFreighterInstalled: installed,
isLoading,
error,
connect,
disconnect,
import { useStellar } from "@/context/StellarContext";

/**
Expand Down
105 changes: 105 additions & 0 deletions lib/stellar/actions/contribute.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import {
Contract,
TransactionBuilder,
Networks,
BASE_FEE,
Address,
nativeToScVal,
rpc,
} from "@stellar/stellar-sdk";
import { signWithFreighter } from "@/lib/stellar/wallet";

const SOROBAN_RPC_URL = "https://soroban-testnet.stellar.org";

export interface ContributeParams {
/** Public key of the contributing roommate */
from: string;
/** Contribution amount (in stroops, i128) */
amount: bigint;
/** Deployed rent escrow contract ID */
contractId: string;
}

export interface ContributeResult {
success: boolean;
txHash: string;
ledger: number;
}

/**
* Build, sign, and submit a contribution transaction to the rent escrow contract.
*/
export async function contribute(
params: ContributeParams
): Promise<ContributeResult> {
const { from, amount, contractId } = params;
const server = new rpc.Server(SOROBAN_RPC_URL);

// Load the source account
const sourceAccount = await server.getAccount(from);

// Build the contract call operation using Contract.call()
const contract = new Contract(contractId);
const operation = contract.call(
"contribute",
nativeToScVal(Address.fromString(from), { type: "address" }),
nativeToScVal(amount, { type: "i128" })
);

// Build the transaction
const transaction = new TransactionBuilder(sourceAccount, {
fee: BASE_FEE,
networkPassphrase: Networks.TESTNET,
})
.addOperation(operation)
.setTimeout(300)
.build();

// Simulate to get the correct footprint and resource fees
const simulated = await server.simulateTransaction(transaction);
if (rpc.Api.isSimulationError(simulated)) {
throw new Error(`Simulation failed: ${simulated.error}`);
}

const assembledTx = rpc.assembleTransaction(
transaction,
simulated
).build();

// Sign with Freighter
const signedXdr = await signWithFreighter(
assembledTx.toXDR(),
Networks.TESTNET
);

// Submit the signed transaction
const signedTx = TransactionBuilder.fromXDR(
signedXdr,
Networks.TESTNET
);

const sendResult = await server.sendTransaction(signedTx);

if (sendResult.status === "ERROR") {
throw new Error(`Transaction submission failed: ${sendResult.status}`);
}

// Poll for confirmation
const txHash = sendResult.hash;
let getResult = await server.getTransaction(txHash);

while (getResult.status === "NOT_FOUND") {
await new Promise((resolve) => setTimeout(resolve, 2000));
getResult = await server.getTransaction(txHash);
}

if (getResult.status === "FAILED") {
throw new Error("Transaction failed on-chain");
}

return {
success: true,
txHash,
ledger: getResult.latestLedger,
};
}
Loading
Loading