From 40c6703e685b1ed9df907892f86dfb1e25196cbd Mon Sep 17 00:00:00 2001 From: Wuraola Olaniyan <122721324+OG-wura@users.noreply.github.com> Date: Mon, 31 Aug 2026 10:02:30 +0000 Subject: [PATCH] feat: Implement AlbedoWallet adapter --- apps/web/package.json | 1 + .../src/__tests__/lib/albedo-wallet.test.ts | 252 ++++++++++++++++++ apps/web/src/lib/wallet.ts | 120 +++++++++ pnpm-lock.yaml | 8 + 4 files changed, 381 insertions(+) create mode 100644 apps/web/src/__tests__/lib/albedo-wallet.test.ts diff --git a/apps/web/package.json b/apps/web/package.json index cfd88d14..2fbd2830 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -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:*", diff --git a/apps/web/src/__tests__/lib/albedo-wallet.test.ts b/apps/web/src/__tests__/lib/albedo-wallet.test.ts new file mode 100644 index 00000000..d8661ca5 --- /dev/null +++ b/apps/web/src/__tests__/lib/albedo-wallet.test.ts @@ -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; + }) { + ( + 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" + ); + }); +}); diff --git a/apps/web/src/lib/wallet.ts b/apps/web/src/lib/wallet.ts index 11b93f1c..04f0fbe0 100644 --- a/apps/web/src/lib/wallet.ts +++ b/apps/web/src/lib/wallet.ts @@ -10,6 +10,48 @@ import { signTransaction as lobstrSign, } from "@lobstrco/signer-extension-api"; +type AlbedoModule = { + publicKey: (params: Record) => 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 { + 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 @@ -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 { + 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 { + return withMockWallet( + (mock) => mock.installed && mock.authorized, + async () => { + const installed = await this.isInstalled(); + if (!installed) return false; + return readStoredAlbedoPublicKey() !== null; + } + ); + } + + async connect(): Promise { + 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 { + 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(); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 39b0541e..90714640 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -128,6 +128,9 @@ importers: apps/web: dependencies: + '@albedo-link/intent': + specifier: ^0.13.0 + version: 0.13.0 '@lobstrco/signer-extension-api': specifier: ^2.1.0 version: 2.1.0 @@ -287,6 +290,9 @@ importers: packages: + '@albedo-link/intent@0.13.0': + resolution: {integrity: sha512-A8CBXqGQEBMXhwxNXj5inC6HLjyx5Do7jW99NOFeecYd1nPUq8gfM0tvoNoR8H8JQ11aTl9tyQBuu/+l3xeBnQ==} + '@algolia/abtesting@1.18.1': resolution: {integrity: sha512-aehCadlWOGvrT91KUIZpC0MbB8KBW9yUuvTJFd2xesR7le/IsT4nJUnjCCZ4ZqZCeTcPHPV5mo//fZ5oxcSVYw==} engines: {node: '>= 14.0.0'} @@ -3885,6 +3891,8 @@ packages: snapshots: + '@albedo-link/intent@0.13.0': {} + '@algolia/abtesting@1.18.1': dependencies: '@algolia/client-common': 5.52.1