Skip to content
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

Implementing Loom Provider 2 (WebSocket + JSON RPC) #303

Open
wants to merge 21 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 16 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .travis_e2e_test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@

set -euxo pipefail

eval "$(GIMME_GO_VERSION=1.10.2 gimme)"
eval "$(GIMME_GO_VERSION=1.12.7 gimme)"

export BUILD_ID=build-1283
export BUILD_ID=build-1313

bash e2e_tests.sh

Expand Down
2 changes: 2 additions & 0 deletions e2e_support/loom.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ ChainConfig:
ContractEnabled: true
Auth:
Chains:
loom:
TxType: "loom"
eth:
TxType: "eth"
AccountType: 1
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export {
} from './middleware'
export { createDefaultTxMiddleware } from './helpers'
export { LoomProvider } from './loom-provider'
export { LoomProvider2 } from './loom-provider-2'

import * as Contracts from './contracts'
export { Contracts }
Expand Down
275 changes: 275 additions & 0 deletions src/loom-provider-2.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,275 @@
import debug from 'debug'
import retry from 'retry'
import { Wallet } from 'ethers'
import { Client as WSClient } from 'rpc-websockets'
import { EthRPCMethod, IEthRPCPayload } from './loom-provider'
import { hexToNumber } from './crypto-utils'
import { EventEmitter } from 'events'

const debugLog = debug('loom-provider-2')
const eventLog = debug('loom-provider-2:event')
const errorLog = debug('loom-provider-2:error')

/**
* Web3 provider that interacts with EVM contracts deployed on Loom DAppChains.
*/
export class LoomProvider2 {
private _wallet: Wallet
private _wsRPC: WSClient
private _ethRPCMethods: Map<string, EthRPCMethod> = new Map<string, EthRPCMethod>()
protected notificationCallbacks: Array<Function> = new Array()

/**
* The retry strategy that should be used to retry some web3 requests.
* By default failed requested won't be resent.
* To understand how to tweak the retry strategy see
* https://github.com/tim-kos/node-retry#retrytimeoutsoptions
*/
retryStrategy: retry.OperationOptions = {
retries: 0,
minTimeout: 1000, // 1s
maxTimeout: 30000, // 30s
randomize: true
}

/**
* Constructs the LoomProvider2 to bridges communication between Web3 and Loom DappChains
*
* @param host Loomchain address
* @param ecdsaPrivateKey ECDSA private key
*/
constructor(public host: string, private ecdsaPrivateKey?: string) {
// Simply create socket
this._wsRPC = new WSClient(host)

// If no privakey passed generate a random wallet
this._wallet = ecdsaPrivateKey ? new Wallet(ecdsaPrivateKey) : Wallet.createRandom()

// Emits new messages to the provider handler above (Web3)
this._wsRPC.once('open', () => {
;((this._wsRPC as any).socket as EventEmitter).on(
'message',
this._onWebSocketMessage.bind(this)
)
;((this._wsRPC as any).socket as WebSocket).onclose = () => {
this.reset()
}
})

// Prepare LoomProvider2 default methods
this.addDefaultMethods()
}

get wallet(): Wallet {
return this._wallet
}

addDefaultMethods() {
this._ethRPCMethods.set('eth_accounts', this._ethAccounts.bind(this))
this._ethRPCMethods.set('eth_sendTransaction', this._ethSendTransaction.bind(this))
}

// Adapter function for sendAsync from truffle provider
async sendAsync(payload: any, callback?: Function): Promise<any | void> {
if (callback) {
await this.send(payload, callback)
} else {
return new Promise((resolve, reject) => {
this.send(payload, (err: Error, result: any) => {
if (err) reject(err)
else resolve(result)
})
})
}
}

/**
* Should be used to make async request
*
* @param payload JSON payload
* @param callback Triggered on end with (err, result)
*/
async send(payload: any, callback: Function) {
const isArray = Array.isArray(payload)
if (isArray) {
payload = payload[0]
}

debugLog('New Payload', JSON.stringify(payload, null, 2))

if (!this._wsRPC.ready) {
debugLog('Socket not ready resched call', payload)

setTimeout(() => {
this.send(payload, callback)
}, 1000)

return
}

const op = retry.operation(this.retryStrategy)
op.attempt(async currAttempt => {
debugLog(`Current attempt ${currAttempt}`)

let result

try {
if (this._ethRPCMethods.has(payload.method)) {
const f: Function = this._ethRPCMethods.get(payload.method)!
result = await f(payload)
} else {
result = await this._wsRPC.call(payload.method, payload.params)
}

callback(null, this._okResponse(payload.id, result, isArray))
} catch (err) {
if (!op.retry(err)) {
callback(err, null)
} else {
errorLog(err)
}
}
})
}

// EVENT HANDLING METHODS

on(type: string, callback: any) {
if (typeof callback !== 'function') {
throw new Error('The second parameter callback must be a function.')
}

eventLog('On event', type)

switch (type) {
case 'data':
this.notificationCallbacks.push(callback)
break
case 'connect':
;((this._wsRPC as any).socket as WebSocket).onopen = callback
break
case 'end':
;((this._wsRPC as any).socket as WebSocket).onclose = callback
break
case 'error':
;((this._wsRPC as any).socket as WebSocket).onerror = callback
break
}
}

removeListener(type: string, callback: (...args: any[]) => void) {
eventLog('Remove listner', type)

switch (type) {
case 'data':
this.notificationCallbacks.forEach((cb, index) => {
if (cb === callback) {
this.notificationCallbacks.splice(index, 1)
}
})
break
}
}

removeAllListeners(type: string) {
eventLog('Remove all listeners of type', type)

switch (type) {
case 'data':
this.notificationCallbacks = []
break
case 'connect':
;((this._wsRPC as any).socket as WebSocket).onopen = null
break
case 'end':
;((this._wsRPC as any).socket as WebSocket).onclose = null
break
case 'error':
;((this._wsRPC as any).socket as WebSocket).onerror = null
break
}
}

reset() {
eventLog('Reset notifications')
this.notificationCallbacks = []
}

disconnect() {
debugLog(`Disconnect`)
this._wsRPC.close(1000, 'bye')
}

// PRIVATE FUNCTIONS

private async _ethAccounts() {
const address = await this.wallet.getAddress()
return [address]
}

private async _ethSendTransaction(payload: IEthRPCPayload) {
const params: any = payload.params[0]

const account = await this.wallet.getAddress()

// Get the nonce for the next tx
const nonce = await this.sendAsync({
id: 0,
method: 'eth_getTransactionCount',
params: [account, 'latest']
})

debugLog(`Next nonce ${nonce.result}`)

// Create transaction
const transaction: any = {
nonce: hexToNumber(nonce.result) + 1,
data: params.data,
gasPrice: '0x0'
}

if (params.to) {
transaction.to = params.to
}

if (params.value) {
transaction.value = params.value
}

const signedTransaction = await this.wallet.sign(transaction)

debugLog(`Signed transaction ${JSON.stringify(transaction, null, 2)} ${signedTransaction}`)

const tx = await this.sendAsync({
id: 0,
method: 'eth_sendRawTransaction',
params: [signedTransaction]
})

return tx.result
}

private _onWebSocketMessage(jsonResult: any) {
try {
const result = JSON.parse(jsonResult)

if (result && result.method && result.method.indexOf('_subscription') !== -1) {
eventLog('New socket event', jsonResult)

this.notificationCallbacks.forEach((callback: Function) => {
callback(result)
})
}
} catch (err) {
errorLog(err)
}
}

// Basic response to web3js
private _okResponse(id: number, result: any = 0, isArray: boolean = false): any {
const response = { id, jsonrpc: '2.0', result }
const ret = isArray ? [response] : response
debugLog('Response payload', JSON.stringify(ret, null, 2))
return ret
}
}
Loading