@coinbase/wallet-sdk
Version:
Coinbase Wallet JavaScript SDK
341 lines • 13.2 kB
JavaScript
// 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