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
53 changes: 53 additions & 0 deletions src/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { afterEach, expect, jest, test } from '@jest/globals';
import { addListener as addProviderListener, getDefaultProvider } from '@sats-connect/core';
import wallet from './index';

jest.mock('@sats-connect/core', () => ({
BaseAdapter: class {
request = jest.fn();
},
RpcErrorCode: {
INTERNAL_ERROR: -32603,
USER_REJECTION: -32000,
},
addListener: jest.fn(() => () => {}),
defaultAdapters: {},
getDefaultProvider: jest.fn(() => null),
removeDefaultProvider: jest.fn(),
setDefaultProvider: jest.fn(),
}));

jest.mock('@sats-connect/make-default-provider-config', () => ({
makeDefaultConfig: jest.fn((providers) => ({ providers })),
}));

jest.mock('@sats-connect/ui', () => ({
close: jest.fn(),
loadSelector: jest.fn(),
selectWalletProvider: jest.fn(),
walletClose: jest.fn(),
walletOpen: jest.fn(),
}));

jest.mock('./selectableProviders', () => ({
getSelectableProviders: jest.fn(() => []),
}));

afterEach(() => {
jest.clearAllMocks();
});

test('delegates listeners for a discovered provider to the standard provider API', () => {
jest.mocked(getDefaultProvider).mockReturnValue('sqrl');
const unsubscribe = jest.fn();
jest.mocked(addProviderListener).mockReturnValue(unsubscribe);
const listener = {
eventName: 'disconnect' as const,
cb: jest.fn(),
};

const result = wallet.addListener(listener);

expect(addProviderListener).toHaveBeenCalledWith(listener, 'sqrl');
expect(result).toBe(unsubscribe);
});
16 changes: 6 additions & 10 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@ import {
RpcResult,
SatsConnectAdapter,
SupportedWallet,
addListener as addProviderListener,
defaultAdapters,
getDefaultProvider,
getSupportedWallets,
removeDefaultProvider,
setDefaultProvider,
type AddListener,
Expand All @@ -23,6 +23,7 @@ import {
walletClose,
walletOpen,
} from '@sats-connect/ui';
import { getSelectableProviders } from './selectableProviders';

class Wallet {
private providerId: string | undefined;
Expand All @@ -40,7 +41,7 @@ class Wallet {
}

public async selectProvider() {
const providers = getSupportedWallets();
const providers = getSelectableProviders();

if (providers.length === 0) {
throw new Error('No wallets detected, may want to prompt user to install a wallet.');
Expand Down Expand Up @@ -140,14 +141,9 @@ class Wallet {
// their wallets having been updated. Until we have API versioning for the
// wallet, we can avoid having apps crash by checking whether the adapter
// actually supports `addListener`.
if (!adapter || !new adapter().addListener) {
console.error(
`The wallet provider you are using does not support the addListener method. Please update your wallet provider.`
);
return () => {};
}

return new adapter().addListener(listenerInfo);
return adapter
? new adapter().addListener(listenerInfo)
: addProviderListener(listenerInfo, this.providerId as string);
};
}

Expand Down
130 changes: 130 additions & 0 deletions src/selectableProviders.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import { afterEach, expect, jest, test } from '@jest/globals';
import { getSelectableProviders } from './selectableProviders';

const BUILT_INS = {
xverse: {
id: 'XverseProviders.BitcoinProvider',
name: 'Xverse',
icon: 'data:image/png;base64,eA==',
},
};

jest.mock('@sats-connect/core', () => ({
DefaultAdaptersInfo: {
xverse: {
id: 'XverseProviders.BitcoinProvider',
name: 'Xverse',
icon: 'data:image/png;base64,eA==',
},
},
getProviderById: (id: string) =>
id
.split('.')
.reduce<unknown>(
(value, segment) =>
value && typeof value === 'object'
? (value as Record<string, unknown>)[segment]
: undefined,
globalThis.window
),
getProviders: () => globalThis.window.btc_providers ?? [],
getSupportedWallets: () => [
{
id: 'XverseProviders.BitcoinProvider',
name: 'Xverse',
icon: 'data:image/png;base64,eA==',
isInstalled: false,
},
],
}));

const originalWindow = globalThis.window;

afterEach(() => {
Object.defineProperty(globalThis, 'window', {
value: originalWindow,
configurable: true,
writable: true,
});
});

test('appends installed WBIP004 providers without hardcoding wallet brands', () => {
const sqrl = { request: jest.fn() };
Object.defineProperty(globalThis, 'window', {
value: {
sqrl,
btc_providers: [
{
id: 'sqrl',
name: 'Sqrl',
icon: 'data:image/svg+xml;base64,PHN2Zy8+',
methods: ['getInfo', 'wallet_connect'],
},
],
},
configurable: true,
writable: true,
});

expect(getSelectableProviders()).toEqual([
...Object.values(BUILT_INS).map((provider) => ({
...provider,
isInstalled: false,
})),
{
id: 'sqrl',
name: 'Sqrl',
icon: 'data:image/svg+xml;base64,PHN2Zy8+',
methods: ['getInfo', 'wallet_connect'],
isInstalled: true,
},
]);
});

test('keeps built-in precedence and rejects malformed or unsafe entries', () => {
const builtIn = Object.values(BUILT_INS)[0];
Object.defineProperty(globalThis, 'window', {
value: {
sqrl: { request: jest.fn() },
duplicate: { request: jest.fn() },
btc_providers: [
{ ...builtIn, name: 'Spoofed built-in' },
{
id: 'sqrl',
name: 'Sqrl',
icon: 'data:image/png;base64,AA==',
},
{
id: 'sqrl',
name: 'Duplicate Sqrl',
icon: 'data:image/png;base64,AA==',
},
{
id: '__proto__.polluted',
name: 'Unsafe',
icon: 'data:image/png;base64,AA==',
},
{
id: 'missing',
name: 'Missing provider object',
icon: 'data:image/png;base64,AA==',
},
{
id: 'duplicate',
name: '',
icon: 'https://example.com/icon.png',
},
],
},
configurable: true,
writable: true,
});

const providers = getSelectableProviders();
expect(providers.filter((provider) => provider.id === builtIn.id)).toHaveLength(1);
expect(providers.find((provider) => provider.id === builtIn.id)?.name).toBe(builtIn.name);
expect(providers.filter((provider) => provider.id === 'sqrl')).toHaveLength(1);
expect(providers.map((provider) => provider.id)).not.toContain('__proto__.polluted');
expect(providers.map((provider) => provider.id)).not.toContain('missing');
expect(providers.map((provider) => provider.id)).not.toContain('duplicate');
});
58 changes: 58 additions & 0 deletions src/selectableProviders.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import {
getProviderById,
getProviders,
getSupportedWallets,
type Provider,
type SupportedWallet,
} from '@sats-connect/core';

const BLOCKED_PATH_SEGMENTS = new Set(['__proto__', 'constructor', 'prototype']);
const PROVIDER_PATH_SEGMENT = /^[A-Za-z_$][A-Za-z0-9_$]*$/;

function isSafeProviderPath(id: string): boolean {
const segments = id.split('.');
return (
segments.length > 0 &&
segments.every(
(segment) => PROVIDER_PATH_SEGMENT.test(segment) && !BLOCKED_PATH_SEGMENTS.has(segment)
)
);
}

function isValidDiscoveredProvider(provider: unknown): provider is Provider {
if (!provider || typeof provider !== 'object') return false;
const candidate = provider as Partial<Provider>;
if (
typeof candidate.id !== 'string' ||
!isSafeProviderPath(candidate.id) ||
typeof candidate.name !== 'string' ||
candidate.name.trim().length === 0 ||
typeof candidate.icon !== 'string' ||
!candidate.icon.startsWith('data:image/')
) {
return false;
}
if (
candidate.methods !== undefined &&
(!Array.isArray(candidate.methods) ||
!candidate.methods.every((method) => typeof method === 'string'))
) {
return false;
}
const providerObject = getProviderById(candidate.id) as { request?: unknown } | undefined;
return typeof providerObject?.request === 'function';
}

export function getSelectableProviders(): SupportedWallet[] {
const builtIns = getSupportedWallets();
const knownIds = new Set(builtIns.map((provider) => provider.id));
const discovered = getProviders()
.filter(isValidDiscoveredProvider)
.filter((provider) => {
if (knownIds.has(provider.id)) return false;
knownIds.add(provider.id);
return true;
})
.map((provider) => ({ ...provider, isInstalled: true }));
return [...builtIns, ...discovered];
}