Skip to content

fix(solana): add support for Mobile Wallet Adapter in config provider #1368

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 15 commits into
base: main
Choose a base branch
from
6 changes: 6 additions & 0 deletions .changeset/tricky-rockets-build.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@ant-design/web3-solana': patch
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

solana 这个包算是新增功能,应该是一个 minor

'@ant-design/web3': patch
---

fix(solana): Support Mobile Wallet Adapter(MWA)
4 changes: 3 additions & 1 deletion packages/common/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ export interface NFTMetadata {
}

export interface ConnectOptions {
connectType?: 'extension' | 'qrCode';
connectType?: 'extension' | 'qrCode' | 'openMobile';
}

export interface UniversalWeb3ProviderInterface {
Expand Down Expand Up @@ -135,6 +135,8 @@ export interface UniversalWeb3ProviderInterface {

export interface Wallet extends WalletMetadata {
_standardWallet?: any;
_isMobileWallet?: boolean;

hasWalletReady?: () => Promise<boolean>;
hasExtensionInstalled?: () => Promise<boolean>;
getQrCode?: () => Promise<{ uri: string }>;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
import React, { useEffect, useState } from 'react';
import { useProvider } from '@ant-design/web3';
import { WalletReadyState } from '@solana/wallet-adapter-base';
import { type ConnectionContextState } from '@solana/wallet-adapter-react';
import { fireEvent } from '@testing-library/react';
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest';

import { MWA_WALLET_NAME } from '../config-provider';
import { SolanaWeb3ConfigProvider } from '../index';
import { xrender } from './utils';

type TestConnection = Partial<ConnectionContextState['connection']>;

const mockSelectFn = vi.fn();
const mockConnectFn = vi.fn();

describe('SolanaWeb3ConfigProvider standard mobile wallet adapter', () => {
beforeEach(() => {
vi.resetAllMocks();
});

afterAll(() => {
vi.resetModules();
});

const mockedData = vi.hoisted(() => {
const mockAddress = '8dQE449ozUAS2XPyvao6hEpkAtGALo1A1q4TApayFfCo';
const mockAddressShort = '8dQE44...FfCo';
const balance = 10002;

const mockedDisconnect = vi.fn();

return {
address: {
value: mockAddress,
short: mockAddressShort,
},
balance,
mockedDisconnect,
};
});

vi.mock('@solana/wallet-adapter-react', async () => {
const originModules = await vi.importActual('@solana/wallet-adapter-react');
const { remember } = await import('./utils');
const mfjs =
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
await vi.importActual<typeof import('@metaplex-foundation/js')>('@metaplex-foundation/js');
const PublicKey = mfjs.PublicKey;

const publicKey = new PublicKey(mockedData.address.value);

const ConnectionProvider: React.FC<React.PropsWithChildren<{ endpoint: string }>> = ({
children,
endpoint,
}) => (
<div>
<div className="endpoint">{endpoint}</div>
{children}
</div>
);

const connectedRef = remember(false);
const currentWalletRef = remember<any>(null);

return {
...originModules,
useWallet: () => {
// provide a state to emit re-render
const [, setConnected] = useState(connectedRef.value);
const [, setCurrentWallet] = useState(currentWalletRef.value);

return {
publicKey,
connected: connectedRef.value,
connect: () => {
mockConnectFn();
connectedRef.value = true;
setConnected(true);
},
select: (_wallet: any) => {
mockSelectFn(_wallet);
currentWalletRef.value = _wallet;
setCurrentWallet(_wallet);
},
disconnect: () => {
mockedData.mockedDisconnect();
},
wallet: currentWalletRef.value,
wallets: [
{
adapter: {
name: MWA_WALLET_NAME,
readyState: WalletReadyState.Installed,
},
},
],
};
},
useConnection: () => {
const connection: TestConnection = {
getBalance: async (_publicKey: any) => {
return mockedData.balance;
},
};

return { connection };
},
ConnectionProvider,
};
});

it('available handle mobile wallet adapter', async () => {
const mockMobileWalletAdapterConnectFn = vi.fn();

const WalletReady = () => {
const { availableWallets, connect } = useProvider();
const [autoAddedReady, setAutoAddedReady] = useState(false);
const [autoAddedExtInstalled, setAddedExtInstalled] = useState(false);

// 1. Mobile Wallet Adapter
const walletNames = availableWallets?.map((v) => v.name).join(', ');

useEffect(() => {
if (availableWallets?.length === 1) {
availableWallets
.at(0)!
.hasWalletReady?.()
.then((v) => {
setAutoAddedReady(v);
});

availableWallets
.at(0)!
.hasExtensionInstalled?.()
.then((v) => {
setAddedExtInstalled(v);
});
}
}, [availableWallets]);

return (
<div>
<div className="wallet-names">{walletNames}</div>
<div className="added-ready">{autoAddedReady ? 'true' : 'false'}</div>
<div className="added-ext-installed">{autoAddedExtInstalled ? 'true' : 'false'}</div>
<button
type="button"
className="connect-wallet-btn"
onClick={() => {
connect?.({
name: MWA_WALLET_NAME,
remark: MWA_WALLET_NAME,
_standardWallet: {
connect: () => {
mockMobileWalletAdapterConnectFn();
},
},
});
}}
>
connect
</button>
</div>
);
};

const App = () => {
return (
<SolanaWeb3ConfigProvider autoAddRegisteredWallets>
<WalletReady />
</SolanaWeb3ConfigProvider>
);
};

const { selector } = xrender(App);

const namesDom = selector('.wallet-names')!;
const readyDom = selector('.added-ready')!;
const extInstalledDom = selector('.added-ext-installed')!;
const connectBtnDom = selector('.connect-wallet-btn')!;

expect(connectBtnDom).not.toBeNull();

// check wallet-connect config can be created
await vi.waitFor(async () => {
expect(namesDom.textContent).toBe(MWA_WALLET_NAME);

expect(readyDom.textContent).toBe('true');

// MWA is not a browser extension
expect(extInstalledDom.textContent).toBe('false');
});

fireEvent.click(connectBtnDom);

await vi.waitFor(() => {
// It will call connect function of MWA directly
expect(mockMobileWalletAdapterConnectFn).toBeCalled();

// In fact, it will never call
expect(mockConnectFn).not.toBeCalled();
expect(mockSelectFn).not.toBeCalled();
});
});
});
24 changes: 24 additions & 0 deletions packages/solana/src/solana-provider/config-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ export interface AntDesignWeb3ConfigProviderProps {
onCurrentChainChange?: (chain?: SolanaChainConfig) => void;
}

export const MWA_WALLET_NAME = 'Mobile Wallet Adapter';

export const AntDesignWeb3ConfigProvider: React.FC<
React.PropsWithChildren<AntDesignWeb3ConfigProviderProps>
> = (props) => {
Expand Down Expand Up @@ -175,11 +177,27 @@ export const AntDesignWeb3ConfigProvider: React.FC<

const providedWalletNames = providedWallets.map((w) => w.name);

// standard wallets
const autoRegisteredWallets = wallets
.filter((w) => !providedWalletNames.includes(w.adapter.name))
.map<Wallet>((w) => {
const adapter = w.adapter;

// MWA is a special case, it's always ready
if (adapter.name === MWA_WALLET_NAME) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

除了 name,还有更可靠的判断方法吗?

return {
name: adapter.name,
icon: adapter.icon,
remark: adapter.name,
_isMobileWallet: true,
_standardWallet: adapter,

hasWalletReady: async () => {
return true;
},
};
}

return {
name: adapter.name,
icon: adapter.icon,
Expand Down Expand Up @@ -228,6 +246,12 @@ export const AntDesignWeb3ConfigProvider: React.FC<
selectWallet(currentWalletName);
}}
connect={async (_wallet, options) => {
// if the wallet is MWA, just call `connect`, it will pop up the mobile wallet immediately
if (_wallet?.name === MWA_WALLET_NAME) {
_wallet._standardWallet?.connect();
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

这个背后实现的逻辑是什么?是基于什么协议实现的?是会打开第三方 app 吗?

return;
}

let resolve: any;
let reject: any;

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { ConnectModal } from '@ant-design/web3';
import { fireEvent, render } from '@testing-library/react';
import type { GetProp } from 'antd';
import { describe, expect, it, vi } from 'vitest';

describe('ConnectModal open Solana Nobile Wallet Adapter(MWA) test', () => {
it('should open external link when clicking wallet on mobile without extension', async () => {
const App = () => {
const testWallet: GetProp<typeof ConnectModal, 'walletList'>[number] = {
name: 'Mobile Wallet Adapter',
remark: 'Solana Mobile Wallet Adapter',
_isMobileWallet: true,
_standardWallet: {},
hasWalletReady: async () => {
return true;
},
};

return <ConnectModal open walletList={[testWallet]} connecting />;
};

const { baseElement } = render(<App />);

// Find and click the wallet item
const walletItem = baseElement.querySelector('.ant-web3-connect-modal-wallet-item');

expect(walletItem).not.toBeNull();
expect(baseElement.querySelector('.ant-web3-connect-modal-ripple-container')).toBeNull();

fireEvent.click(walletItem!);

// Wait for the deeplink to be processed
await vi.waitFor(() => {
// Verify deeplink was opened via window.location.href
// expect(locationHref).toBe('testwallet://connect');
expect(baseElement.querySelector('.ant-web3-connect-modal-ripple-container')).not.toBeNull();
});
});
});
3 changes: 3 additions & 0 deletions packages/web3/src/connect-modal/components/ModalPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,8 @@ const ModalPanel: React.FC<ModalPanelProps> = (props) => {
updatePanelRoute('qrCode', true);
} else if (connectOptions.connectType === 'extension') {
updatePanelRoute('link', true);
} else if (connectOptions.connectType === 'openMobile') {
updatePanelRoute('link', true);
} else {
setPanelRoute('init');
}
Expand All @@ -77,6 +79,7 @@ const ModalPanel: React.FC<ModalPanelProps> = (props) => {
},
[onWalletSelected],
);

const panelRouteBack = React.useCallback(() => {
routeStack.current.pop();
const route = routeStack.current[routeStack.current.length - 1];
Expand Down
28 changes: 22 additions & 6 deletions packages/web3/src/connect-modal/components/WalletList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,20 +65,36 @@ const WalletList: ForwardRefRenderFunction<ConnectModalActionType, WalletListPro
if (hasWalletReady) {
// wallet is ready, call ConnectModal's onWalletSelected
const hasExtensionInstalled = await wallet?.hasExtensionInstalled?.();

// pop up the mobile wallet
if (wallet._isMobileWallet) {
updateSelectedWallet(wallet, {
connectType: 'openMobile',
});
return;
}

// use extension to connect
if (hasExtensionInstalled) {
updateSelectedWallet(wallet, {
connectType: 'extension',
});
} else if (mobile()) {
// open in universal link
}

// open in universal link
else if (mobile()) {
openInUniversalLink(wallet);
} else if (wallet.getQrCode) {
// Extension not installed and can use qr code to connect
}

// Extension not installed and can use qr code to connect
else if (wallet.getQrCode) {
updateSelectedWallet(wallet, {
connectType: 'qrCode',
});
} else {
// use the default connect
}

// use the default connect
else {
updateSelectedWallet(wallet, {});
}
return;
Expand Down
13 changes: 13 additions & 0 deletions packages/web3/src/solana/demos/mobile-wallet-adapter.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import React from 'react';
import { ConnectButton, Connector } from '@ant-design/web3';
import { SolanaWeb3ConfigProvider } from '@ant-design/web3-solana';

export default function App() {
return (
<SolanaWeb3ConfigProvider autoAddRegisteredWallets>
<Connector>
<ConnectButton />
</Connector>
</SolanaWeb3ConfigProvider>
);
}
Loading