UNPKG

@coinbase/wallet-sdk

Version:
341 lines 13.2 kB
// Copyright (c) 2018-2023 Coinbase, Inc. <https://www.coinbase.com/> import { APP_VERSION_KEY, WALLET_USER_NAME_KEY } from '../constants.js'; import { WalletLinkCipher } from './WalletLinkCipher.js'; import { WalletLinkHTTP } from './WalletLinkHTTP.js'; import { ConnectionState, WalletLinkWebSocket } from './WalletLinkWebSocket.js'; import { IntNumber } from '../../../../core/type/index.js'; const HEARTBEAT_INTERVAL = 10000; const REQUEST_TIMEOUT = 60000; /** * Coinbase Wallet Connection */ export class WalletLinkConnection { /** * Constructor * @param session Session * @param linkAPIUrl Coinbase Wallet link server URL * @param listener WalletLinkConnectionUpdateListener * @param [WebSocketClass] Custom WebSocket implementation */ constructor({ session, linkAPIUrl, listener }) { this.destroyed = false; this.lastHeartbeatResponse = 0; this.nextReqId = IntNumber(1); /** * true if connected and authenticated, else false * runs listener when connected status changes */ this._connected = false; /** * true if linked (a guest has joined before) * runs listener when linked status changes */ this._linked = false; this.shouldFetchUnseenEventsOnConnect = false; this.requestResolutions = new Map(); this.handleSessionMetadataUpdated = (metadata) => { if (!metadata) return; // Map of metadata key to handler function const handlers = new Map([ ['__destroyed', this.handleDestroyed], ['EthereumAddress', this.handleAccountUpdated], ['WalletUsername', this.handleWalletUsernameUpdated], ['AppVersion', this.handleAppVersionUpdated], [ 'ChainId', // ChainId and JsonRpcUrl are always updated together (v) => metadata.JsonRpcUrl && this.handleChainUpdated(v, metadata.JsonRpcUrl), ], ]); // call handler for each metadata key if value is defined handlers.forEach((handler, key) => { const value = metadata[key]; if (value === undefined) return; handler(value); }); }; this.handleDestroyed = (__destroyed) => { var _a; if (__destroyed !== '1') return; (_a = this.listener) === null || _a === void 0 ? void 0 : _a.resetAndReload(); }; this.handleAccountUpdated = async (encryptedEthereumAddress) => { var _a; const address = await this.cipher.decrypt(encryptedEthereumAddress); (_a = this.listener) === null || _a === void 0 ? void 0 : _a.accountUpdated(address); }; this.handleMetadataUpdated = async (key, encryptedMetadataValue) => { var _a; const decryptedValue = await this.cipher.decrypt(encryptedMetadataValue); (_a = this.listener) === null || _a === void 0 ? void 0 : _a.metadataUpdated(key, decryptedValue); }; this.handleWalletUsernameUpdated = async (walletUsername) => { this.handleMetadataUpdated(WALLET_USER_NAME_KEY, walletUsername); }; this.handleAppVersionUpdated = async (appVersion) => { this.handleMetadataUpdated(APP_VERSION_KEY, appVersion); }; this.handleChainUpdated = async (encryptedChainId, encryptedJsonRpcUrl) => { var _a; const chainId = await this.cipher.decrypt(encryptedChainId); const jsonRpcUrl = await this.cipher.decrypt(encryptedJsonRpcUrl); (_a = this.listener) === null || _a === void 0 ? void 0 : _a.chainUpdated(chainId, jsonRpcUrl); }; this.session = session; this.cipher = new WalletLinkCipher(session.secret); this.listener = listener; const ws = new WalletLinkWebSocket(`${linkAPIUrl}/rpc`, WebSocket); ws.setConnectionStateListener(async (state) => { // attempt to reconnect every 5 seconds when disconnected let connected = false; switch (state) { case ConnectionState.DISCONNECTED: // if DISCONNECTED and not destroyed if (!this.destroyed) { const connect = async () => { // wait 5 seconds await new Promise((resolve) => setTimeout(resolve, 5000)); // check whether it's destroyed again if (!this.destroyed) { // reconnect ws.connect().catch(() => { connect(); }); } }; connect(); } break; case ConnectionState.CONNECTED: // perform authentication upon connection // if CONNECTED, authenticate, and then check link status connected = await this.handleConnected(); // send heartbeat every n seconds while connected // if CONNECTED, start the heartbeat timer // first timer event updates lastHeartbeat timestamp // subsequent calls send heartbeat message this.updateLastHeartbeat(); setInterval(() => { this.heartbeat(); }, HEARTBEAT_INTERVAL); // check for unseen events if (this.shouldFetchUnseenEventsOnConnect) { this.fetchUnseenEventsAPI(); } break; case ConnectionState.CONNECTING: break; } // distinctUntilChanged if (this.connected !== connected) { this.connected = connected; } }); ws.setIncomingDataListener((m) => { var _a; switch (m.type) { // handle server's heartbeat responses case 'Heartbeat': this.updateLastHeartbeat(); return; // handle link status updates case 'IsLinkedOK': case 'Linked': { const linked = m.type === 'IsLinkedOK' ? m.linked : undefined; this.linked = linked || m.onlineGuests > 0; break; } // handle session config updates case 'GetSessionConfigOK': case 'SessionConfigUpdated': { this.handleSessionMetadataUpdated(m.metadata); break; } case 'Event': { this.handleIncomingEvent(m); break; } } // resolve request promises if (m.id !== undefined) { (_a = this.requestResolutions.get(m.id)) === null || _a === void 0 ? void 0 : _a(m); } }); this.ws = ws; this.http = new WalletLinkHTTP(linkAPIUrl, session.id, session.key); } /** * Make a connection to the server */ connect() { if (this.destroyed) { throw new Error('instance is destroyed'); } this.ws.connect(); } /** * Terminate connection, and mark as destroyed. To reconnect, create a new * instance of WalletSDKConnection */ async destroy() { if (this.destroyed) return; await this.makeRequest({ type: 'SetSessionConfig', id: IntNumber(this.nextReqId++), sessionId: this.session.id, metadata: { __destroyed: '1' }, }, { timeout: 1000 }); this.destroyed = true; this.ws.disconnect(); this.listener = undefined; } get connected() { return this._connected; } set connected(connected) { this._connected = connected; } get linked() { return this._linked; } set linked(linked) { var _a, _b; this._linked = linked; if (linked) (_a = this.onceLinked) === null || _a === void 0 ? void 0 : _a.call(this); (_b = this.listener) === null || _b === void 0 ? void 0 : _b.linkedUpdated(linked); } setOnceLinked(callback) { return new Promise((resolve) => { if (this.linked) { callback().then(resolve); } else { this.onceLinked = () => { callback().then(resolve); this.onceLinked = undefined; }; } }); } async handleIncomingEvent(m) { var _a; if (m.type !== 'Event' || m.event !== 'Web3Response') { return; } const decryptedData = await this.cipher.decrypt(m.data); const message = JSON.parse(decryptedData); if (message.type !== 'WEB3_RESPONSE') return; const { id, response } = message; (_a = this.listener) === null || _a === void 0 ? void 0 : _a.handleWeb3ResponseMessage(id, response); } async checkUnseenEvents() { if (!this.connected) { this.shouldFetchUnseenEventsOnConnect = true; return; } await new Promise((resolve) => setTimeout(resolve, 250)); try { await this.fetchUnseenEventsAPI(); } catch (e) { console.error('Unable to check for unseen events', e); } } async fetchUnseenEventsAPI() { this.shouldFetchUnseenEventsOnConnect = false; const responseEvents = await this.http.fetchUnseenEvents(); responseEvents.forEach((e) => this.handleIncomingEvent(e)); } /** * Publish an event and emit event ID when successful * @param event event name * @param unencryptedData unencrypted event data * @param callWebhook whether the webhook should be invoked * @returns a Promise that emits event ID when successful */ async publishEvent(event, unencryptedData, callWebhook = false) { const data = await this.cipher.encrypt(JSON.stringify(Object.assign(Object.assign({}, unencryptedData), { origin: location.origin, location: location.href, relaySource: 'coinbaseWalletExtension' in window && window.coinbaseWalletExtension ? 'injected_sdk' : 'sdk' }))); const message = { type: 'PublishEvent', id: IntNumber(this.nextReqId++), sessionId: this.session.id, event, data, callWebhook, }; return this.setOnceLinked(async () => { const res = await this.makeRequest(message); if (res.type === 'Fail') { throw new Error(res.error || 'failed to publish event'); } return res.eventId; }); } sendData(message) { this.ws.sendData(JSON.stringify(message)); } updateLastHeartbeat() { this.lastHeartbeatResponse = Date.now(); } heartbeat() { if (Date.now() - this.lastHeartbeatResponse > HEARTBEAT_INTERVAL * 2) { this.ws.disconnect(); return; } try { this.ws.sendData('h'); } catch (_a) { // noop } } async makeRequest(message, options = { timeout: REQUEST_TIMEOUT }) { const reqId = message.id; this.sendData(message); // await server message with corresponding id let timeoutId; return Promise.race([ new Promise((_, reject) => { timeoutId = window.setTimeout(() => { reject(new Error(`request ${reqId} timed out`)); }, options.timeout); }), new Promise((resolve) => { this.requestResolutions.set(reqId, (m) => { clearTimeout(timeoutId); // clear the timeout resolve(m); this.requestResolutions.delete(reqId); }); }), ]); } async handleConnected() { const res = await this.makeRequest({ type: 'HostSession', id: IntNumber(this.nextReqId++), sessionId: this.session.id, sessionKey: this.session.key, }); if (res.type === 'Fail') return false; this.sendData({ type: 'IsLinked', id: IntNumber(this.nextReqId++), sessionId: this.session.id, }); this.sendData({ type: 'GetSessionConfig', id: IntNumber(this.nextReqId++), sessionId: this.session.id, }); return true; } } //# sourceMappingURL=WalletLinkConnection.js.map