diff --git a/android/walletlib/src/main/java/com/solana/mobilewalletadapter/walletlib/transport/websockets/server/LocalWebSocketServer.java b/android/walletlib/src/main/java/com/solana/mobilewalletadapter/walletlib/transport/websockets/server/LocalWebSocketServer.java index 583c9c4c9..78b863b8a 100644 --- a/android/walletlib/src/main/java/com/solana/mobilewalletadapter/walletlib/transport/websockets/server/LocalWebSocketServer.java +++ b/android/walletlib/src/main/java/com/solana/mobilewalletadapter/walletlib/transport/websockets/server/LocalWebSocketServer.java @@ -89,7 +89,6 @@ public void onOpen(WebSocket conn, ClientHandshake handshake) { final MessageReceiver mr = mScenario.createMessageReceiver(); ws.messageReceiver = mr; mr.receiverConnected(ws); - conn.send(new byte[0]); // send APP_PING } @Override diff --git a/examples/example-web-app/pages/_app.tsx b/examples/example-web-app/pages/_app.tsx index 2dbabe1f5..d6af0ac0a 100644 --- a/examples/example-web-app/pages/_app.tsx +++ b/examples/example-web-app/pages/_app.tsx @@ -28,6 +28,7 @@ registerMwa({ }, authorizationResultCache: createDefaultAuthorizationResultCache(), chain: 'solana:testnet', + remoteHostAuthority: '4.tcp.us-cal-1.ngrok.io:15762', onWalletNotFound: createDefaultWalletNotFoundHandler(), }) diff --git a/js/packages/mobile-wallet-adapter-protocol/src/transact.ts b/js/packages/mobile-wallet-adapter-protocol/src/transact.ts index 40e773201..c26e8615e 100644 --- a/js/packages/mobile-wallet-adapter-protocol/src/transact.ts +++ b/js/packages/mobile-wallet-adapter-protocol/src/transact.ts @@ -455,11 +455,13 @@ export async function transactRemote( try { resolve(await callback(new Proxy(wallet as RemoteMobileWallet, { get(target: RemoteMobileWallet, p: TMethodName) { - if (p === 'terminateSession') { - disposeSocket(); - socket.close(); - return; - } else return target[p] + if (p == 'terminateSession') { + return async function () { + disposeSocket(); + socket.close(); + return; + }; + } else return target[p]; }, }))) } catch (e) { diff --git a/js/packages/wallet-standard-mobile/package.json b/js/packages/wallet-standard-mobile/package.json index f70344043..f93befdbb 100644 --- a/js/packages/wallet-standard-mobile/package.json +++ b/js/packages/wallet-standard-mobile/package.json @@ -41,10 +41,14 @@ "@solana-mobile/mobile-wallet-adapter-protocol-web3js": "^2.1.3", "@solana/wallet-standard-chains": "^1.1.0", "@solana/wallet-standard-features": "^1.2.0", - "@solana/web3.js": "^1.58.0", + "@solana/web3.js": "^1.95.3", "@wallet-standard/base": "^1.0.1", "@wallet-standard/features": "^1.0.3", "@wallet-standard/wallet": "^1.0.1", - "bs58": "^5.0.0" + "bs58": "^5.0.0", + "qrcode": "^1.5.4" + }, + "devDependencies": { + "@types/qrcode": "^1.5.5" } } diff --git a/js/packages/wallet-standard-mobile/src/embedded-modal/controller.ts b/js/packages/wallet-standard-mobile/src/embedded-modal/controller.ts new file mode 100644 index 000000000..76f9bf843 --- /dev/null +++ b/js/packages/wallet-standard-mobile/src/embedded-modal/controller.ts @@ -0,0 +1,50 @@ +import EmbeddedModal from './modal'; + +export default class EmbeddedModalController { + private _modal: EmbeddedModal; + private _connectionStatus: 'not-connected' | 'connecting' | 'connected' = 'not-connected'; + + constructor(title: string) { + this._modal = new EmbeddedModal(title); + } + + async init() { + await this._modal.init(); + this.attachEventListeners(); + } + + private attachEventListeners() { + const connectBtn = document.querySelector('#connect-btn'); + connectBtn?.addEventListener('click', () => this.connect()); + } + + open() { + this._modal.open(); + this.setConnectionStatus('not-connected'); + } + + close() { + this._modal.close(); + this.setConnectionStatus('not-connected'); + } + + private setConnectionStatus(status: 'not-connected' | 'connecting' | 'connected') { + this._connectionStatus = status; + this._modal.setConnectionStatus(status); + } + + private async connect() { + console.log('Connecting...'); + this.setConnectionStatus('connecting'); + + try { + // Simulating connection process + await new Promise((resolve) => setTimeout(resolve, 5000)); + this.setConnectionStatus('connected'); + console.log('Connected!'); + } catch (error) { + console.error('Connection failed:', error); + this.setConnectionStatus('not-connected'); + } + } +} diff --git a/js/packages/wallet-standard-mobile/src/embedded-modal/modal.ts b/js/packages/wallet-standard-mobile/src/embedded-modal/modal.ts new file mode 100644 index 000000000..c6c05e14c --- /dev/null +++ b/js/packages/wallet-standard-mobile/src/embedded-modal/modal.ts @@ -0,0 +1,128 @@ +import QRCode from 'qrcode'; + +import { QRCodeHtml } from './qrcode-html.js'; +import { css } from './styles.js'; + +export default class EmbeddedModal { + private _title: string; + private _root: HTMLElement | null = null; + + constructor(title: string) { + this._title = title; + + // Bind methods to ensure `this` context is correct + this.init = this.init.bind(this); + this.injectQRCodeHTML = this.injectQRCodeHTML.bind(this); + this.open = this.open.bind(this); + this.close = this.close.bind(this); + this.connect = this.connect.bind(this); + + this._root = document.getElementById('mobile-wallet-adapter-embedded-root-ui'); + } + + async init(qrCode: string) { + console.log('Injecting modal'); + this.injectStyles(); + this.injectQRCodeHTML(qrCode); + } + + setConnectionStatus(status: 'not-connected' | 'connecting' | 'connected') { + if (!this._root) return; + + const statuses = ['not-connected', 'connecting', 'connected']; + statuses.forEach((s) => { + const el = this._root!.querySelector(`#status-${s}`); + if (el instanceof HTMLElement) { + el.style.display = s === status ? 'flex' : 'none'; + } + }); + } + + private injectStyles() { + // Check if the styles have already been injected + if (document.getElementById('mobile-wallet-adapter-styles')) { + return; + } + + const styleElement = document.createElement('style'); + styleElement.id = 'mobile-wallet-adapter-styles'; + styleElement.textContent = css; + document.head.appendChild(styleElement); + } + + private async populateQRCode(qrUrl: string) { + const qrcodeContainer = document.getElementById('mobile-wallet-adapter-embedded-modal-qr-code-container'); + if (qrcodeContainer) { + const qrCodeElement = await QRCode.toCanvas(qrUrl, { width: 400 }); + if (qrcodeContainer.firstElementChild !== null) { + qrcodeContainer.replaceChild(qrCodeElement, qrcodeContainer.firstElementChild); + } else qrcodeContainer.appendChild(qrCodeElement); + } else { + console.error('QRCode Container not found'); + } + } + + private injectQRCodeHTML(qrCode: string) { + // Check if the HTML has already been injected + if (document.getElementById('mobile-wallet-adapter-embedded-root-ui')) { + if (!this._root) this._root = document.getElementById('mobile-wallet-adapter-embedded-root-ui'); + this.populateQRCode(qrCode); + return; + } + + // Create a container for the modal + this._root = document.createElement('div'); + this._root.id = 'mobile-wallet-adapter-embedded-root-ui'; + this._root.className = 'mobile-wallet-adapter-embedded-modal'; + this._root.innerHTML = QRCodeHtml; + this._root.style.display = 'none'; + + // Append the modal to the body + document.body.appendChild(this._root); + + // Render the QRCode + this.populateQRCode(qrCode); + + this.attachEventListeners(); + } + + private attachEventListeners() { + if (!this._root) return; + + const closeBtn = this._root.querySelector('#mobile-wallet-adapter-embedded-modal-close'); + const cancelBtn = this._root.querySelector('#cancel-btn'); + const connectBtn = this._root.querySelector('#connect-btn'); + + closeBtn?.addEventListener('click', () => this.close()); + cancelBtn?.addEventListener('click', () => this.close()); + connectBtn?.addEventListener('click', () => this.connect()); + } + + open() { + console.debug('Modal open'); + if (this._root) { + this._root.style.display = 'flex'; + this.setConnectionStatus('not-connected'); // Reset status when opening + } + } + + close() { + console.debug('Modal close'); + if (this._root) { + this._root.style.display = 'none'; + this.setConnectionStatus('not-connected'); // Reset status when closing + } + } + + private connect() { + console.log('Connecting...'); + // Mock connection + this.setConnectionStatus('connecting'); + + // Simulate connection process + setTimeout(() => { + this.setConnectionStatus('connected'); + console.log('Connected!'); + }, 5000); // 5 seconds delay + } +} diff --git a/js/packages/wallet-standard-mobile/src/embedded-modal/qrcode-html.ts b/js/packages/wallet-standard-mobile/src/embedded-modal/qrcode-html.ts new file mode 100644 index 000000000..c7044b9f7 --- /dev/null +++ b/js/packages/wallet-standard-mobile/src/embedded-modal/qrcode-html.ts @@ -0,0 +1,12 @@ +export const QRCodeHtml = ` +
+ +

Scan to connect

+

Use your wallet app to scan the QR Code and connect.

+
+
+`; diff --git a/js/packages/wallet-standard-mobile/src/embedded-modal/styles.ts b/js/packages/wallet-standard-mobile/src/embedded-modal/styles.ts new file mode 100644 index 000000000..0963368f6 --- /dev/null +++ b/js/packages/wallet-standard-mobile/src/embedded-modal/styles.ts @@ -0,0 +1,159 @@ +export const css = ` +.mobile-wallet-adapter-embedded-modal { + display: flex; /* Use flexbox to center content */ + flex-direction: column; + justify-content: center; /* Center horizontally */ + align-items: center; /* Center vertically */ + position: fixed; /* Stay in place */ + z-index: 1; /* Sit on top */ + left: 0; + top: 0; + width: 100%; /* Full width */ + height: 100%; /* Full height */ + background-color: rgba(0,0,0,0.4); /* Black w/ opacity */ +} + +.mobile-wallet-adapter-embedded-modal-content { + background: #10141f; + padding: 20px; + border-radius: 10px; + width: 80%; + max-width: 500px; + text-align: center; + position: relative; + display: flex; + flex-direction: column; + align-items: center; /* Center children horizontally */ +} + +.mobile-wallet-adapter-embedded-modal-subtitle { + color: #D8D8D8; +} + +.mobile-wallet-adapter-embedded-modal-close { + display: flex; + align-items: center; + justify-content: center; + position: absolute; + top: 18px; + right: 18px; + padding: 12px; + cursor: pointer; + background: #1a1f2e; + border: none; + border-radius: 50%; +} + +.mobile-wallet-adapter-embedded-modal-close:focus-visible { + outline-color: white; +} + +.mobile-wallet-adapter-embedded-modal-close svg { + fill: #777; + transition: fill 200ms ease 0s; +} + +.mobile-wallet-adapter-embedded-modal-close:hover svg { + fill: #fff; +} + +.icon-container { + display: flex; + justify-content: center; + margin-bottom: 20px; +} + +.icon { + width: 80px; + height: 80px; + border-radius: 50%; + background-color: #ddd; /* Placeholder for icon background */ +} + +/* Modal Title */ +.mobile-wallet-adapter-embedded-modal-content h1 { + color: white; + font-size: 24px; +} + +.button-group { + display: flex; + width: 100%; + gap: 10px; +} + +.connect-btn, .cancel-btn { + flex: 1; + padding: 12px 20px; + font-size: 16px; + cursor: pointer; + border-radius: 10px; + transition: all 0.3s ease; +} + +.connect-btn { + background-color: #007bff; + color: white; + border: none; +} + +.connect-btn:hover { + background-color: #0056b3; +} + +.cancel-btn { + background-color: transparent; + color: #a0a0a0; + border: 1px solid #a0a0a0; +} + +.cancel-btn:hover { + background-color: rgba(160, 160, 160, 0.1); +} + +/* BT Connection Status */ + +.mobile-wallet-adapter-embedded-modal-connection-status-container { + margin: 20px 0px 20px 0px; +} + +.connection-status { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + margin: 20px 0; +} + +.connection-status p { + margin-top: 10px; + color: #a0a0a0; +} + +.bluetooth-icon, .checkmark-icon { + width: 48px; + height: 48px; +} + +.spinner { + border: 4px solid #f3f3f3; + border-top: 4px solid #3498db; + border-radius: 50%; + width: 40px; + height: 40px; + animation: spin 1s linear infinite; +} + +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +/* QR Code */ + +#mobile-wallet-adapter-embedded-modal-qr-code-container { + width: 500px; + height: 500px; + align-content: center; +} +`; diff --git a/js/packages/wallet-standard-mobile/src/getIsSupported.ts b/js/packages/wallet-standard-mobile/src/getIsSupported.ts index 063d67a02..934da1bdc 100644 --- a/js/packages/wallet-standard-mobile/src/getIsSupported.ts +++ b/js/packages/wallet-standard-mobile/src/getIsSupported.ts @@ -1,4 +1,4 @@ -export default function getIsLocalAssociationSupported() { +export function getIsLocalAssociationSupported() { return ( typeof window !== 'undefined' && window.isSecureContext && @@ -6,3 +6,12 @@ export default function getIsLocalAssociationSupported() { /android/i.test(navigator.userAgent) ); } + +export function getIsRemoteAssociationSupported() { + return ( + typeof window !== 'undefined' && + window.isSecureContext && + typeof document !== 'undefined' && + !/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent) + ); +} \ No newline at end of file diff --git a/js/packages/wallet-standard-mobile/src/initialize.ts b/js/packages/wallet-standard-mobile/src/initialize.ts index 54a60cdf2..b7729e71a 100644 --- a/js/packages/wallet-standard-mobile/src/initialize.ts +++ b/js/packages/wallet-standard-mobile/src/initialize.ts @@ -1,19 +1,28 @@ import { registerWallet } from "@wallet-standard/wallet"; -import { AddressSelector, AuthorizationResultCache, SolanaMobileWalletAdapterWallet } from "./wallet"; +import { + AddressSelector, + AuthorizationResultCache, + LocalSolanaMobileWalletAdapterWallet, + RemoteSolanaMobileWalletAdapterWallet, + SolanaMobileWalletAdapterWallet +} from "./wallet"; import { AppIdentity } from "@solana-mobile/mobile-wallet-adapter-protocol"; import { IdentifierString } from "@wallet-standard/base"; -import getIsLocalAssociationSupported from "./getIsSupported"; +import { getIsLocalAssociationSupported, getIsRemoteAssociationSupported } from "./getIsSupported"; export function registerMwa(config: { addressSelector: AddressSelector; appIdentity: AppIdentity; authorizationResultCache: AuthorizationResultCache; chain: IdentifierString; + remoteHostAuthority?: string; onWalletNotFound: (mobileWalletAdapter: SolanaMobileWalletAdapterWallet) => Promise; }) { if (getIsLocalAssociationSupported()) { - registerWallet(new SolanaMobileWalletAdapterWallet(config)) + registerWallet(new LocalSolanaMobileWalletAdapterWallet(config)) + } else if (getIsRemoteAssociationSupported() && config.remoteHostAuthority !== undefined) { + registerWallet(new RemoteSolanaMobileWalletAdapterWallet({ ...config, remoteHostAuthority: config.remoteHostAuthority })) } else { - // TODO: register remote wallet on desktop envs + // currently not supported (non-Android mobile device) } } \ No newline at end of file diff --git a/js/packages/wallet-standard-mobile/src/styles.css b/js/packages/wallet-standard-mobile/src/styles.css new file mode 100644 index 000000000..5301c680f --- /dev/null +++ b/js/packages/wallet-standard-mobile/src/styles.css @@ -0,0 +1,162 @@ +/* The Modal (background) */ +.mobile-wallet-adapter-embedded-modal { + display: flex; /* Use flexbox to center content */ + flex-direction: column; + justify-content: center; /* Center horizontally */ + align-items: center; /* Center vertically */ + position: fixed; /* Stay in place */ + z-index: 1; /* Sit on top */ + left: 0; + top: 0; + width: 100%; /* Full width */ + height: 100%; /* Full height */ + background-color: rgba(0,0,0,0.4); /* Black w/ opacity */ +} + +/* Modal Content/Box */ +.mobile-wallet-adapter-embedded-modal-content { + background: #10141f; + padding: 20px; + border-radius: 10px; + width: 80%; + max-width: 500px; + text-align: center; + position: relative; + display: flex; + flex-direction: column; + align-items: center; /* Center children horizontally */ +} + +.mobile-wallet-adapter-embedded-modal-subtitle { + color: #D8D8D8; +} + +/* The Close Button */ +.mobile-wallet-adapter-embedded-modal-close { + display: flex; + align-items: center; + justify-content: center; + position: absolute; + top: 18px; + right: 18px; + padding: 12px; + cursor: pointer; + background: #1a1f2e; + border: none; + border-radius: 50%; +} + +.mobile-wallet-adapter-embedded-modal-close:focus-visible { + outline-color: white; +} + +.mobile-wallet-adapter-embedded-modal-close svg { + fill: #777; + transition: fill 200ms ease 0s; +} + +.mobile-wallet-adapter-embedded-modal-close:hover svg { + fill: #fff; +} + +/* Icon Container */ +.icon-container { +display: flex; +justify-content: center; +margin-bottom: 20px; +} + +.icon { +width: 80px; +height: 80px; +border-radius: 50%; +background-color: #ddd; /* Placeholder for icon background */ +} + +/* Modal Title */ +.mobile-wallet-adapter-embedded-modal-content h1 { + color: white; + font-size: 24px; +} + +/* Button Group */ +.button-group { + display: flex; + width: 100%; + gap: 10px; +} + +.connect-btn, .cancel-btn { + flex: 1; + padding: 12px 20px; + font-size: 16px; + cursor: pointer; + border-radius: 10px; + transition: all 0.3s ease; +} + +.connect-btn { + background-color: #007bff; + color: white; + border: none; +} + +.connect-btn:hover { + background-color: #0056b3; +} + +.cancel-btn { + background-color: transparent; + color: #a0a0a0; + border: 1px solid #a0a0a0; +} + +.cancel-btn:hover { + background-color: rgba(160, 160, 160, 0.1); +} + +/* Connection Status */ + +.mobile-wallet-adapter-embedded-modal-connection-status-container { + margin: 20px 0px 20px 0px; +} + +.connection-status { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + margin: 20px 0; + } + + .connection-status p { + margin-top: 10px; + color: #a0a0a0; + } + + .bluetooth-icon, .checkmark-icon { + width: 48px; + height: 48px; + } + + .spinner { + border: 4px solid #f3f3f3; + border-top: 4px solid #3498db; + border-radius: 50%; + width: 40px; + height: 40px; + animation: spin 1s linear infinite; + } + + @keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } + } + + /* QR Code */ + + #mobile-wallet-adapter-embedded-modal-qr-code-container { + width: 500px; + height: 500px; + align-content: center; + } \ No newline at end of file diff --git a/js/packages/wallet-standard-mobile/src/wallet.ts b/js/packages/wallet-standard-mobile/src/wallet.ts index f198da5f6..e069ac162 100644 --- a/js/packages/wallet-standard-mobile/src/wallet.ts +++ b/js/packages/wallet-standard-mobile/src/wallet.ts @@ -21,6 +21,7 @@ import { Transaction as LegacyTransaction, VersionedTransaction } from '@solana/web3.js'; +import EmbeddedDialogModal from './embedded-modal/modal.js'; import { Account, AppIdentity, @@ -47,7 +48,7 @@ import { } from '@wallet-standard/features'; import { icon } from './icon'; import { isVersionedTransaction, MWA_SOLANA_CHAINS } from './solana'; -import { transact, Web3MobileWallet } from '@solana-mobile/mobile-wallet-adapter-protocol-web3js'; +import { transact, transactRemote, Web3MobileWallet, Web3RemoteMobileWallet } from '@solana-mobile/mobile-wallet-adapter-protocol-web3js'; import { fromUint8Array, toUint8Array } from './base64Utils'; import { WalletConnectionError, @@ -74,7 +75,11 @@ export const SolanaMobileWalletAdapterWalletName = 'Mobile Wallet Adapter v2'; const SIGNATURE_LENGTH_IN_BYTES = 64; const DEFAULT_FEATURES = [SolanaSignAndSendTransaction, SolanaSignTransaction, SolanaSignMessage, SolanaSignIn] as const; -export class SolanaMobileWalletAdapterWallet implements Wallet { +export interface SolanaMobileWalletAdapterWallet extends Wallet { + url: string +} + +export class LocalSolanaMobileWalletAdapterWallet implements SolanaMobileWalletAdapterWallet { readonly #listeners: { [E in StandardEventsNames]?: StandardEventsListeners[E][] } = {}; readonly #version = '1.0.0' as const; // wallet-standard version readonly #name = SolanaMobileWalletAdapterWalletName; @@ -251,9 +256,6 @@ export class SolanaMobileWalletAdapterWallet implements Wallet { this.#handleAuthorizationResult(authorizationResult), ]); - // await this.#authorizationResultCache.set(authorizationResult); - // await this.handleAuthorizationResult(authorizationResult); - return authorizationResult; }); } catch (e) { @@ -509,3 +511,457 @@ export class SolanaMobileWalletAdapterWallet implements Wallet { } } +export class RemoteSolanaMobileWalletAdapterWallet implements SolanaMobileWalletAdapterWallet { + readonly #listeners: { [E in StandardEventsNames]?: StandardEventsListeners[E][] } = {}; + readonly #version = '1.0.0' as const; // wallet-standard version + readonly #name = SolanaMobileWalletAdapterWalletName; + readonly #url = 'https://solanamobile.com/wallets'; + readonly #icon = icon; + + // #accounts: [MobileWalletAccount] | [] = []; + + #addressSelector: AddressSelector; + #appIdentity: AppIdentity; + #authorizationResult: AuthorizationResult | undefined; + #authorizationResultCache: AuthorizationResultCache; + #connecting = false; + /** + * Every time the connection is recycled in some way (eg. `disconnect()` is called) + * increment this and use it to make sure that `transact` calls from the previous + * 'generation' don't continue to do work and throw exceptions. + */ + #connectionGeneration = 0; + #chain: IdentifierString; + #onWalletNotFound: (mobileWalletAdapter: SolanaMobileWalletAdapterWallet) => Promise; + #selectedAddress: Base64EncodedAddress | undefined; + #hostAuthority: string; + #wallet: Web3RemoteMobileWallet | undefined; + + get version() { + return this.#version; + } + + get name() { + return this.#name; + } + + get url() { + return this.#url + } + + get icon() { + return this.#icon; + } + + get chains() { + return MWA_SOLANA_CHAINS.slice(); + } + + get features(): StandardConnectFeature & + StandardDisconnectFeature & + StandardEventsFeature & + SolanaSignAndSendTransactionFeature & + SolanaSignTransactionFeature & + SolanaSignMessageFeature & + SolanaSignInFeature { + return { + [StandardConnect]: { + version: '1.0.0', + connect: this.#connect, + }, + [StandardDisconnect]: { + version: '1.0.0', + disconnect: this.#disconnect, + }, + [StandardEvents]: { + version: '1.0.0', + on: this.#on, + }, + // TODO: signAndSendTransaction was an optional feature in MWA 1.x. + // this should be omitted here and only added after confirming wallet support via getCapabilities + // Note: dont forget to emit the StandardEvent 'change' for the updated feature list + [SolanaSignAndSendTransaction]: { + version: '1.0.0', + supportedTransactionVersions: ['legacy', 0], + signAndSendTransaction: this.#signAndSendTransaction, + }, + // TODO: signTransaction is an optional, deprecated feature in MWA 2.0. + // this should be omitted here and only added after confirming wallet support via getCapabilities + // Note: dont forget to emit the StandardEvent 'change' for the updated feature list + [SolanaSignTransaction]: { + version: '1.0.0', + supportedTransactionVersions: ['legacy', 0], + signTransaction: this.#signTransaction, + }, + [SolanaSignMessage]: { + version: '1.0.0', + signMessage: this.#signMessage, + }, + [SolanaSignIn]: { + version: '1.0.0', + signIn: this.#signIn, + }, + }; + } + + get accounts() { + return this.#authorizationResult?.accounts as WalletAccount[] ?? []; + } + + constructor(config: { + addressSelector: AddressSelector; + appIdentity: AppIdentity; + authorizationResultCache: AuthorizationResultCache; + chain: IdentifierString; + remoteHostAuthority: string; + onWalletNotFound: (mobileWalletAdapter: SolanaMobileWalletAdapterWallet) => Promise; + }) { + this.#authorizationResultCache = config.authorizationResultCache; + this.#addressSelector = config.addressSelector; + this.#appIdentity = config.appIdentity; + this.#chain = config.chain; + this.#hostAuthority = config.remoteHostAuthority; + this.#onWalletNotFound = config.onWalletNotFound; + } + + get connected(): boolean { + return !!this.#authorizationResult; + } + + // not needed anymore (doesn't do anything) but leaving for now + #runWithGuard = async (callback: () => Promise) => { + try { + return await callback(); + } catch (e: any) { + throw e; + } + } + + #on: StandardEventsOnMethod = (event, listener) => { + this.#listeners[event]?.push(listener) || (this.#listeners[event] = [listener]); + return (): void => this.#off(event, listener); + }; + + #emit(event: E, ...args: Parameters): void { + // eslint-disable-next-line prefer-spread + this.#listeners[event]?.forEach((listener) => listener.apply(null, args)); + } + + #off(event: E, listener: StandardEventsListeners[E]): void { + this.#listeners[event] = this.#listeners[event]?.filter((existingListener) => listener !== existingListener); + } + + #connect: StandardConnectMethod = async ({ silent } = {}) => { + if (this.#connecting || this.connected) { + return { accounts: this.accounts }; + } + await this.#runWithGuard(async () => { + this.#connecting = true; + try { + await this.#performAuthorization(); + } catch (e) { + throw new WalletConnectionError((e instanceof Error && e.message) || 'Unknown error', e); + } finally { + this.#connecting = false; + } + }); + + return { accounts: this.accounts }; + } + + #performAuthorization = async (signInPayload?: SignInPayload) => { + try { + const cachedAuthorizationResult = await this.#authorizationResultCache.get(); + if (cachedAuthorizationResult) { + // TODO: Evaluate whether there's any threat to not `awaiting` this expression + this.#handleAuthorizationResult(cachedAuthorizationResult); + return cachedAuthorizationResult; + } + if (this.#wallet) this.#wallet = undefined; + return await this.#transact(async (wallet) => { + this.#wallet = wallet; + const mwaAuthorizationResult = await wallet.authorize({ + chain: this.#chain, + identity: this.#appIdentity, + sign_in_payload: signInPayload, + }); + + const accounts = this.#accountsToWalletStandardAccounts(mwaAuthorizationResult.accounts) + const authorizationResult = { ...mwaAuthorizationResult, accounts}; + // TODO: Evaluate whether there's any threat to not `awaiting` this expression + Promise.all([ + this.#authorizationResultCache.set(authorizationResult), + this.#handleAuthorizationResult(authorizationResult), + ]); + + return authorizationResult; + }); + } catch (e) { + throw new WalletConnectionError((e instanceof Error && e.message) || 'Unknown error', e); + } + } + + #handleAuthorizationResult = async (authorizationResult: AuthorizationResult) => { + const didPublicKeysChange = + // Case 1: We started from having no authorization. + this.#authorizationResult == null || + // Case 2: The number of authorized accounts changed. + this.#authorizationResult?.accounts.length !== authorizationResult.accounts.length || + // Case 3: The new list of addresses isn't exactly the same as the old list, in the same order. + this.#authorizationResult.accounts.some( + (account, ii) => account.address !== authorizationResult.accounts[ii].address, + ); + this.#authorizationResult = authorizationResult; + if (didPublicKeysChange) { + const nextSelectedAddress = await this.#addressSelector.select( + authorizationResult.accounts.map(({ address }) => address), + ); + if (nextSelectedAddress !== this.#selectedAddress) { + this.#selectedAddress = nextSelectedAddress; + this.#emit('change',{ accounts: this.accounts }); + } + } + } + + #performReauthorization = async (wallet: Web3MobileWallet, authToken: AuthToken) => { + try { + const mwaAuthorizationResult = await wallet.authorize({ + auth_token: authToken, + identity: this.#appIdentity, + }); + + const accounts = this.#accountsToWalletStandardAccounts(mwaAuthorizationResult.accounts) + const authorizationResult = { ...mwaAuthorizationResult, + accounts: accounts + }; + // TODO: Evaluate whether there's any threat to not `awaiting` this expression + Promise.all([ + this.#authorizationResultCache.set(authorizationResult), + this.#handleAuthorizationResult(authorizationResult), + ]); + } catch (e) { + this.#disconnect(); + throw new WalletDisconnectedError((e instanceof Error && e?.message) || 'Unknown error', e); + } + } + + #disconnect: StandardDisconnectMethod = async () => { + this.#wallet?.terminateSession(); + this.#authorizationResultCache.clear(); // TODO: Evaluate whether there's any threat to not `awaiting` this expression + this.#connecting = false; + this.#connectionGeneration++; + this.#authorizationResult = undefined; + this.#selectedAddress = undefined; + this.#wallet = undefined; + this.#emit('change', { accounts: this.accounts }); + }; + + #transact = async (callback: (wallet: Web3RemoteMobileWallet) => TReturn) => { + const walletUriBase = this.#authorizationResult?.wallet_uri_base; + const baseConfig = walletUriBase ? { baseUri: walletUriBase } : undefined; + const remoteConfig = { ...baseConfig, remoteHostAuthority: this.#hostAuthority }; + const currentConnectionGeneration = this.#connectionGeneration; + const modal = new EmbeddedDialogModal('MWA QR'); + + if (this.#wallet) { + return callback(this.#wallet); + } + + try { + const { associationUrl, result: promise } = await transactRemote(async (wallet) => { + const result = await callback(wallet); + modal.close(); + return result; + }, remoteConfig); + modal.init(associationUrl.toString()); + modal.open(); + return await promise; + } catch (e) { + if (this.#connectionGeneration !== currentConnectionGeneration) { + await new Promise(() => {}); // Never resolve. + } + if ( + e instanceof Error && + e.name === 'SolanaMobileWalletAdapterError' && + ( + e as SolanaMobileWalletAdapterError< + typeof SolanaMobileWalletAdapterErrorCode[keyof typeof SolanaMobileWalletAdapterErrorCode] + > + ).code === 'ERROR_WALLET_NOT_FOUND' + ) { + await this.#onWalletNotFound(this); + } + throw e; + } + } + + #assertIsAuthorized = () => { + if (!this.#authorizationResult || !this.#selectedAddress) throw new WalletNotConnectedError(); + return { + authToken: this.#authorizationResult.auth_token, + selectedAddress: this.#selectedAddress, + }; + } + + #accountsToWalletStandardAccounts = (accounts: Account[]) => { + return accounts.map((account) => { + const publicKey = toUint8Array(account.address) + return { + address: base58.encode(publicKey), + publicKey, + label: account.label, + icon: account.icon, + chains: account.chains ?? [this.#chain], + // TODO: get supported features from getCapabilities API + features: account.features ?? DEFAULT_FEATURES + } as WalletAccount + }); + } + + #performSignTransactions = async ( + transactions: T[] + ) => { + const { authToken } = this.#assertIsAuthorized(); + try { + return await this.#transact(async (wallet) => { + await this.#performReauthorization(wallet, authToken); + const signedTransactions = await wallet.signTransactions({ + transactions, + }); + return signedTransactions; + }); + } catch (error: any) { + throw new WalletSignTransactionError(error?.message, error); + } + } + + #performSignAndSendTransaction = async ( + transaction: VersionedTransaction, + options?: SolanaSignAndSendTransactionOptions | undefined, + ) => { + return await this.#runWithGuard(async () => { + const { authToken } = this.#assertIsAuthorized(); + try { + return await this.#transact(async (wallet) => { + const [capabilities, _1] = await Promise.all([ + wallet.getCapabilities(), + this.#performReauthorization(wallet, authToken) + ]); + if (capabilities.supports_sign_and_send_transactions) { + const signatures = await wallet.signAndSendTransactions({ + ...options, + transactions: [transaction], + }); + return signatures[0]; + } else { + throw new Error('connected wallet does not support signAndSendTransaction') + } + }); + } catch (error: any) { + throw new WalletSendTransactionError(error?.message, error); + } + }); + } + + #signAndSendTransaction: SolanaSignAndSendTransactionMethod = async (...inputs) => { + + const outputs: SolanaSignAndSendTransactionOutput[] = []; + + for (const input of inputs) { + const transaction = VersionedTransaction.deserialize(input.transaction); + const signature = (await this.#performSignAndSendTransaction(transaction, input.options)) + outputs.push({ signature: base58.decode(signature) }) + } + + return outputs; + }; + + #signTransaction: SolanaSignTransactionMethod = async (...inputs) => { + const transactions = inputs.map(({ transaction }) => VersionedTransaction.deserialize(transaction)); + return await this.#runWithGuard(async () => { + const signedTransactions = await this.#performSignTransactions(transactions); + return signedTransactions.map((signedTransaction) => { + const serializedTransaction = isVersionedTransaction(signedTransaction) + ? signedTransaction.serialize() + : new Uint8Array( + (signedTransaction as LegacyTransaction).serialize({ + requireAllSignatures: false, + verifySignatures: false, + }) + ); + + return { signedTransaction: serializedTransaction }; + }); + }); + }; + + #signMessage: SolanaSignMessageMethod = async (...inputs) => { + + const outputs: SolanaSignMessageOutput[] = []; + return await this.#runWithGuard(async () => { + const { authToken, selectedAddress } = this.#assertIsAuthorized(); + const addresses = inputs.map(({ account }) => fromUint8Array(account.publicKey)) + const messages = inputs.map(({ message }) => message); + try { + return await this.#transact(async (wallet) => { + await this.#performReauthorization(wallet, authToken); + const signedMessages = await wallet.signMessages({ + addresses: addresses, + payloads: messages, + }); + return signedMessages.map((signedMessage) => { + return { signedMessage: signedMessage, signature: signedMessage.slice(-SIGNATURE_LENGTH_IN_BYTES) } + }); + }); + } catch (error: any) { + throw new WalletSignMessageError(error?.message, error); + } + }); + }; + + #signIn: SolanaSignInMethod = async (...inputs) => { + const outputs: SolanaSignInOutput[] = []; + + if (inputs.length > 1) { + for (const input of inputs) { + outputs.push(await this.#performSignIn(input)); + } + } else { + return [await this.#performSignIn(inputs[0])]; + } + + return outputs; + }; + + #performSignIn = async (input?: SolanaSignInInput) => { + return await this.#runWithGuard(async () => { + this.#connecting = true; + try { + const authorizationResult = await this.#performAuthorization({ + ...input, + domain: input?.domain ?? window.location.host + }); + if (!authorizationResult.sign_in_result) { + throw new Error("Sign in failed, no sign in result returned by wallet"); + } + const signedInAddress = authorizationResult.sign_in_result.address; + const signedInAccount: WalletAccount = { + ...authorizationResult.accounts.find(acc => acc.address == signedInAddress) ?? { + address: signedInAddress + }, + publicKey: toUint8Array(signedInAddress) + } as WalletAccount; + return { + account: signedInAccount, + signedMessage: toUint8Array(authorizationResult.sign_in_result.signed_message), + signature: toUint8Array(authorizationResult.sign_in_result.signature) + }; + } catch (e) { + throw new WalletConnectionError((e instanceof Error && e.message) || 'Unknown error', e); + } finally { + this.#connecting = false; + } + }); + } +} + diff --git a/js/yarn.lock b/js/yarn.lock index 0d320a5d4..1b5726527 100644 --- a/js/yarn.lock +++ b/js/yarn.lock @@ -1272,7 +1272,7 @@ "@solana/wallet-standard-core" "^1.1.1" "@solana/wallet-standard-wallet-adapter" "^1.1.2" -"@solana/web3.js@^1.58.0", "@solana/web3.js@^1.91.7": +"@solana/web3.js@^1.91.7", "@solana/web3.js@^1.95.3": version "1.95.3" resolved "https://registry.yarnpkg.com/@solana/web3.js/-/web3.js-1.95.3.tgz#70b5f4d76823f56b5af6403da51125fffeb65ff3" integrity sha512-O6rPUN0w2fkNqx/Z3QJMB9L225Ex10PRDH8bTaIUPZXMPV0QP8ZpPvjQnXK+upUczlRgzHzd6SjKIha1p+I6og==