Skip to content
Closed
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
205 changes: 205 additions & 0 deletions features/freighter-connect/__tests__/freighterAccount.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
FREIGHTER_ACCOUNT_POLL_MS,
accountFromListenerEvent,
readFreighterAccountState,
subscribeFreighterAccountChanges,
type FreighterApi

Check failure on line 7 in features/freighter-connect/__tests__/freighterAccount.test.ts

View workflow job for this annotation

GitHub Actions / Lint, test, and build

Module '"@/features/freighter-connect/lib/freighterAccount"' declares 'FreighterApi' locally, but it is not exported.
} from "@/features/freighter-connect/lib/freighterAccount";

const KEY_A = "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF";
const KEY_B = "GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB";

// readFreighterAccountState awaits several wallet calls, so a single microtask
// turn is not enough to observe the initial poll emit. Drain the queue instead.
const flushAsync = async () => {
for (let i = 0; i < 20; i += 1) {
await Promise.resolve();
}
};

describe("readFreighterAccountState", () => {
it("returns the same connected account when wallet state is unchanged", async () => {
const api: FreighterApi = {
isConnected: async () => true,
isAllowed: async () => true,
getPublicKey: async () => KEY_A,
getNetwork: async () => "TESTNET"
};

const first = await readFreighterAccountState(api);
const second = await readFreighterAccountState(api);

expect(first).toEqual({ connected: true, publicKey: KEY_A, walletNetwork: "TESTNET" });
expect(second).toEqual(first);
});

it("returns a changed public key when Freighter switches accounts", async () => {
let currentKey = KEY_A;
const api: FreighterApi = {
isConnected: async () => true,
getPublicKey: async () => currentKey,
getNetwork: async () => "TESTNET"
};

expect(await readFreighterAccountState(api)).toMatchObject({ publicKey: KEY_A });

currentKey = KEY_B;

expect(await readFreighterAccountState(api)).toMatchObject({ publicKey: KEY_B });
});

it("returns disconnected state when permission is revoked", async () => {
const api: FreighterApi = {
isConnected: async () => false,
isAllowed: async () => false,
getPublicKey: async () => KEY_A
};

expect(await readFreighterAccountState(api)).toEqual({
connected: false,
publicKey: "",
walletNetwork: ""
});
});

it("returns disconnected state when getPublicKey throws", async () => {
const api: FreighterApi = {
isConnected: async () => true,
getPublicKey: async () => {
throw new Error("revoked");
}
};

expect(await readFreighterAccountState(api)).toEqual({
connected: false,
publicKey: "",
walletNetwork: ""
});
});
});

describe("accountFromListenerEvent", () => {
it("maps a changed account to connected state", () => {
expect(accountFromListenerEvent(KEY_B)).toEqual({ connected: true, publicKey: KEY_B });
});

it("maps empty account values to disconnected state", () => {
expect(accountFromListenerEvent("")).toEqual({ connected: false, publicKey: "" });
expect(accountFromListenerEvent(" ")).toEqual({ connected: false, publicKey: "" });
});
});

describe("subscribeFreighterAccountChanges", () => {
beforeEach(() => {
vi.useFakeTimers();
});

afterEach(() => {
vi.useRealTimers();
});

it("updates through account listeners and removes them on cleanup", async () => {
let registeredListener: ((account: string) => void) | undefined;
const removeAccountListener = vi.fn();
const api: FreighterApi = {
addAccountListener: (callback) => {

Check failure on line 106 in features/freighter-connect/__tests__/freighterAccount.test.ts

View workflow job for this annotation

GitHub Actions / Lint, test, and build

Parameter 'callback' implicitly has an 'any' type.
registeredListener = callback;
},
removeAccountListener,
getNetwork: async () => "TESTNET"
};
const onUpdate = vi.fn();

const unsubscribe = subscribeFreighterAccountChanges(api, onUpdate);

registeredListener?.(KEY_A);
await Promise.resolve();
registeredListener?.(KEY_B);
await Promise.resolve();

expect(onUpdate).toHaveBeenCalledWith({
connected: true,
publicKey: KEY_A,
walletNetwork: ""
});
expect(onUpdate).toHaveBeenCalledWith({
connected: true,
publicKey: KEY_B,
walletNetwork: ""
});
expect(onUpdate).toHaveBeenCalledWith({
connected: true,
publicKey: KEY_B,
walletNetwork: "TESTNET"
});

unsubscribe();

expect(removeAccountListener).toHaveBeenCalledTimes(1);
expect(removeAccountListener.mock.calls[0][0]).toBe(registeredListener);
});

it("polls for account changes when listeners are unavailable", async () => {
let currentKey = KEY_A;
const api: FreighterApi = {
isConnected: async () => true,
getPublicKey: async () => currentKey,
getNetwork: async () => "TESTNET"
};
const onUpdate = vi.fn();

const unsubscribe = subscribeFreighterAccountChanges(api, onUpdate);

await flushAsync();
expect(onUpdate).toHaveBeenCalledWith({
connected: true,
publicKey: KEY_A,
walletNetwork: "TESTNET"
});

currentKey = KEY_B;
await vi.advanceTimersByTimeAsync(FREIGHTER_ACCOUNT_POLL_MS);

expect(onUpdate).toHaveBeenCalledWith({
connected: true,
publicKey: KEY_B,
walletNetwork: "TESTNET"
});

await vi.advanceTimersByTimeAsync(FREIGHTER_ACCOUNT_POLL_MS);
const callCount = onUpdate.mock.calls.length;

unsubscribe();

await vi.advanceTimersByTimeAsync(FREIGHTER_ACCOUNT_POLL_MS);
expect(onUpdate.mock.calls.length).toBe(callCount);
});

it("polls revoked access back to disconnected state", async () => {
let connected = true;
const api: FreighterApi = {
isConnected: async () => connected,
getPublicKey: async () => {
if (!connected) {
throw new Error("revoked");
}

return KEY_A;
}
};
const onUpdate = vi.fn();

subscribeFreighterAccountChanges(api, onUpdate);

await Promise.resolve();
connected = false;
await vi.advanceTimersByTimeAsync(FREIGHTER_ACCOUNT_POLL_MS);

expect(onUpdate).toHaveBeenCalledWith({
connected: false,
publicKey: "",
walletNetwork: ""
});
});
});
21 changes: 18 additions & 3 deletions features/freighter-connect/hooks/useFreighter.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
"use client";

import { useCallback, useEffect, useState } from "react";
import { subscribeFreighterAccountChanges } from "@/features/freighter-connect/lib/freighterAccount";
import { readWallet, requestAccess } from "@/features/freighter-connect/lib/freighter";
import { readFreighterApi } from "@/features/freighter-connect/schema";
import type {
FreighterErrorCode,
WalletSnapshot
Expand All @@ -27,15 +29,28 @@ export function useFreighter() {

useEffect(() => {
let active = true;
let unsubscribe: (() => void) | undefined;

const syncWallet = () => {
void readWallet().then((result) => {
if (active) setState(toState(result));
});
};

// The extension injects itself asynchronously, so the wallet cannot be
// read during render.
void readWallet().then((result) => {
if (active) setState(toState(result));
});
syncWallet();

const api = readFreighterApi();
if (api.ok) {
unsubscribe = subscribeFreighterAccountChanges(api.value, () => {
syncWallet();
});
}

return () => {
active = false;
unsubscribe?.();
};
}, []);

Expand Down
127 changes: 127 additions & 0 deletions features/freighter-connect/lib/freighterAccount.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import type { FreighterApi } from "@/features/freighter-connect/types";

export interface FreighterAccountState {
connected: boolean;
publicKey: string;
walletNetwork: string;
}

export const FREIGHTER_ACCOUNT_POLL_MS = 2000;

export function accountFromListenerEvent(
account: string
): Pick<FreighterAccountState, "connected" | "publicKey"> {
const publicKey = account.trim();

if (!publicKey) {
return { connected: false, publicKey: "" };
}

return { connected: true, publicKey };
}

export async function readFreighterAccountState(api: FreighterApi): Promise<FreighterAccountState> {
const isConnected = api.isConnected ? await api.isConnected().catch(() => false) : false;
const isAllowed = api.isAllowed ? await api.isAllowed().catch(() => false) : false;
const connected = isConnected || isAllowed;

if (!connected) {
return { connected: false, publicKey: "", walletNetwork: "" };
}

if (!api.getPublicKey) {
return { connected: true, publicKey: "", walletNetwork: "" };
}

let publicKey = "";

try {
publicKey = (await api.getPublicKey()).trim();
} catch {
return { connected: false, publicKey: "", walletNetwork: "" };
}

if (!publicKey) {
return { connected: false, publicKey: "", walletNetwork: "" };
}

const walletNetwork = api.getNetwork ? await api.getNetwork().catch(() => "") : "";

return { connected: true, publicKey, walletNetwork };
}

export function subscribeFreighterAccountChanges(
api: FreighterApi,
onUpdate: (state: FreighterAccountState) => void
): () => void {
let disposed = false;
let lastSnapshot = "";

const emitIfChanged = (state: FreighterAccountState) => {
const snapshot = `${state.connected}:${state.publicKey}:${state.walletNetwork}`;

if (snapshot === lastSnapshot) {
return;
}

lastSnapshot = snapshot;
onUpdate(state);
};

const refreshFromWallet = () => {
if (disposed) {
return;
}

void readFreighterAccountState(api).then((state) => {
if (!disposed) {
emitIfChanged(state);
}
});
};

const listener = (account: string) => {
if (disposed) {
return;
}

const next = accountFromListenerEvent(account);
emitIfChanged({
connected: next.connected,
publicKey: next.publicKey,
walletNetwork: ""
});

if (next.connected && api.getNetwork) {
void api
.getNetwork()
.then((walletNetwork) => {
if (!disposed && next.connected) {
emitIfChanged({
connected: true,
publicKey: next.publicKey,
walletNetwork
});
}
})
.catch(() => {});
}
};

if (api.addAccountListener && api.removeAccountListener) {
api.addAccountListener(listener);

return () => {
disposed = true;
api.removeAccountListener?.(listener);
};
}

refreshFromWallet();
const pollTimer = setInterval(refreshFromWallet, FREIGHTER_ACCOUNT_POLL_MS);

return () => {
disposed = true;
clearInterval(pollTimer);
};
}
2 changes: 2 additions & 0 deletions features/freighter-connect/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ export interface FreighterApi {
setAllowed?: () => Promise<boolean>;
getPublicKey?: () => Promise<string>;
getNetwork?: () => Promise<string>;
addAccountListener?: (callback: (account: string) => void) => void;
removeAccountListener?: (callback: (account: string) => void) => void;
}

export interface WalletSnapshot {
Expand Down
Loading