Skip to content
Open
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
1 change: 1 addition & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"test:e2e:ui": "playwright test --ui"
},
"dependencies": {
"@albedo-link/intent": "^0.13.0",
"@lobstrco/signer-extension-api": "^2.1.0",
"@meridian/shared": "workspace:*",
"@meridian/stellar-sdk-helpers": "workspace:*",
Expand Down
252 changes: 252 additions & 0 deletions apps/web/src/__tests__/lib/albedo-wallet.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,252 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";

vi.mock("@albedo-link/intent", () => ({
default: {
publicKey: vi.fn(),
tx: vi.fn(),
},
}));

import albedo from "@albedo-link/intent";
import { AlbedoWallet, __resetAlbedoForTests } from "../../lib/wallet";

const ADDRESS = "GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5";

// Create a fresh instance per-file so we don't share state with the singleton
// `wallet` export (which is FreighterWallet).
const albedoWallet = new AlbedoWallet();

afterEach(() => {
delete (window as unknown as { __E2E_MOCK_WALLET__?: unknown })
.__E2E_MOCK_WALLET__;
});

beforeEach(() => {
vi.clearAllMocks();
window.sessionStorage.clear();
__resetAlbedoForTests();
});

describe("AlbedoWallet — real Albedo path (no mock wallet present)", () => {
it("isInstalled always returns true (Albedo is web-based, no extension)", async () => {
await expect(albedoWallet.isInstalled()).resolves.toBe(true);
// No Albedo API call should be made for an install check
expect(vi.mocked(albedo.publicKey)).not.toHaveBeenCalled();
expect(vi.mocked(albedo.tx)).not.toHaveBeenCalled();
});

// Albedo has no passive permission query without opening the popup.
// isAuthorized must not call publicKey() (which opens the popup) — it
// checks sessionStorage for a key stored by a prior connect().
it("isAuthorized returns true when a key was stored by connect", async () => {
window.sessionStorage.setItem("meridian-albedo-public-key", ADDRESS);
await expect(albedoWallet.isAuthorized()).resolves.toBe(true);
expect(vi.mocked(albedo.publicKey)).not.toHaveBeenCalled();
});

it("isAuthorized returns false when nothing was stored", async () => {
await expect(albedoWallet.isAuthorized()).resolves.toBe(false);
expect(vi.mocked(albedo.publicKey)).not.toHaveBeenCalled();
});

it("isAuthorized never calls publicKey (passive, no prompt)", async () => {
await albedoWallet.isAuthorized();
expect(vi.mocked(albedo.publicKey)).not.toHaveBeenCalled();
});

it("connect returns the public key and remembers it for isAuthorized", async () => {
vi.mocked(albedo.publicKey).mockResolvedValue({
pubkey: ADDRESS,
signed_message: "msg",
signature: "sig",
});
await expect(albedoWallet.connect()).resolves.toBe(ADDRESS);
expect(window.sessionStorage.getItem("meridian-albedo-public-key")).toBe(
ADDRESS
);

await expect(albedoWallet.isAuthorized()).resolves.toBe(true);
});

it("connect throws when publicKey returns no pubkey", async () => {
vi.mocked(albedo.publicKey).mockResolvedValue({
pubkey: "",
signed_message: "",
signature: "",
});
await expect(albedoWallet.connect()).rejects.toThrow(
"Albedo wallet returned no public key"
);
});

it("connect propagates errors thrown by Albedo", async () => {
vi.mocked(albedo.publicKey).mockRejectedValue(new Error("User rejected"));
await expect(albedoWallet.connect()).rejects.toThrow("User rejected");
});

it("connect calls publicKey with an empty object (token auto-generated)", async () => {
vi.mocked(albedo.publicKey).mockResolvedValue({
pubkey: ADDRESS,
signed_message: "msg",
signature: "sig",
});
await albedoWallet.connect();
expect(albedo.publicKey).toHaveBeenCalledWith({});
});

it("sign returns the signed envelope on success", async () => {
vi.mocked(albedo.tx).mockResolvedValue({
signed_envelope_xdr: "SIGNED_XDR",
xdr: "XDR",
tx_hash: "hash",
network: "testnet",
result: {},
});
await expect(albedoWallet.sign("XDR", "passphrase")).resolves.toBe(
"SIGNED_XDR"
);
});

it("sign forwards XDR, mapped network, and submit:false to Albedo", async () => {
vi.mocked(albedo.tx).mockResolvedValue({
signed_envelope_xdr: "SIGNED_XDR",
xdr: "XDR",
tx_hash: "hash",
network: "testnet",
result: {},
});
await albedoWallet.sign("XDR", "Test SDF Network ; September 2015");
expect(albedo.tx).toHaveBeenCalledWith({
xdr: "XDR",
network: "testnet",
submit: false,
});
});

it("sign maps mainnet passphrase to public", async () => {
vi.mocked(albedo.tx).mockResolvedValue({
signed_envelope_xdr: "SIGNED_XDR",
xdr: "XDR",
tx_hash: "hash",
network: "public",
result: {},
});
await albedoWallet.sign(
"XDR",
"Public Global Stellar Network ; September 2015"
);
expect(albedo.tx).toHaveBeenCalledWith({
xdr: "XDR",
network: "public",
submit: false,
});
});

it("sign passes through unknown network strings unchanged", async () => {
vi.mocked(albedo.tx).mockResolvedValue({
signed_envelope_xdr: "SIGNED_XDR",
xdr: "XDR",
tx_hash: "hash",
network: "passphrase",
result: {},
});
await albedoWallet.sign("XDR", "passphrase");
expect(albedo.tx).toHaveBeenCalledWith({
xdr: "XDR",
network: "passphrase",
submit: false,
});
});

it("sign throws when signing is cancelled (no signed_envelope_xdr)", async () => {
vi.mocked(albedo.tx).mockResolvedValue({
signed_envelope_xdr: "",
xdr: "XDR",
tx_hash: "",
network: "testnet",
result: {},
});
await expect(albedoWallet.sign("XDR", "passphrase")).rejects.toThrow(
"Signing cancelled"
);
});

it("sign propagates errors thrown by Albedo", async () => {
vi.mocked(albedo.tx).mockRejectedValue(new Error("User rejected"));
await expect(albedoWallet.sign("XDR", "passphrase")).rejects.toThrow(
"User rejected"
);
});
});

describe("AlbedoWallet — e2e mock wallet path", () => {
function setMockWallet(overrides: {
installed?: boolean;
authorized?: boolean;
address?: string;
sign?: (xdr: string, networkPassphrase: string) => Promise<string>;
}) {
(
window as unknown as { __E2E_MOCK_WALLET__: unknown }
).__E2E_MOCK_WALLET__ = {
installed: true,
authorized: true,
address: ADDRESS,
sign: async () => "MOCK_SIGNED",
...overrides,
};
}

it("short-circuits isInstalled without calling the real API", async () => {
setMockWallet({ installed: false });
await expect(albedoWallet.isInstalled()).resolves.toBe(false);
expect(albedo.publicKey).not.toHaveBeenCalled();
expect(albedo.tx).not.toHaveBeenCalled();
});

it("short-circuits isInstalled true when mock says installed", async () => {
setMockWallet({ installed: true });
await expect(albedoWallet.isInstalled()).resolves.toBe(true);
expect(albedo.publicKey).not.toHaveBeenCalled();
});

it("short-circuits isAuthorized to installed && authorized", async () => {
setMockWallet({ installed: true, authorized: false });
await expect(albedoWallet.isAuthorized()).resolves.toBe(false);

setMockWallet({ installed: false, authorized: true });
await expect(albedoWallet.isAuthorized()).resolves.toBe(false);

setMockWallet({ installed: true, authorized: true });
await expect(albedoWallet.isAuthorized()).resolves.toBe(true);

expect(albedo.publicKey).not.toHaveBeenCalled();
});

it("short-circuits connect to the mock address", async () => {
setMockWallet({ address: ADDRESS });
await expect(albedoWallet.connect()).resolves.toBe(ADDRESS);
expect(albedo.publicKey).not.toHaveBeenCalled();
});

it("short-circuits sign to the mock sign function", async () => {
const sign = vi.fn(async (xdr: string) => `SIGNED:${xdr}`);
setMockWallet({ sign });
await expect(albedoWallet.sign("XDR", "passphrase")).resolves.toBe(
"SIGNED:XDR"
);
expect(sign).toHaveBeenCalledWith("XDR", "passphrase");
expect(albedo.tx).not.toHaveBeenCalled();
});

it("propagates a decline rejection from the mock sign function", async () => {
setMockWallet({
sign: async () => {
throw new Error("User declined access");
},
});
await expect(albedoWallet.sign("XDR", "passphrase")).rejects.toThrow(
"User declined access"
);
});
});
120 changes: 120 additions & 0 deletions apps/web/src/lib/wallet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,48 @@ import {
signTransaction as lobstrSign,
} from "@lobstrco/signer-extension-api";

type AlbedoModule = {
publicKey: (params: Record<string, unknown>) => Promise<{
pubkey: string;
signed_message: string;
signature: string;
}>;
tx: (params: {
xdr: string;
network?: string;
submit?: boolean;
pubkey?: string;
}) => Promise<{
signed_envelope_xdr: string;
xdr: string;
tx_hash: string;
network: string;
result: unknown;
}>;
};

async function getAlbedo(): Promise<AlbedoModule> {
try {
const mod = (await import("@albedo-link/intent")) as unknown as {
default: AlbedoModule;
} & AlbedoModule;
return (mod.default ?? mod) as AlbedoModule;
} catch {
return {
publicKey: async () => {
throw new Error("Albedo not available");
},
tx: async () => {
throw new Error("Albedo not available");
},
};
}
}

// Test-only helper retained for compatibility with tests that call it
// explicitly; no longer needed since getAlbedo no longer caches.
export function __resetAlbedoForTests(): void {}

// Freighter's real API talks to the browser extension via an internal
// postMessage protocol, which isn't practical to fake from outside the app.
// Playwright e2e tests inject this global before the app loads (see
Expand Down Expand Up @@ -185,5 +227,83 @@ export class LobstrWallet implements WalletAdapter {
}
}

// Albedo is a web-based Stellar signer that authorizes through a popup at
// albedo.link. Unlike extension wallets it needs no install, so
// isInstalled() is always true and the popup flow itself is the install
// check. It has no passive "is site authorized" query without prompting, so
// isAuthorized() is treated the same way as for LOBSTR: installed plus a
// public key stored by a prior connect(). The key is kept in sessionStorage
// so a returning session re-validates instead of trusting a stale value.
const ALBEDO_PUBLIC_KEY_STORAGE_KEY = "meridian-albedo-public-key";

function readStoredAlbedoPublicKey(): string | null {
if (typeof window === "undefined") return null;
return window.sessionStorage.getItem(ALBEDO_PUBLIC_KEY_STORAGE_KEY);
}

function storeAlbedoPublicKey(publicKey: string): void {
if (typeof window === "undefined") return;
window.sessionStorage.setItem(ALBEDO_PUBLIC_KEY_STORAGE_KEY, publicKey);
}

function albedoNetworkFromPassphrase(passphrase: string): string {
if (passphrase === "Public Global Stellar Network ; September 2015")
return "public";
if (passphrase === "Test SDF Network ; September 2015") return "testnet";
return passphrase;
}

export class AlbedoWallet implements WalletAdapter {
async isInstalled(): Promise<boolean> {
return withMockWallet(
(mock) => mock.installed,
async () => true
);
}

// Albedo's API has no non-prompting "is site authorized" query. Calling
// publicKey() would open the Albedo popup, which must not happen on every
// tab focus (store/wallet.ts revalidate() runs isAuthorized on mount and
// focus). Treat "installed + a public key stored by a prior connect()" as
// authorized instead.
async isAuthorized(): Promise<boolean> {
return withMockWallet(
(mock) => mock.installed && mock.authorized,
async () => {
const installed = await this.isInstalled();
if (!installed) return false;
return readStoredAlbedoPublicKey() !== null;
}
);
}

async connect(): Promise<string> {
return withMockWallet(
(mock) => mock.address,
async () => {
const albedo = await getAlbedo();
const result = await albedo.publicKey({});
if (!result?.pubkey)
throw new Error("Albedo wallet returned no public key");
storeAlbedoPublicKey(result.pubkey);
return result.pubkey;
}
);
}

async sign(xdr: string, networkPassphrase: string): Promise<string> {
return withMockWallet(
(mock) => mock.sign(xdr, networkPassphrase),
async () => {
const albedo = await getAlbedo();
const network = albedoNetworkFromPassphrase(networkPassphrase);
const result = await albedo.tx({ xdr, network, submit: false });
if (!result?.signed_envelope_xdr) throw new Error("Signing cancelled");
return result.signed_envelope_xdr;
}
);
}
}

// Freighter is the only supported wallet today.
export const wallet: WalletAdapter = new FreighterWallet();
Loading