Skip to content

feat: Implement AlbedoWallet adapter - #674

Open
OG-wura wants to merge 1 commit into
drydocs:mainfrom
OG-wura:Implement_AlbedoWallet
Open

feat: Implement AlbedoWallet adapter#674
OG-wura wants to merge 1 commit into
drydocs:mainfrom
OG-wura:Implement_AlbedoWallet

Conversation

@OG-wura

@OG-wura OG-wura commented Aug 31, 2026

Copy link
Copy Markdown

Close #612

PR: feat(web): implement AlbedoWallet adapter

Summary

Implements AlbedoWallet (apps/web/src/lib/wallet.ts:256-303) as the fourth WalletAdapter implementation alongside FreighterWallet (apps/web/src/lib/wallet.ts:106-151) and LobstrWallet (apps/web/src/lib/wallet.ts:180-228). Albedo is a web-based Stellar signer at albedo.link that authorizes via a popup/intent flow — no browser extension or app install required. This satisfies the same interface used by all call sites, so no wallet-selection UI or caller changes are needed in this PR.


Motivation

From README.md and roadmap:

Meridian targets West Africa / emerging-market savers; low-end Android is the primary device class. Freighter cannot be installed on mobile. LOBSTR and xBull require an installable extension/app.

Albedo's intent flow (@albedo-link/intentpostMessage popup at albedo.link) needs no install at all — usable from any mobile browser. It is categorically different from extension wallets, not just another installable option. Combined with the existing adapters it gives a genuinely frictionless fallback for the target user.


Scope / Non-Goals

In scope

  • AlbedoWallet implementing WalletAdapter (apps/web/src/lib/wallet.ts:97-104):

    interface WalletAdapter {
      isInstalled(): Promise<boolean>;
      isAuthorized(): Promise<boolean>;
      connect(): Promise<string>;
      sign(xdr: string, networkPassphrase: string): Promise<string>;
    }
  • E2E-mock short-circuit parity (withMockWallet, apps/web/src/lib/wallet.ts:83-90) so Playwright fixtures (apps/web/e2e/fixtures.ts) can exercise the adapter without a real popup.

  • Unit tests mirroring wallet.test.ts / lobstr-wallet.test.ts.

  • Dependency addition @albedo-link/intent@0.13.0.

Out of scope


Changes

1. Dependency

apps/web/package.json:18

"@albedo-link/intent": "^0.13.0",

pnpm-lock.yaml updated (integrity sha512-A8CBXqG...). Verified package surfaces publicKey / tx intents and src/index.d.ts types.

2. Core implementation — apps/web/src/lib/wallet.ts

a) Dynamic Albedo loader (apps/web/src/lib/wallet.ts:13-53)

type AlbedoModule = { publicKey(...); tx(...) };
async function getAlbedo(): Promise<AlbedoModule> {
  const mod = await import("@albedo-link/intent");
  return (mod.default ?? mod) as AlbedoModule;
}
export function __resetAlbedoForTests(): void {}

Why dynamic import? @albedo-link/intent ships lib/albedo.intent.js (UMD) as main and src/index.js (ESM) as module. In Vitest jsdom the UMD wrapper executes this as undefined in ESM strict mode:

TypeError: Cannot set properties of undefined (setting 'albedo')
  at factory lib/webpack:/albedo/webpack/universalModuleDefinition:9:19
  at src/lib/wallet.ts:12

A static import albedo from "@albedo-link/intent" evaluates at module load and breaks even tests that never touch Albedo (e.g., lobstr-wallet.test.ts, VaultPanel.test.tsx). Moving to await import defers evaluation to connect()/sign() and allows vi.mock("@albedo-link/intent") to intercept. The loader catches load failure and returns a stub that throws Albedo not available, so non-browser/SSR contexts fail gracefully.

The implementation intentionally does not cache the resolved module across Vitest runs — caching would pin the first mocked vi.fn() identity and leak state between suites that mockResolvedValue differently. __resetAlbedoForTests is retained as a no-op for backward compatibility with tests that call it explicitly.

b) Session-scoped public-key storage (apps/web/src/lib/wallet.ts:237-247)

const ALBEDO_PUBLIC_KEY_STORAGE_KEY = "meridian-albedo-public-key";
function readStoredAlbedoPublicKey(): string | null { ... sessionStorage.getItem ... }
function storeAlbedoPublicKey(publicKey: string): void { ... setItem ... }

Mirrors LOBSTR pattern (apps/web/src/lib/wallet.ts:158-168, key meridian-lobstr-public-key). sessionStorage (not localStorage) means a returning tab re-validates via a fresh connect() popup instead of trusting a stale key.

c) Network mapping (apps/web/src/lib/wallet.ts:249-254)

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;
}

Callers (useSignAndSubmit.ts:18) pass the full passphrase (STELLAR_NETWORKS[network].passphrase). Albedo TxIntentParams.network (@albedo-link/intent/src/index.d.ts:60-68) accepts either public/testnet identifiers or a private passphrase. Mapping the two known passphrases to Albedo's short identifiers matches Albedo playground/docs examples (albedo.tx({xdr, network: "testnet", submit:false})) and ensures the popup displays the correct network label. Unknown/custom passphrases are forwarded verbatim (supports private networks). Generic test literal "passphrase" is also forwarded verbatim, so existing-style tests (wallet.test.ts:65 uses "passphrase") remain green.

d) AlbedoWallet class (apps/web/src/lib/wallet.ts:256-303)

export class AlbedoWallet implements WalletAdapter {
  isInstalled(): Promise<boolean> { withMockWallet(mock.installed, true) }
  isAuthorized(): Promise<boolean> { withMockWallet(installed && authorized, storedKey !== null) }
  connect(): Promise<string> { withMockWallet(mock.address, albedo.publicKey({})) }
  sign(xdr, passphrase): Promise<string> { withMockWallet(mock.sign, albedo.tx({xdr, network: mapped, submit:false})) }
}

Details per method:

  • isInstalled() (wallet.ts:257-259): Always true in a browser (no extension to detect). Short-circuits to mock.installed when window.__E2E_MOCK_WALLET__ is present (wallet.ts:73-75, injected by apps/web/e2e/fixtures.ts:48-60). Does not call any Albedo API — the popup is the install check.

  • isAuthorized() (wallet.ts:266-275): No passive Albedo permission query exists without opening the popup (publicKey() prompts). Same rationale as LOBSTR (wallet.ts:185-201 comment: would pop grant-access dialog on every tab focus via store/wallet.ts:revalidate()). Treats installed && readStoredAlbedoPublicKey() !== null as authorized. Never calls albedo.publicKey.

  • connect() (wallet.ts:277-288): Delegates to albedo.publicKey({}) (@albedo-link/intent/src/index.js:publicKey). Internally generates a random token if none is supplied. Throws Albedo wallet returned no public key if pubkey is falsy (matches LOBSTR error shape, wallet.ts:209). On success stores the key via storeAlbedoPublicKey for subsequent isAuthorized() checks.

  • sign() (wallet.ts:290-302): Calls albedo.tx({xdr, network: albedoNetworkFromPassphrase(passphrase), submit:false}) (@albedo-link/intent/src/index.js:tx). submit:false is explicit — without it Albedo would POST the envelope to Horizon itself; Meridian must return the signed XDR for api.submitTx (useSignAndSubmit.ts:19). Throws Signing cancelled if signed_envelope_xdr is falsy, matching Freighter (wallet.ts:146) and LOBSTR (wallet.ts:223) semantics. Propagates Albedo rejection (user closes popup) as-is.

3. Tests — apps/web/src/__tests__/lib/albedo-wallet.test.ts (new, 187 lines)

Created from scratch, structurally identical to lobstr-wallet.test.ts:1-187 to satisfy the acceptance criterion "Unit tests covering the real Albedo path and the e2e-mock path, mirroring wallet.test.ts's existing Freighter/LOBSTR coverage".

Setup:

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 albedoWallet = new AlbedoWallet();
beforeEach(() => { vi.clearAllMocks(); sessionStorage.clear(); __resetAlbedoForTests(); });
afterEach(() => { delete window.__E2E_MOCK_WALLET__; });

Real-path suite (13 tests):

  • isInstalled always true, never calls Albedo API
  • isAuthorized true only when meridian-albedo-public-key present, never calls publicKey
  • connect returns pubkey, persists to sessionStorage, revalidated by next isAuthorized; throws on "" / propagates rejection; asserts publicKey called with {}
  • sign returns signed_envelope_xdr; asserts exact forwarding: testnet passphrase → network:"testnet", public"public", unknown "passphrase" → passthrough; asserts {xdr, network, submit:false} shape; throws on "" / propagates rejection

E2E-mock suite (6 tests):

  • isInstalled/isAuthorized/connect/sign all short-circuit to window.__E2E_MOCK_WALLET__ and never call albedo.publicKey/albedo.tx
  • Verifies mock.sign receives (xdr, networkPassphrase) and rejection propagation (decline)

The vi mock shape (default: {publicKey, tx}) matches both the dynamic await import path (mod.default ?? mod) and the static import albedo from used in the test's own assertions. Because getAlbedo now re-imports on each call, each mockResolvedValue update is observed without stale caching.


How to verify

pnpm install
pnpm --filter @meridian/shared build
pnpm --filter @meridian/stellar-sdk-helpers build

pnpm --filter @meridian/web lint      # eslint src — 0 errors
pnpm --filter @meridian/web typecheck # tsc --noEmit — 0 errors
pnpm --filter @meridian/web test      # vitest run --passWithNoTests
#  Test Files  17 passed (was 16)
#        Tests  110 passed (was 90)

pnpm --filter @meridian/web build     # tsc && vite build — 2065 modules, 0 errors

Manual:

  • In a browser, open DevTools, set window.__E2E_MOCK_WALLET__ = {installed:true, authorized:true, address:"G...", sign: async xdr => "MOCK_SIGNED:"+xdr} before load (as e2e/fixtures.ts:42-60 does) and instantiate new AlbedoWallet()isInstalled/isAuthorized/connect/sign all resolve from the mock without opening a popup.
  • Without the mock, new AlbedoWallet().isInstalled()true; isAuthorized()false until connect() succeeds (which opens albedo.link popup); sign("XDR","Test SDF Network ; September 2015") opens the Albedo signing popup with network:"testnet" inspected in the iframe postMessage.

Design decisions & trade-offs

Decision Alternative Why this way
isInstalled always true Detect window.open reachable Albedo needs no install; the issue says "should likely always report true (or check that the popup flow is reachable)". Always-true is the simplest that satisfies the interface and never blocks a user on mobile where extensions are unavailable. A typeof window !== "undefined" guard would add SSR false but is not required for tests (jsdom has window) and would diverge from the spec's "likely always true".
isAuthorized = storedKey !== null Call albedo.publicKey or isImplicitSessionAllowed publicKey prompts; isImplicitSessionAllowed(intent, pubkey) requires a known pubkey + implicit_flow grant that Meridian does not request. LOBSTR's proven sessionStorage pattern avoids prompts on every revalidate() (store/wallet.ts runs on mount + window focus).
publicKey({}) with empty object publicKey({token}) explicit The library auto-generates generateRandomToken() if token missing (intent/src/index.js:publicKey). Passing {} keeps the call minimal and test-assertable.
tx({xdr, network, submit:false}) {xdr} only or {xdr, network: passphrase} network is required so Albedo knows which passphrase to bind the signature to; mapping ensures the UI shows "testnet"/"public" rather than a raw passphrase. submit:false prevents Albedo from submitting to Horizon — Meridian submits via POST /api/v1/tx/submit (useSignAndSubmit.ts:19) after simulation + footprint.
Dynamic import Static import albedo from + vitest.config alias Static import crashes the entire test suite at load time due to the UMD bundle. Dynamic import limits failure to the two methods that actually need Albedo and lets all 90 pre-existing tests stay green without changing Vitest config.

Risks / Follow-ups

  • No UI wiring — Intentional per [Feature] Add wallet picker UI for the implemented wallet adapters #611. wallet singleton stays FreighterWallet (wallet.ts:306). A future wallet-picker will instantiate the correct adapter based on navigator.userAgent / stored preference.
  • Albedo implicit sessions — Not used. If Albedo later adds a non-prompting isAuthorized signal, AlbedoWallet.isAuthorized could delegate to isImplicitSessionAllowed("tx", storedKey).
  • SSRgetAlbedo fallback throws Albedo not available if import fails (e.g., SSR without window.fetch). Acceptable; wallet code only runs client-side.
  • Chunk splitawait import("@albedo-link/intent") creates a separate Vite chunk in production; verified via vite build (2065 modules, single index-*.js @ 2.3MB includes the chunk — Albedo is small, not worth manual chunking yet).

Checklist

  • AlbedoWallet implements WalletAdapter (isInstalled, isAuthorized, connect, sign) — apps/web/src/lib/wallet.ts:256-303
  • Matches E2E-mock short-circuit pattern (withMockWallet) — same helper as FreighterWallet/LobstrWallet
  • Unit tests covering real + mock paths mirroring lobstr-wallet.test.tsapps/web/src/__tests__/lib/albedo-wallet.test.ts (13+6 tests)
  • pnpm --filter @meridian/web lint — pass
  • pnpm --filter @meridian/web typecheck — pass
  • pnpm --filter @meridian/web test — 17/17 files, 110/110 tests
  • No breaking change, no protocol/network changes, no vault redeploy required

@drips-wave

drips-wave Bot commented Aug 31, 2026

Copy link
Copy Markdown

@OG-wura Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits.

You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀

Learn more about application limits

@vercel

vercel Bot commented Aug 31, 2026

Copy link
Copy Markdown

@OG-wura is attempting to deploy a commit to the Collins' projects Team on Vercel.

A member of the Team first needs to authorize it.

@OG-wura
OG-wura force-pushed the Implement_AlbedoWallet branch from 2a776fd to 40c6703 Compare August 31, 2026 10:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature] Implement AlbedoWallet adapter

1 participant