Skip to content

Commit

Permalink
Add BlockbookElectrum wrapper
Browse files Browse the repository at this point in the history
  • Loading branch information
samholmes committed May 3, 2024
1 parent bd5dd58 commit 190b4c6
Show file tree
Hide file tree
Showing 5 changed files with 554 additions and 26 deletions.
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

## Unreleased

- added: Support for ElectrumX server WebSocket connections under 'electrumws(s):' protocol scheme.
- added: Query for UTXO data from ElectrumX servers (sans transaction data).

## 2.6.2 (2024-04-24)

- changed: Reorganize `currencyInfo`.
Expand Down
83 changes: 58 additions & 25 deletions src/common/utxobased/engine/ServerStates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
BlockbookTransaction,
SubscribeAddressResponse
} from '../network/blockbookApi'
import { makeBlockbookElectrum } from '../network/BlockbookElectrum'
import Deferred from '../network/Deferred'
import { WsTask } from '../network/Socket'
import { SocketEmitter, SocketEvent } from '../network/SocketEmitter'
Expand Down Expand Up @@ -218,7 +219,7 @@ export function makeServerStates(config: ServerStateConfig): ServerStates {
}

const doRefillServers = (): void => {
const includePatterns = ['wss:', 'ws:']
const includePatterns = ['wss:', 'ws:', 'electrumwss:', 'electrumws:']
if (serverList.length === 0) {
serverList = pluginState.getLocalServers(NEW_CONNECTIONS, includePatterns)
}
Expand Down Expand Up @@ -252,30 +253,62 @@ export function makeServerStates(config: ServerStateConfig): ServerStates {
continue
}

// Make new Blockbook instance
const blockbook = makeBlockbook({
asAddress: pluginInfo.engineInfo.asBlockbookAddress,
connectionUri: uri,
engineEmitter,
log,
onQueueSpaceCB: async (): Promise<
WsTask<any> | boolean | undefined
> => {
// Exit if the connection is no longer active
if (!(uri in serverStatesCache)) return

const task = await pickNextTaskCB(uri)
if (task != null && typeof task !== 'boolean') {
const taskMessage = `${task.method} params: ${JSON.stringify(
task.params
)}`
log(`${uri} nextTask: ${taskMessage}`)
}
return task
},
socketEmitter,
walletId: walletInfo.id
})
// Blockbook instance variable
let blockbook: Blockbook

// Create a new blockbook instance based on the URI scheme
if (['electrumwss', 'electrumws'].includes(parsed.scheme)) {
// Electrum wrapper
blockbook = makeBlockbookElectrum({
asAddress: pluginInfo.engineInfo.asBlockbookAddress,
connectionUri: uri,
engineEmitter,
log,
onQueueSpaceCB: async (): Promise<
WsTask<any> | boolean | undefined
> => {
// Exit if the connection is no longer active
if (!(uri in serverStatesCache)) return

const task = await pickNextTaskCB(uri)
if (task != null && typeof task !== 'boolean') {
const taskMessage = `${task.method} params: ${JSON.stringify(
task.params
)}`
log(`${uri} nextTask: ${taskMessage}`)
}
return task
},
pluginInfo,
socketEmitter,
walletId: walletInfo.id
})
} else {
// Regular blockbook instance
blockbook = makeBlockbook({
asAddress: pluginInfo.engineInfo.asBlockbookAddress,
connectionUri: uri,
engineEmitter,
log,
onQueueSpaceCB: async (): Promise<
WsTask<any> | boolean | undefined
> => {
// Exit if the connection is no longer active
if (!(uri in serverStatesCache)) return

const task = await pickNextTaskCB(uri)
if (task != null && typeof task !== 'boolean') {
const taskMessage = `${task.method} params: ${JSON.stringify(
task.params
)}`
log(`${uri} nextTask: ${taskMessage}`)
}
return task
},
socketEmitter,
walletId: walletInfo.id
})
}

// Make new ServerStates instance
serverStatesCache[uri] = makeServerStatesCacheEntry(blockbook)
Expand Down
279 changes: 279 additions & 0 deletions src/common/utxobased/network/BlockbookElectrum.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,279 @@
import { crypto } from 'altcoin-js'
// import { add, sub } from 'biggystring'
import {
asEither,
asJSON,
asObject,
asOptional,
asString,
asUnknown,
Cleaner
} from 'cleaners'
import { EdgeLog } from 'edge-core-js/types'

import { EngineEmitter } from '../../plugin/EngineEmitter'
import { PluginInfo } from '../../plugin/types'
import { addressToScriptPubkey } from '../keymanager/keymanager'
import { Blockbook, makeBlockbook } from './Blockbook'
import {
AddressResponse,
BlockbookAccountUtxo
// BlockbookTransaction,
// transactionMessage
} from './blockbookApi'
import Deferred from './Deferred'
import {
addressTransactionsMessage,
AddressTransactionsResponse,
// ElectrumTransaction,
listUnspentMessage,
ListUnspentResponse,
pingMessage
} from './electrumApi'
import { OnQueueSpaceCB, WsResponse, WsResponseMessage, WsTask } from './Socket'
import { SocketEmitter } from './SocketEmitter'

export interface BlockbookElectrumConfig {
asAddress?: Cleaner<string>
connectionUri: string
engineEmitter: EngineEmitter
log: EdgeLog
onQueueSpaceCB: OnQueueSpaceCB
pluginInfo: PluginInfo
socketEmitter: SocketEmitter
walletId: string
}

export interface ElectrumResponseMessage {
id: number
data: unknown
error: { message: string } | undefined
}

const asResponseMessage = (raw: unknown): WsResponseMessage => {
const message = asJSON(
asObject({
id: asString,
result: asUnknown,
error: asOptional(
asEither(
asObject({ message: asString }),
asObject({ connected: asString })
)
)
})
)(raw)

return {
id: message.id,
data: message.result,
error: message.error
}
}

const asResponse: Cleaner<WsResponse> = (raw: unknown) => {
const payloadString = asString(raw).trimEnd()
const messageStrings = payloadString.split('\n')
const messages = messageStrings.map(asResponseMessage)
return messages
}

export function makeBlockbookElectrum(
config: BlockbookElectrumConfig
): Blockbook {
const {
asAddress,
connectionUri,
engineEmitter,
log,
onQueueSpaceCB,
pluginInfo,
socketEmitter,
walletId
} = config
log(`makeBlockbookElectrum with uri ${connectionUri}`)

const addressToScriptHash = (address: string): string => {
const scriptPubkey = addressToScriptPubkey({
address,
coin: pluginInfo.coinInfo.name
})
const scriptHashBuffer = crypto.sha256(Buffer.from(scriptPubkey, 'hex'))
const scriptHash = scriptHashBuffer.reverse().toString('hex')
return scriptHash
}

const instance = makeBlockbook({
asAddress,
asResponse,
connectionUri,
engineEmitter,
log,
onQueueSpaceCB: async (
uri: string
): Promise<WsTask<unknown> | boolean | undefined> => {
const task = await onQueueSpaceCB(uri)
if (task == null || typeof task === 'boolean') return task

// Translate getAccountUtxo to blockchain.scripthash.listunspent:
if (task.method === 'getAccountUtxo') {
const params = task.params as { descriptor: string }
const address =
asAddress != null ? asAddress(params.descriptor) : params.descriptor
const scriptHash = addressToScriptHash(address)

const deferred = new Deferred<any>()
deferred.promise
.then((electrumUtxos: ListUnspentResponse): void => {
const blockbookUtxos: BlockbookAccountUtxo[] = electrumUtxos.map(
utxo => ({
txid: utxo.tx_hash,
vout: utxo.tx_pos,
value: utxo.value.toString(),
height: utxo.height
})
)
task.deferred.resolve(blockbookUtxos)
})
.catch((e): void => {
task.deferred.reject(e)
})

const translatedTask: WsTask<unknown> = {
...listUnspentMessage(scriptHash),
deferred
}
return translatedTask
}

// Get Address Transactions:
if (task.method === 'getAccountInfo') {
const params = task.params as { descriptor: string }
const address =
asAddress != null ? asAddress(params.descriptor) : params.descriptor
const scriptHash = addressToScriptHash(address)

const deferred = new Deferred<any>()
deferred.promise
.then((electrumUtxos: AddressTransactionsResponse): void => {
const blockbookUtxos: AddressResponse = {
address, // unused
balance: '0', // unused
totalReceived: '0', // unused
totalSent: '0', // unused
txs: electrumUtxos.length,
unconfirmedBalance: '0', // unused
unconfirmedTxs: electrumUtxos.reduce(
(sum, tx) => (sum + tx.height >= 0 ? 1 : 0),
0
),
txids: [], // unused
transactions: [], // TODO: this requires an extra query per txid
page: 0, // unused
totalPages: 1,
itemsOnPage: 0 // unused
}
task.deferred.resolve(blockbookUtxos)
})
.catch((e): void => {
task.deferred.reject(e)
})

const translatedTask: WsTask<unknown> = {
...addressTransactionsMessage(scriptHash),
deferred
}
return translatedTask
}

// Translate getTransaction to blockchain.transaction.get:
// **Unsupported until we have a way to translate inputs correctly**
/*
if (task.method === 'getTransaction') {
const params = task.params as { txid: string }
const txHash = params.txid
const deferred = new Deferred<any>()
deferred.promise
.then((electrumTx: ElectrumTransaction): void => {
const vin = electrumTx.vin.map(vin => ({
txid: vin.txid,
n: vin.vout,
vout: vin.vout,
addresses: [], // TODO: figure out how to translate addresses. Reviewer don't dismiss this
isAddress: false, // TODO: figure out how to translate isAddress. Reviewer don't dismiss this
value: '0' // TODO: figure out how to translate value. Reviewer don't dismiss this
}))
const vout = electrumTx.vout.map(vout => ({
addresses: vout.scriptPubKey.addresses,
n: vout.n,
value: vout.value.toString()
}))
const vinTotalValue = vin.reduce(
(sum, vin) => add(sum, vin.value),
'0'
)
const voutTotalValue = vout.reduce(
(sum, vout) => add(sum, vout.value),
'0'
)
const fees = sub(voutTotalValue, vinTotalValue)
const blockbookTx: BlockbookTransaction = {
txid: electrumTx.txid,
hex: electrumTx.hex,
blockHeight: -0, // TODO: figure out how to translate blockHeight. Reviewer don't dismiss this
confirmations: electrumTx.confirmations,
blockTime: electrumTx.blocktime,
fees,
vin,
vout
}
task.deferred.resolve(blockbookTx)
})
.catch((e): void => {
task.deferred.reject(e)
})
const translatedTask: WsTask<unknown> = {
...transactionMessage(txHash),
deferred
}
return translatedTask
}
*/

// Skip unsupported task and continue with the next one:
return true
},
ping: async (): Promise<void> => {
return await instance.promisifyWsMessage(pingMessage())
},
socketEmitter,
walletId
})

instance.broadcastTx = async () => {
throw new Error('broadcastTx not supported for Electrum connections')
}
instance.fetchAddress = async () => {
throw new Error('fetchAddress not supported for Electrum connections')
}
instance.fetchAddressUtxos = async () => {
throw new Error('fetchAddressUtxos not supported for Electrum connections')
}
instance.fetchInfo = async () => {
throw new Error('fetchInfo not supported for Electrum connections')
}
instance.fetchTransaction = async () => {
throw new Error('fetchTransaction not supported for Electrum connections')
}

instance.watchAddresses = async () => {
return // Fail gracefully
}
instance.watchBlocks = async () => {
return // Fail gracefully
}

return instance
}
Loading

0 comments on commit 190b4c6

Please sign in to comment.