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 package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"test-storybook": "test-storybook"
},
"dependencies": {
"@albedo-link/intent": "^0.13.0",
"@ckb-ccc/ccc": "^1.1.25",
"@ckb-ccc/connector-react": "^1.0.34",
"@noble/curves": "^1.9.7",
Expand Down
414 changes: 192 additions & 222 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

11 changes: 8 additions & 3 deletions src/components/StellarWalletPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
*
* Albedo and WalletConnect are always shown as available (web-based, no extension needed).
* WalletConnect displays a QR code when selected.
* LOBSTR uses deep-links when the extension is not detected.
*/

import { useState, useEffect } from 'react';
Expand Down Expand Up @@ -164,7 +165,11 @@ export function StellarWalletPicker({ state }: Props) {
<div className="space-y-2">
{WALLET_IDS.map((id) => {
const meta = WALLET_META[id];
const isAvail = available[id] ?? (id === 'albedo' || id === 'walletconnect'); // albedo & walletconnect always available
// Albedo and WalletConnect are web-based — always available.
// LOBSTR is always available via deep-links even without extension.
const isAvail =
available[id] ??
(id === 'albedo' || id === 'walletconnect' || id === 'lobstr');
const isLoading = pending === id;
const isDisabled = !!pending && pending !== id;

Expand Down Expand Up @@ -263,8 +268,8 @@ export function StellarWalletPicker({ state }: Props) {

{/* Footer note */}
<p className="text-[10px] text-[#333333] leading-relaxed pt-1">
Albedo and WalletConnect work in any browser — no extension needed. Other wallets require
their browser extension to be installed.
Albedo, LOBSTR, and WalletConnect work in any browser — no extension needed. Freighter
and xBull require their browser extension to be installed.
</p>
</div>

Expand Down
91 changes: 91 additions & 0 deletions src/wallets/stellar/AlbedoAdapter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
/**
* src/wallets/stellar/AlbedoAdapter.ts
*
* Albedo wallet adapter. Albedo is a web-based Stellar wallet that works
* via a popup flow — no browser extension required. Uses the
* @albedo-link/intent SDK for all operations.
*
* Package: @albedo-link/intent (lazy-loaded)
* Docs: https://github.com/stellar-expert/albedo
*/

import type { StellarWallet, ConnectResult, SignResult, SignOpts } from './types';
import { WalletError } from './types';

export class AlbedoAdapter implements StellarWallet {
readonly id = 'albedo' as const;
readonly name = 'Albedo';
readonly icon = 'https://albedo.link/img/albedo-logo.svg';
readonly installUrl = 'https://albedo.link';

async isAvailable(): Promise<boolean> {
try {
await import(
/* webpackChunkName: "albedo" */
'@albedo-link/intent'
);
return true;
} catch {
return false;
}
}

async connect(): Promise<ConnectResult> {
try {
const albedo = await import(
/* webpackChunkName: "albedo" */
'@albedo-link/intent'
);

const result = await albedo.default.publicKey({});
const pubkey = result.pubkey ?? '';

if (!pubkey) {
throw new WalletError('No public key returned from Albedo', 'CONNECT_FAILED', 'albedo');
}

return {
publicKey: pubkey,
network: 'testnet',
};
} catch (err) {
if (err instanceof WalletError) throw err;
const msg = String(err);
if (msg.toLowerCase().includes('cancel') || msg.toLowerCase().includes('reject')) {
throw new WalletError('Albedo connection rejected by user', 'USER_REJECTED', 'albedo');
}
throw new WalletError(`Albedo connect failed: ${msg}`, 'CONNECT_FAILED', 'albedo');
}
}

async signTransaction(xdr: string, opts: SignOpts = {}): Promise<SignResult> {
try {
const albedo = await import(
/* webpackChunkName: "albedo" */
'@albedo-link/intent'
);

const network = opts.networkPassphrase?.includes('Public') ? 'public' : 'testnet';

const result = await albedo.default.tx({ xdr, network });
const signedXdr = result.signed_envelope_xdr ?? '';

if (!signedXdr) {
throw new WalletError('No signed XDR returned from Albedo', 'SIGN_FAILED', 'albedo');
}

return { signedXdr };
} catch (err) {
if (err instanceof WalletError) throw err;
const msg = String(err);
if (msg.toLowerCase().includes('cancel') || msg.toLowerCase().includes('reject')) {
throw new WalletError('Albedo signing rejected by user', 'USER_REJECTED', 'albedo');
}
throw new WalletError(`Albedo sign failed: ${msg}`, 'SIGN_FAILED', 'albedo');
}
}

async disconnect(): Promise<void> {
// Albedo has no persistent session — the popup flow is stateless.
}
}
179 changes: 179 additions & 0 deletions src/wallets/stellar/LOBSTRAdapter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
/**
* src/wallets/stellar/LOBSTRAdapter.ts
*
* LOBSTR wallet adapter. LOBSTR is primarily a mobile Stellar wallet.
*
* When the LOBSTR Signer browser extension is installed, it injects
* a `window.lobstr` object — the adapter uses that for direct API access.
*
* When the extension is not available, the adapter provides deep-link URLs
* that open the LOBSTR mobile app for signing. These deep-link URLs are
* surfaced via the `getSignUrl()` and `getConnectUrl()` helper methods
* so the UI can display them (e.g. as a QR code or direct link).
*
* LOBSTR can also be used via WalletConnect — that's handled by the
* separate WalletConnectAdapter.
*
* Docs: https://lobstr.co/signer
* Extension: https://github.com/Lobstrco/lobstr-browser-extension
*/

import type { StellarWallet, ConnectResult, SignResult, SignOpts } from './types';
import { WalletError } from './types';

interface LOBSTRDeepLinkOpts {
/** The transaction XDR to sign (base64). */
xdr?: string;
/** The Stellar public key to sign with. */
pubkey?: string;
/** Network: 'testnet' or 'public'. */
network?: string;
/** Callback URL to return to after signing. */
callback?: string;
}

/**
* LOBSTR Signer base URL for deep-link flows.
* The signer opens on mobile or in a web page that bridges to the app.
*/
const LOBSTR_SIGNER_BASE = 'https://lobstr.co/signer';

interface LOBSTRWindow {
lobstr?: {
connect(): Promise<{ publicKey: string }>;
getPublicKey(): Promise<string>;
signTransaction(xdr: string, opts?: { network?: string; publicKey?: string }): Promise<string>;
};
}

declare const window: LOBSTRWindow;

export class LOBSTRAdapter implements StellarWallet {
readonly id = 'lobstr' as const;
readonly name = 'LOBSTR';
readonly icon = 'https://lobstr.co/static/img/lobstr-logo.svg';
readonly installUrl = 'https://lobstr.co/signer';

async isAvailable(): Promise<boolean> {
// Always available via web signer / deep-links even without extension.
// The extension check happens inline in connect()/signTransaction().
return true;
}

/** Check if the LOBSTR Signer extension is available. */
private hasExtension(): boolean {
return typeof window !== 'undefined' && !!window.lobstr;
}

async connect(): Promise<ConnectResult> {
// Extension path
const ext = this.hasExtension() ? window.lobstr : null;
if (ext) {
try {
const result = await ext.connect();
const publicKey = result.publicKey ?? '';

if (!publicKey) {
throw new WalletError(
'No public key returned from LOBSTR',
'CONNECT_FAILED',
'lobstr',
);
}

return { publicKey, network: 'testnet' };
} catch (err) {
if (err instanceof WalletError) throw err;
throw new WalletError(
`LOBSTR connect failed: ${String(err)}`,
'CONNECT_FAILED',
'lobstr',
);
}
}

// Deep-link path — throw a specific error with the URL
// so the UI can display a clickable link.
throw new WalletError(
this.getConnectUrl(),
'CONNECT_FAILED',
'lobstr',
);
}

async signTransaction(xdr: string, opts: SignOpts = {}): Promise<SignResult> {
// Extension path
const ext = this.hasExtension() ? window.lobstr : null;
if (ext) {
try {
const network = opts.networkPassphrase?.includes('Public') ? 'public' : 'testnet';
const signedXdr = await ext.signTransaction(xdr, {
network,
publicKey: opts.publicKey,
});

if (!signedXdr) {
throw new WalletError(
'No signed XDR returned from LOBSTR',
'SIGN_FAILED',
'lobstr',
);
}

return { signedXdr };
} catch (err) {
if (err instanceof WalletError) throw err;
const msg = String(err);
if (msg.toLowerCase().includes('reject')) {
throw new WalletError('LOBSTR signing rejected', 'USER_REJECTED', 'lobstr');
}
throw new WalletError(`LOBSTR sign failed: ${msg}`, 'SIGN_FAILED', 'lobstr');
}
}

// Deep-link path — throw a specific error with the sign URL
const network = opts.networkPassphrase?.includes('Public') ? 'public' : 'testnet';
throw new WalletError(
this.getSignUrl({
xdr,
pubkey: opts.publicKey,
network,
}),
'SIGN_FAILED',
'lobstr',
);
}

async disconnect(): Promise<void> {
// LOBSTR has no programmatic disconnect for the extension.
// Clearing local session state is handled by the useStellarWallet hook.
}

// ── Deep-link URL helpers ──────────────────────────────────────────────────

/**
* Returns the LOBSTR signer URL for connecting.
* The user opens this URL to connect their LOBSTR wallet.
*/
getConnectUrl(): string {
const callback = typeof location !== 'undefined' ? location.origin : '';
return `${LOBSTR_SIGNER_BASE}?type=connect&callback=${encodeURIComponent(callback)}`;
}

/**
* Returns the LOBSTR signer URL for signing a transaction via deep-link.
* The UI can display this as a link or QR code for the user to open on mobile.
*/
getSignUrl(opts: LOBSTRDeepLinkOpts = {}): string {
const params = new URLSearchParams();
if (opts.xdr) params.set('xdr', opts.xdr);
if (opts.pubkey) params.set('pubkey', opts.pubkey);
if (opts.network) params.set('network', opts.network);
if (opts.callback) {
params.set('callback', opts.callback);
} else if (typeof location !== 'undefined') {
params.set('callback', location.origin);
}
return `${LOBSTR_SIGNER_BASE}/tx?${params.toString()}`;
}
}
Loading
Loading