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 9, 2024
1 parent 477e911 commit 38abb8f
Show file tree
Hide file tree
Showing 5 changed files with 491 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
216 changes: 216 additions & 0 deletions src/common/utxobased/network/BlockbookElectrum.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
import { crypto } from 'altcoin-js'
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 } from './blockbookApi'
import Deferred from './Deferred'
import {
addressTransactionsMessage,
AddressTransactionsResponse,
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
}

// 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
}
12 changes: 11 additions & 1 deletion src/common/utxobased/network/Socket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -391,7 +391,17 @@ export function makeSocket(uri: string, config: SocketConfig): Socket {
}

// Append "/websocket" if needed:
const fullUri = uri.replace(/\/websocket\/?$/, '') + '/websocket'
let fullUri = uri
if (uri.startsWith('wss:') || uri.startsWith('ws:')) {
fullUri = uri.replace(/\/websocket\/?$/, '') + '/websocket'
}
if (uri.startsWith('electrumwss:')) {
fullUri = uri.replace(/^electrumwss:/, 'wss:')
}
if (uri.startsWith('electrumws:')) {
fullUri = uri.replace(/^electrumws:/, 'ws:')
}

try {
socket = setupBrowser(fullUri, cbs)
} catch {
Expand Down
Loading

0 comments on commit 38abb8f

Please sign in to comment.