diff --git a/README.md b/README.md index 0979509..bbb6c74 100644 --- a/README.md +++ b/README.md @@ -175,9 +175,9 @@ For apps that operate on multiple chains at once, you can use the Multichain SDK Currently a transaction hash or account address can be subscribed to for all events. The `subscribe` method requires an `id`, `chainId` and a `type` and returns an `Observable`. The `Observable` that is returned is specific for events on this subscription and on completion or unsubscribing from the observable will automatically unsubscribe within the SDK. Alternatively you can listen on the global transactions `Observable` at `sdk.transactions$`. ```javascript -import { MultichainSDK } from 'bnc-sdk' +import Blocknative from 'bnc-sdk' -const blocknative = new MultichainSDK({ apiKey: '' }) +const blocknative = Blocknative.multichain({ apiKey: '' }) // subscribe to address events const addressSubscription = blocknative.subscribe({ diff --git a/package.json b/package.json index eec0023..dceef39 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bnc-sdk", - "version": "4.4.0", + "version": "4.4.0-0.0.1", "description": "SDK to connect to the blocknative backend via a websocket connection", "keywords": [ "ethereum", diff --git a/rollup.config.js b/rollup.config.js index bbd207f..6a80281 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -39,8 +39,7 @@ export default [ output: [ { format: 'cjs', - dir: 'dist/cjs/', - exports: 'named' + dir: 'dist/cjs/' } ], plugins: [ diff --git a/src/index.ts b/src/index.ts index b55b4e9..ffcfff2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,290 @@ -import Blocknative from './sdk' +import { validateOptions } from './validation' +import SturdyWebSocket from 'sturdy-websocket' +import CryptoEs from 'crypto-es' +import transaction from './transaction' +import account from './account' +import event from './event' +import simulate from './simulate' +import unsubscribe from './unsubscribe' +import configuration from './configuration' +import { DEFAULT_RATE_LIMIT_RULES } from './defaults' +import { isLocalStorageAvailable } from './utilities' +import MultiChain from './multichain' +import { + sendMessage, + handleMessage, + processQueue, + createEventLog +} from './messages' + +import { + InitializationOptions, + Ac, + TransactionHandler, + EventObject, + Tx, + Transaction, + Account, + Event, + Unsubscribe, + Simulate, + Destroy, + Configuration, + SDKError, + LimitRules, + EnhancedConfig, + MultiChainOptions +} from './types' + +const DEFAULT_APP_NAME = 'unknown' +const DEFAULT_APP_VERSION = 'unknown' +const DEFAULT_SYSTEM = 'ethereum' + +class SDK { + protected _storageKey: string + protected _connectionId: string | undefined + protected _dappId: string + protected _system: string + protected _networkId: number + protected _appName: string + protected _appVersion: string + protected _transactionHandlers: TransactionHandler[] + protected _socket: any + protected _connected: boolean + protected _sendMessage: (msg: EventObject) => void + protected _pingTimeout?: NodeJS.Timeout + protected _heartbeat?: () => void + protected _destroyed: boolean + protected _onerror: ((error: SDKError) => void) | undefined + protected _queuedMessages: string[] + protected _limitRules: LimitRules + protected _waitToRetry: null | Promise + protected _processingQueue: boolean + protected _processQueue: () => Promise + + public transaction: Transaction + public account: Account + public event: Event + public simulate: Simulate + public unsubscribe: Unsubscribe + public destroy: Destroy + public configuration: Configuration + public watchedTransactions: Tx[] + public watchedAccounts: Ac[] + public configurations: Map + + constructor(options: InitializationOptions) { + validateOptions(options) + + const { + dappId, + system = DEFAULT_SYSTEM, + name = DEFAULT_APP_NAME, + appVersion = DEFAULT_APP_VERSION, + networkId, + transactionHandlers = [], + apiUrl, + ws, + onopen, + ondown, + onreopen, + onerror, + onclose + } = options + + // override default timeout to allow for slow connections + const timeout = { connectTimeout: 10000 } + + const socket = new SturdyWebSocket( + apiUrl || 'wss://api.blocknative.com/v0', + ws + ? { + wsConstructor: ws, + ...timeout + } + : { ...timeout } + ) + + socket.onopen = onOpen.bind(this, onopen) + socket.ondown = onDown.bind(this, ondown) + socket.onreopen = onReopen.bind(this, onreopen) + socket.onmessage = handleMessage.bind(this) + socket.onerror = (error: any) => + onerror && onerror({ message: 'There was a WebSocket error', error }) + socket.onclose = () => { + this._pingTimeout && clearInterval(this._pingTimeout) + onclose && onclose() + } + + const storageKey = CryptoEs.SHA1(`${dappId} - ${name}`).toString() + const storedConnectionId = + isLocalStorageAvailable() && window.localStorage.getItem(storageKey) + + this._storageKey = storageKey + this._connectionId = storedConnectionId || undefined + this._dappId = dappId + this._system = system + this._networkId = networkId + this._appName = name + this._appVersion = appVersion + this._transactionHandlers = transactionHandlers + this._socket = socket + this._connected = false + this._sendMessage = sendMessage.bind(this) + this._pingTimeout = undefined + this._destroyed = false + this._onerror = onerror + this._queuedMessages = [] + this._limitRules = DEFAULT_RATE_LIMIT_RULES + this._waitToRetry = null + this._processingQueue = false + this._processQueue = processQueue.bind(this) + + if (this._socket.ws.on) { + this._heartbeat = () => { + this._pingTimeout && clearTimeout(this._pingTimeout) + + this._pingTimeout = setTimeout(() => { + // terminate connection if we haven't heard the server ping after server timeout plus conservative latency delay + // Sturdy Websocket will handle the new connection logic + this._socket.ws.terminate() + }, 30000 + 1000) + } + + this._socket.ws.on('ping', () => { + this._heartbeat && this._heartbeat() + }) + } + + // public API + this.watchedTransactions = [] + this.watchedAccounts = [] + this.configurations = new Map() + this.transaction = transaction.bind(this) + this.account = account.bind(this) + this.event = event.bind(this) + this.simulate = simulate.bind(this) + this.unsubscribe = unsubscribe.bind(this) + this.configuration = configuration.bind(this) + this.destroy = () => { + this._socket.close() + this._destroyed = true + + // call onclose manually here as SturdyWebSocket doesn't currently work as expected + // https://github.com/dphilipson/sturdy-websocket/issues/5 + this._socket.onclose() + } + } + + static multichain(options: MultiChainOptions) { + return new MultiChain(options, this) + } +} + +function onOpen(this: any, handler: (() => void) | undefined) { + this._connected = true + + const msg = { + categoryCode: 'initialize', + eventCode: 'checkDappId', + connectionId: this._connectionId + } + + // send this message directly rather than put in queue + this._socket.send(createEventLog.bind(this)(msg)) + this._heartbeat && this._heartbeat() + handler && handler() +} + +function onDown( + this: any, + handler: ((closeEvent: CloseEvent) => void) | undefined, + closeEvent: CloseEvent +) { + this._connected = false + + if (handler) { + handler(closeEvent) + } + + this._pingTimeout && clearTimeout(this._pingTimeout) +} + +async function onReopen(this: any, handler: (() => void) | undefined) { + this._connected = true + + const msg = { + categoryCode: 'initialize', + eventCode: 'checkDappId', + connectionId: this._connectionId + } + + this._socket.send(createEventLog.bind(this)(msg)) + + // re-register all configurations on re-connection + const configurations: EnhancedConfig[] = Array.from( + this.configurations.values() + ) + + // register global config first and wait for it to complete + const globalConfiguration = this.configurations.get('global') + + if (globalConfiguration) { + try { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { emitter, subscription, ...config } = globalConfiguration + await this.configuration(config) + } catch (error) { + console.warn( + 'Error re-sending global configuration upon reconnection:', + error + ) + } + } + + const addressConfigurations = configurations.filter( + ({ scope }) => scope !== 'global' + ) + + addressConfigurations.forEach((enhancedConfig: EnhancedConfig) => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { emitter, subscription, ...config } = enhancedConfig + + this._sendMessage({ + categoryCode: 'configs', + eventCode: 'put', + config + }) + }) + + // re-register all accounts to be watched by server upon + // re-connection as they don't get transferred over automatically + // to the new connection like tx hashes do + this.watchedAccounts.forEach((account: Ac) => { + this._sendMessage({ + eventCode: 'accountAddress', + categoryCode: 'watch', + account: { + address: account.address + } + }) + }) + + if (handler) { + handler() + } + + if (this._socket.ws && this._socket.ws.on) { + // need to re-register ping event since new connection + this._socket.ws.on('ping', () => { + this._heartbeat && this._heartbeat() + }) + + this._heartbeat() + } +} + +export default SDK export * from './types' -export { default as MultichainSDK } from './multichain' -export default Blocknative +export type { MultiChain } diff --git a/src/multichain/index.ts b/src/multichain/index.ts index 0ef7bbe..1ff2039 100644 --- a/src/multichain/index.ts +++ b/src/multichain/index.ts @@ -1,5 +1,5 @@ import { Observable, Subject } from 'rxjs' -import Blocknative from '../sdk' +import type SDK from '../' import subscribe from './subscribe' import unsubscribe from './unsubscribe' @@ -14,15 +14,16 @@ import { class MultiChain { public apiKey: string public ws: WebSocket | void - public connections: Record + public connections: Record public transactions$: Observable public errors$: Subject public subscribe: typeof subscribe public unsubscribe: typeof unsubscribe + public Blocknative: typeof SDK protected onTransaction$: Subject - constructor(options: MultiChainOptions) { + constructor(options: MultiChainOptions, Blocknative: typeof SDK) { const { apiKey, ws } = options this.apiKey = apiKey @@ -31,6 +32,7 @@ class MultiChain { this.onTransaction$ = new Subject() this.transactions$ = this.onTransaction$.asObservable() this.errors$ = new Subject() + this.Blocknative = Blocknative this.subscribe = subscribe.bind(this) this.unsubscribe = unsubscribe.bind(this) } diff --git a/src/multichain/subscribe.ts b/src/multichain/subscribe.ts index 863de86..4e3c922 100644 --- a/src/multichain/subscribe.ts +++ b/src/multichain/subscribe.ts @@ -1,14 +1,14 @@ import { fromEvent, Observable } from 'rxjs' import { filter, finalize, takeWhile } from 'rxjs/operators' import MultiChainWebSocket from '.' -import Blocknative from '../sdk' +import type SDK from '../' +import { networkName } from '../utilities' import { AccountSubscription, EthereumTransactionData, Subscription } from '../types' -import { networkName } from '../utilities' function subscribe( this: MultiChainWebSocket, @@ -21,7 +21,7 @@ function subscribe( } if (!this.connections[chainId]) { - this.connections[chainId] = new Blocknative({ + this.connections[chainId] = new this.Blocknative({ system: 'ethereum', networkId: parseInt(chainId, 16), dappId: this.apiKey, @@ -35,7 +35,7 @@ function subscribe( }) } - const sdk = this.connections[chainId] as Blocknative + const sdk = this.connections[chainId] as SDK if (type === 'account') { const { filters = [], abi = [] } = subscription as AccountSubscription diff --git a/src/multichain/unsubscribe.ts b/src/multichain/unsubscribe.ts index 7856858..cb4fb6e 100644 --- a/src/multichain/unsubscribe.ts +++ b/src/multichain/unsubscribe.ts @@ -1,7 +1,7 @@ import { merge, timer } from 'rxjs' import { filter, take } from 'rxjs/operators' import MultiChainWebSocket from '.' -import Blocknative from '../sdk' +import SDK from '../' import { Address, ChainId, Hash } from '../types' type UnsubscribeOptions = { @@ -31,7 +31,7 @@ function unsubscribe( if (typeof res === 'number') { const sdkConnections = Object.entries(this.connections).filter( ([chainId, sdk]) => sdk !== null - ) as [ChainId, Blocknative][] + ) as [ChainId, SDK][] sdkConnections.forEach(([connectionChainId, sdk]) => { // if chainId is passed and it doesn't match, then no unsub (return early) diff --git a/src/sdk.ts b/src/sdk.ts deleted file mode 100644 index 376e6dc..0000000 --- a/src/sdk.ts +++ /dev/null @@ -1,282 +0,0 @@ -import { validateOptions } from './validation' -import SturdyWebSocket from 'sturdy-websocket' -import CryptoEs from 'crypto-es' -import transaction from './transaction' -import account from './account' -import event from './event' -import simulate from './simulate' -import unsubscribe from './unsubscribe' -import configuration from './configuration' -import { DEFAULT_RATE_LIMIT_RULES } from './defaults' -import { isLocalStorageAvailable } from './utilities' - -import { - sendMessage, - handleMessage, - processQueue, - createEventLog -} from './messages' - -import { - InitializationOptions, - Ac, - TransactionHandler, - EventObject, - Tx, - Transaction, - Account, - Event, - Unsubscribe, - Simulate, - Destroy, - Configuration, - SDKError, - LimitRules, - EnhancedConfig -} from './types' - -const DEFAULT_APP_NAME = 'unknown' -const DEFAULT_APP_VERSION = 'unknown' -const DEFAULT_SYSTEM = 'ethereum' - -class Blocknative { - protected _storageKey: string - protected _connectionId: string | undefined - protected _dappId: string - protected _system: string - protected _networkId: number - protected _appName: string - protected _appVersion: string - protected _transactionHandlers: TransactionHandler[] - protected _socket: any - protected _connected: boolean - protected _sendMessage: (msg: EventObject) => void - protected _pingTimeout?: NodeJS.Timeout - protected _heartbeat?: () => void - protected _destroyed: boolean - protected _onerror: ((error: SDKError) => void) | undefined - protected _queuedMessages: string[] - protected _limitRules: LimitRules - protected _waitToRetry: null | Promise - protected _processingQueue: boolean - protected _processQueue: () => Promise - - public transaction: Transaction - public account: Account - public event: Event - public simulate: Simulate - public unsubscribe: Unsubscribe - public destroy: Destroy - public configuration: Configuration - public watchedTransactions: Tx[] - public watchedAccounts: Ac[] - public configurations: Map - - constructor(options: InitializationOptions) { - validateOptions(options) - - const { - dappId, - system = DEFAULT_SYSTEM, - name = DEFAULT_APP_NAME, - appVersion = DEFAULT_APP_VERSION, - networkId, - transactionHandlers = [], - apiUrl, - ws, - onopen, - ondown, - onreopen, - onerror, - onclose - } = options - - // override default timeout to allow for slow connections - const timeout = { connectTimeout: 10000 } - - const socket = new SturdyWebSocket( - apiUrl || 'wss://api.blocknative.com/v0', - ws - ? { - wsConstructor: ws, - ...timeout - } - : { ...timeout } - ) - - socket.onopen = onOpen.bind(this, onopen) - socket.ondown = onDown.bind(this, ondown) - socket.onreopen = onReopen.bind(this, onreopen) - socket.onmessage = handleMessage.bind(this) - socket.onerror = (error: any) => - onerror && onerror({ message: 'There was a WebSocket error', error }) - socket.onclose = () => { - this._pingTimeout && clearInterval(this._pingTimeout) - onclose && onclose() - } - - const storageKey = CryptoEs.SHA1(`${dappId} - ${name}`).toString() - const storedConnectionId = - isLocalStorageAvailable() && window.localStorage.getItem(storageKey) - - this._storageKey = storageKey - this._connectionId = storedConnectionId || undefined - this._dappId = dappId - this._system = system - this._networkId = networkId - this._appName = name - this._appVersion = appVersion - this._transactionHandlers = transactionHandlers - this._socket = socket - this._connected = false - this._sendMessage = sendMessage.bind(this) - this._pingTimeout = undefined - this._destroyed = false - this._onerror = onerror - this._queuedMessages = [] - this._limitRules = DEFAULT_RATE_LIMIT_RULES - this._waitToRetry = null - this._processingQueue = false - this._processQueue = processQueue.bind(this) - - if (this._socket.ws.on) { - this._heartbeat = () => { - this._pingTimeout && clearTimeout(this._pingTimeout) - - this._pingTimeout = setTimeout(() => { - // terminate connection if we haven't heard the server ping after server timeout plus conservative latency delay - // Sturdy Websocket will handle the new connection logic - this._socket.ws.terminate() - }, 30000 + 1000) - } - - this._socket.ws.on('ping', () => { - this._heartbeat && this._heartbeat() - }) - } - - // public API - this.watchedTransactions = [] - this.watchedAccounts = [] - this.configurations = new Map() - this.transaction = transaction.bind(this) - this.account = account.bind(this) - this.event = event.bind(this) - this.simulate = simulate.bind(this) - this.unsubscribe = unsubscribe.bind(this) - this.configuration = configuration.bind(this) - this.destroy = () => { - this._socket.close() - this._destroyed = true - - // call onclose manually here as SturdyWebSocket doesn't currently work as expected - // https://github.com/dphilipson/sturdy-websocket/issues/5 - this._socket.onclose() - } - } -} - -function onOpen(this: any, handler: (() => void) | undefined) { - this._connected = true - - const msg = { - categoryCode: 'initialize', - eventCode: 'checkDappId', - connectionId: this._connectionId - } - - // send this message directly rather than put in queue - this._socket.send(createEventLog.bind(this)(msg)) - this._heartbeat && this._heartbeat() - handler && handler() -} - -function onDown( - this: any, - handler: ((closeEvent: CloseEvent) => void) | undefined, - closeEvent: CloseEvent -) { - this._connected = false - - if (handler) { - handler(closeEvent) - } - - this._pingTimeout && clearTimeout(this._pingTimeout) -} - -async function onReopen(this: any, handler: (() => void) | undefined) { - this._connected = true - - const msg = { - categoryCode: 'initialize', - eventCode: 'checkDappId', - connectionId: this._connectionId - } - - this._socket.send(createEventLog.bind(this)(msg)) - - // re-register all configurations on re-connection - const configurations: EnhancedConfig[] = Array.from( - this.configurations.values() - ) - - // register global config first and wait for it to complete - const globalConfiguration = this.configurations.get('global') - - if (globalConfiguration) { - try { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { emitter, subscription, ...config } = globalConfiguration - await this.configuration(config) - } catch (error) { - console.warn( - 'Error re-sending global configuration upon reconnection:', - error - ) - } - } - - const addressConfigurations = configurations.filter( - ({ scope }) => scope !== 'global' - ) - - addressConfigurations.forEach((enhancedConfig: EnhancedConfig) => { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { emitter, subscription, ...config } = enhancedConfig - - this._sendMessage({ - categoryCode: 'configs', - eventCode: 'put', - config - }) - }) - - // re-register all accounts to be watched by server upon - // re-connection as they don't get transferred over automatically - // to the new connection like tx hashes do - this.watchedAccounts.forEach((account: Ac) => { - this._sendMessage({ - eventCode: 'accountAddress', - categoryCode: 'watch', - account: { - address: account.address - } - }) - }) - - if (handler) { - handler() - } - - if (this._socket.ws && this._socket.ws.on) { - // need to re-register ping event since new connection - this._socket.ws.on('ping', () => { - this._heartbeat && this._heartbeat() - }) - - this._heartbeat() - } -} - -export default Blocknative diff --git a/src/types.ts b/src/types.ts index bb9d1e4..72ccb2b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,5 +1,4 @@ import { Subject } from 'rxjs' - export interface NotificationObject { type?: 'pending' | 'success' | 'error' | 'hint' message?: string