@bsv/wallet-toolbox
Version:
BRC100 conforming wallet, wallet storage and wallet signer components
725 lines (632 loc) • 22.6 kB
text/typescript
import { Beef, HexString, Utils, WhatsOnChainConfig } from '@bsv/sdk'
import { asArray, asString, doubleSha256BE, sdk, Services, validateScriptHash, wait } from '../../index.client'
import { convertProofToMerklePath } from '../../utility/tscProofToMerklePath'
import SdkWhatsOnChain from './SdkWhatsOnChain'
import { parseWalletOutpoint, ReqHistoryNote } from '../../sdk'
/**
*
*/
export class WhatsOnChain extends SdkWhatsOnChain {
services: Services
constructor(chain: sdk.Chain = 'main', config: WhatsOnChainConfig = {}, services?: Services) {
super(chain, config)
this.services = services || new Services(chain)
}
/**
* POST
* https://api.whatsonchain.com/v1/bsv/main/txs/status
* Content-Type: application/json
* data: "{\"txids\":[\"6815f8014db74eab8b7f75925c68929597f1d97efa970109d990824c25e5e62b\"]}"
*
* result for a mined txid:
* [{
* "txid":"294cd1ebd5689fdee03509f92c32184c0f52f037d4046af250229b97e0c8f1aa",
* "blockhash":"000000000000000004b5ce6670f2ff27354a1e87d0a01bf61f3307f4ccd358b5",
* "blockheight":612251,
* "blocktime":1575841517,
* "confirmations":278272
* }]
*
* result for a valid recent txid:
* [{"txid":"6815f8014db74eab8b7f75925c68929597f1d97efa970109d990824c25e5e62b"}]
*
* result for an unknown txid:
* [{"txid":"6815f8014db74eab8b7f75925c68929597f1d97efa970109d990824c25e5e62c","error":"unknown"}]
*/
async getStatusForTxids(txids: string[]): Promise<sdk.GetStatusForTxidsResult> {
const r: sdk.GetStatusForTxidsResult = {
name: 'WoC',
status: 'error',
error: undefined,
results: []
}
const requestOptions = {
method: 'POST',
headers: this.getHttpHeaders(),
data: { txids }
}
const url = `${this.URL}/txs/status`
try {
const response = await this.httpClient.request<WhatsOnChainTxsStatusData[]>(url, requestOptions)
if (!response.data || !response.ok || response.status !== 200)
throw new sdk.WERR_INVALID_OPERATION(`Unable to get status for txids at this timei.`)
const data = response.data
for (const txid of txids) {
const d = data.find(d => d.txid === txid)
if (!d || d.error === 'unknown') r.results.push({ txid, status: 'unknown', depth: undefined })
else if (d.error !== undefined) {
console.log(`WhatsOnChain getStatusForTxids unexpected error ${d.error} ${txid}`)
r.results.push({ txid, status: 'unknown', depth: undefined })
} else if (d.confirmations === undefined) r.results.push({ txid, status: 'known', depth: 0 })
else r.results.push({ txid, status: 'mined', depth: d.confirmations })
}
r.status = 'success'
} catch (eu: unknown) {
const e = sdk.WalletError.fromUnknown(eu)
r.error = e
}
return r
}
/**
* 2025-02-16 throwing internal server error 500.
* @param txid
* @returns
*/
async getTxPropagation(txid: string): Promise<number> {
const requestOptions = {
method: 'GET',
headers: this.getHttpHeaders()
}
const response = await this.httpClient.request<string>(`${this.URL}/tx/hash/${txid}/propagation`, requestOptions)
// response.statusText is often, but not always 'OK' on success...
if (!response.data || !response.ok || response.status !== 200)
throw new sdk.WERR_INVALID_PARAMETER('txid', `valid transaction. '${txid}' response ${response.statusText}`)
return 0
}
/**
* May return undefined for unmined transactions that are in the mempool.
* @param txid
* @returns raw transaction as hex string or undefined if txid not found in mined block.
*/
async getRawTx(txid: string): Promise<string | undefined> {
const headers = this.getHttpHeaders()
headers['Cache-Control'] = 'no-cache'
const requestOptions = {
method: 'GET',
headers
}
const url = `${this.URL}/tx/${txid}/hex`
for (let retry = 0; retry < 2; retry++) {
const response = await this.httpClient.request<string>(url, requestOptions)
if (response.statusText === 'Too Many Requests' && retry < 2) {
await wait(2000)
continue
}
if (response.status === 404 && response.statusText === 'Not Found') return undefined
// response.statusText is often, but not always 'OK' on success...
if (!response.data || !response.ok || response.status !== 200)
throw new sdk.WERR_INVALID_PARAMETER('txid', `valid transaction. '${txid}' response ${response.statusText}`)
return response.data
}
throw new sdk.WERR_INTERNAL()
}
async getRawTxResult(txid: string): Promise<sdk.GetRawTxResult> {
const r: sdk.GetRawTxResult = { name: 'WoC', txid: asString(txid) }
try {
const rawTxHex = await this.getRawTx(txid)
if (rawTxHex) r.rawTx = asArray(rawTxHex)
} catch (err: unknown) {
r.error = sdk.WalletError.fromUnknown(err)
}
return r
}
/**
* WhatsOnChain does not natively support a postBeef end-point aware of multiple txids of interest in the Beef.
*
* Send rawTx in `txids` order from beef.
*
* @param beef
* @param txids
* @returns
*/
async postBeef(beef: Beef, txids: string[]): Promise<sdk.PostBeefResult> {
const r: sdk.PostBeefResult = {
name: 'WoC',
status: 'success',
txidResults: [],
notes: []
}
let delay = false
const nn = () => ({ name: 'WoCpostBeef', when: new Date().toISOString() })
const nne = () => ({ ...nn(), beef: beef.toHex(), txids: txids.join(',') })
for (const txid of txids) {
const rawTx = Utils.toHex(beef.findTxid(txid)!.rawTx!)
if (delay) {
// For multiple txids, give WoC time to propagate each one.
await wait(3000)
}
delay = true
const tr = await this.postRawTx(rawTx)
if (txid !== tr.txid) {
tr.notes!.push({ ...nne(), what: 'postRawTxTxidChanged', txid, trTxid: tr.txid })
}
r.txidResults.push(tr)
if (r.status === 'success' && tr.status !== 'success') r.status = 'error'
}
if (r.status === 'success') {
r.notes!.push({ ...nn(), what: 'postBeefSuccess' })
} else {
r.notes!.push({ ...nne(), what: 'postBeefError' })
}
return r
}
/**
* @param rawTx raw transaction to broadcast as hex string
* @returns txid returned by transaction processor of transaction broadcast
*/
async postRawTx(rawTx: HexString): Promise<sdk.PostTxResultForTxid> {
let txid = Utils.toHex(doubleSha256BE(Utils.toArray(rawTx, 'hex')))
const r: sdk.PostTxResultForTxid = {
txid,
status: 'success',
notes: []
}
const headers = this.getHttpHeaders()
headers['Content-Type'] = 'application/json'
headers['Accept'] = 'text/plain'
const requestOptions = {
method: 'POST',
headers,
data: { txhex: rawTx }
}
const url = `${this.URL}/tx/raw`
const nn = () => ({ name: 'WoCpostRawTx', when: new Date().toISOString() })
const nne = () => ({ ...nn(), rawTx, txid, url })
const retryLimit = 5
for (let retry = 0; retry < retryLimit; retry++) {
try {
const response = await this.httpClient.request<string>(url, requestOptions)
if (response.statusText === 'Too Many Requests' && retry < 2) {
r.notes!.push({ ...nn(), what: 'postRawTxRateLimit' })
await wait(2000)
continue
}
if (response.ok) {
const txid = response.data
r.notes!.push({ ...nn(), what: 'postRawTxSuccess' })
} else if (response.data === 'unexpected response code 500: Transaction already in the mempool') {
r.notes!.push({ ...nne(), what: 'postRawTxSuccessAlreadyInMempool' })
} else {
r.status = 'error'
if (response.data === 'unexpected response code 500: 258: txn-mempool-conflict') {
r.doubleSpend = true // this is a possible double spend attempt
r.competingTxs = undefined // not provided with any data for this.
r.notes!.push({ ...nne(), what: 'postRawTxErrorMempoolConflict' })
} else if (response.data === 'unexpected response code 500: Missing inputs') {
r.doubleSpend = true // this is a possible double spend attempt
r.competingTxs = undefined // not provided with any data for this.
r.notes!.push({ ...nne(), what: 'postRawTxErrorMissingInputs' })
} else {
const n: ReqHistoryNote = {
...nne(),
what: 'postRawTxError'
}
if (typeof response.data === 'string') {
n.data = response.data.slice(0, 128)
r.data = response.data
} else {
r.data = ''
}
if (typeof response.statusText === 'string') {
n.statusText = response.statusText.slice(0, 128)
r.data += `,${response.statusText}`
}
if (typeof response.status === 'string') {
n.status = (response.status as string).slice(0, 128)
r.data += `,${response.status}`
}
if (typeof response.status === 'number') {
n.status = response.status
r.data += `,${response.status}`
}
r.notes!.push(n)
}
}
} catch (eu: unknown) {
r.status = 'error'
const e = sdk.WalletError.fromUnknown(eu)
r.notes!.push({
...nne(),
what: 'postRawTxCatch',
code: e.code,
description: e.description
})
r.serviceError = true
r.data = `${e.code} ${e.description}`
}
return r
}
r.status = 'error'
r.serviceError = true
r.notes!.push({
...nne(),
what: 'postRawTxRetryLimit',
retryLimit
})
return r
}
/**
* @param txid
* @returns
*/
async getMerklePath(txid: string, services: sdk.WalletServices): Promise<sdk.GetMerklePathResult> {
const r: sdk.GetMerklePathResult = { name: 'WoCTsc', notes: [] }
const headers = this.getHttpHeaders()
const requestOptions = {
method: 'GET',
headers
}
for (let retry = 0; retry < 2; retry++) {
try {
const response = await this.httpClient.request<WhatsOnChainTscProof | WhatsOnChainTscProof[]>(
`${this.URL}/tx/${txid}/proof/tsc`,
requestOptions
)
if (response.statusText === 'Too Many Requests' && retry < 2) {
r.notes!.push({
what: 'getMerklePathRetry',
name: r.name,
status: response.status,
statusText: response.statusText
})
await wait(2000)
continue
}
if (response.status === 404 && response.statusText === 'Not Found') {
r.notes!.push({
what: 'getMerklePathNotFound',
name: r.name,
status: response.status,
statusText: response.statusText
})
return r
}
// response.statusText is often, but not always 'OK' on success...
if (!response.ok || response.status !== 200) {
r.notes!.push({
what: 'getMerklePathBadStatus',
name: r.name,
status: response.status,
statusText: response.statusText
})
throw new sdk.WERR_INVALID_PARAMETER('txid', `valid transaction. '${txid}' response ${response.statusText}`)
}
if (!response.data) {
// Unmined, proof not yet available.
r.notes!.push({
what: 'getMerklePathNoData',
name: r.name,
status: response.status,
statusText: response.statusText
})
return r
}
if (!Array.isArray(response.data)) response.data = [response.data]
if (response.data.length != 1) return r
const p = response.data[0]
const header = await services.hashToHeader(p.target)
if (header) {
const proof = {
index: p.index,
nodes: p.nodes,
height: header.height
}
r.merklePath = convertProofToMerklePath(txid, proof)
r.header = header
r.notes!.push({
what: 'getMerklePathSuccess',
name: r.name,
status: response.status,
statusText: response.statusText
})
} else {
r.notes!.push({
what: 'getMerklePathNoHeader',
target: p.target,
name: r.name,
status: response.status,
statusText: response.statusText
})
throw new sdk.WERR_INVALID_PARAMETER('blockhash', 'a valid on-chain block hash')
}
} catch (eu: unknown) {
const e = sdk.WalletError.fromUnknown(eu)
r.notes!.push({
what: 'getMerklePathError',
name: r.name,
code: e.code,
description: e.description
})
r.error = e
}
return r
}
r.notes!.push({ what: 'getMerklePathInternal', name: r.name })
throw new sdk.WERR_INTERNAL()
}
async updateBsvExchangeRate(rate?: sdk.BsvExchangeRate, updateMsecs?: number): Promise<sdk.BsvExchangeRate> {
if (rate) {
// Check if the rate we know is stale enough to update.
updateMsecs ||= 1000 * 60 * 15
if (new Date(Date.now() - updateMsecs) < rate.timestamp) return rate
}
const requestOptions = {
method: 'GET',
headers: this.getHttpHeaders()
}
for (let retry = 0; retry < 2; retry++) {
const response = await this.httpClient.request<{
rate: number
time: number
currency: string
}>(`${this.URL}/exchangerate`, requestOptions)
if (response.statusText === 'Too Many Requests' && retry < 2) {
await wait(2000)
continue
}
// response.statusText is often, but not always 'OK' on success...
if (!response.data || !response.ok || response.status !== 200)
throw new sdk.WERR_INVALID_OPERATION(`WoC exchangerate response ${response.statusText}`)
const wocrate = response.data
if (wocrate.currency !== 'USD') wocrate.rate = NaN
const newRate: sdk.BsvExchangeRate = {
timestamp: new Date(),
base: 'USD',
rate: wocrate.rate
}
return newRate
}
throw new sdk.WERR_INTERNAL()
}
async getUtxoStatus(
output: string,
outputFormat?: sdk.GetUtxoStatusOutputFormat,
outpoint?: string
): Promise<sdk.GetUtxoStatusResult> {
const r: sdk.GetUtxoStatusResult = {
name: 'WoC',
status: 'error',
error: new sdk.WERR_INTERNAL(),
details: []
}
for (let retry = 0; ; retry++) {
let url: string = ''
try {
const scriptHash = validateScriptHash(output, outputFormat)
const requestOptions = {
method: 'GET',
headers: this.getHttpHeaders()
}
const response = await this.httpClient.request<WhatsOnChainUtxoStatus>(
`${this.URL}/script/${scriptHash}/unspent/all`,
requestOptions
)
if (response.statusText === 'Too Many Requests' && retry < 2) {
await wait(2000)
continue
}
// response.statusText is often, but not always 'OK' on success...
if (!response.data || !response.ok || response.status !== 200)
throw new sdk.WERR_INVALID_OPERATION(`WoC getUtxoStatus response ${response.statusText}`)
const data = response.data
if (data.script !== scriptHash || !Array.isArray(data.result)) {
throw new sdk.WERR_INTERNAL('data. is not an array')
}
if (data.result.length === 0) {
r.status = 'success'
r.error = undefined
r.isUtxo = false
} else {
r.status = 'success'
r.error = undefined
for (const s of data.result) {
r.details.push({
txid: s.tx_hash,
satoshis: s.value,
height: s.height,
index: s.tx_pos
})
}
if (outpoint) {
const { txid, vout } = parseWalletOutpoint(outpoint)
r.isUtxo = r.details.find(d => d.txid === txid && d.index === vout) !== undefined
} else r.isUtxo = r.details.length > 0
}
return r
} catch (eu: unknown) {
const e = sdk.WalletError.fromUnknown(eu)
if (e.code !== 'ECONNRESET' || retry > 2) {
r.error = new sdk.WERR_INTERNAL(
`service failure: ${url}, error: ${JSON.stringify(sdk.WalletError.fromUnknown(eu))}`
)
return r
}
}
}
}
async getScriptHashConfirmedHistory(hash: string): Promise<sdk.GetScriptHashHistoryResult> {
const r: sdk.GetScriptHashHistoryResult = {
name: 'WoC',
status: 'error',
error: undefined,
history: []
}
// reverse hash from LE to BE for Woc
hash = Utils.toHex(Utils.toArray(hash, 'hex').reverse())
const url = `${this.URL}/script/${hash}/confirmed/history`
for (let retry = 0; ; retry++) {
try {
const requestOptions = {
method: 'GET',
headers: this.getHttpHeaders()
}
const response = await this.httpClient.request<WhatsOnChainScriptHashHistoryData>(url, requestOptions)
if (response.statusText === 'Too Many Requests' && retry < 2) {
await wait(2000)
continue
}
if (!response.ok && response.status === 404) {
// There is no history for this script hash...
r.status = 'success'
return r
}
// response.statusText is often, but not always 'OK' on success...
if (!response.data || !response.ok || response.status !== 200) {
r.error = new sdk.WERR_BAD_REQUEST(
`WoC getScriptHashConfirmedHistory response ${response.ok} ${response.status} ${response.statusText}`
)
return r
}
if (response.data.error) {
r.error = new sdk.WERR_BAD_REQUEST(`WoC getScriptHashConfirmedHistory error ${response.data.error}`)
return r
}
r.history = response.data.result.map(d => ({ txid: d.tx_hash, height: d.height }))
r.status = 'success'
return r
} catch (eu: unknown) {
const e = sdk.WalletError.fromUnknown(eu)
if (e.code !== 'ECONNRESET' || retry > 2) {
r.error = new sdk.WERR_INTERNAL(
`WoC getScriptHashConfirmedHistory service failure: ${url}, error: ${JSON.stringify(sdk.WalletError.fromUnknown(eu))}`
)
return r
}
}
}
return r
}
async getScriptHashUnconfirmedHistory(hash: string): Promise<sdk.GetScriptHashHistoryResult> {
const r: sdk.GetScriptHashHistoryResult = {
name: 'WoC',
status: 'error',
error: undefined,
history: []
}
// reverse hash from LE to BE for Woc
hash = Utils.toHex(Utils.toArray(hash, 'hex').reverse())
const url = `${this.URL}/script/${hash}/unconfirmed/history`
for (let retry = 0; ; retry++) {
try {
const requestOptions = {
method: 'GET',
headers: this.getHttpHeaders()
}
const response = await this.httpClient.request<WhatsOnChainScriptHashHistoryData>(url, requestOptions)
if (response.statusText === 'Too Many Requests' && retry < 2) {
await wait(2000)
continue
}
if (!response.ok && response.status === 404) {
// There is no history for this script hash...
r.status = 'success'
return r
}
// response.statusText is often, but not always 'OK' on success...
if (!response.data || !response.ok || response.status !== 200) {
r.error = new sdk.WERR_BAD_REQUEST(
`WoC getScriptHashUnconfirmedHistory response ${response.ok} ${response.status} ${response.statusText}`
)
return r
}
if (response.data.error) {
r.error = new sdk.WERR_BAD_REQUEST(`WoC getScriptHashUnconfirmedHistory error ${response.data.error}`)
return r
}
r.history = response.data.result.map(d => ({ txid: d.tx_hash, height: d.height }))
r.status = 'success'
return r
} catch (eu: unknown) {
const e = sdk.WalletError.fromUnknown(eu)
if (e.code !== 'ECONNRESET' || retry > 2) {
r.error = new sdk.WERR_INTERNAL(
`WoC getScriptHashUnconfirmedHistory service failure: ${url}, error: ${JSON.stringify(sdk.WalletError.fromUnknown(eu))}`
)
return r
}
}
}
return r
}
async getScriptHashHistory(hash: string): Promise<sdk.GetScriptHashHistoryResult> {
const r1 = await this.getScriptHashConfirmedHistory(hash)
if (r1.error || r1.status !== 'success') return r1
const r2 = await this.getScriptHashUnconfirmedHistory(hash)
if (r2.error || r2.status !== 'success') return r2
r1.history = r1.history.concat(r2.history)
return r1
}
}
interface WhatsOnChainTscProof {
index: number
nodes: string[]
target: string
txOrId: string
}
interface WhatsOnChainScriptHashHistory {
tx_hash: string
height?: number
}
interface WhatsOnChainScriptHashHistoryData {
script: string
result: WhatsOnChainScriptHashHistory[]
error?: string
nextPageToken?: string
}
interface WhatsOnChainTxsStatusData {
txid: string
blockhash?: string
blockheight?: number
blocktime?: number
confirmations?: number
/**
* 'unknown' if txid isn't known
*/
error?: string
}
/**
* GET https://api.whatsonchain.com/v1/bsv/<network>/script/<scriptHash>/unspent/all
*
* Response
{
"error":"",
"status":200,
"statusText":"OK",
"ok":true,
"data":{
"script":"d3ef8eeb691e7405caca142bfcd6f499b142884d7883e6701a0ee76047b4af32",
"result":[
{
"height":893652,
"tx_pos":11,
"tx_hash":"2178a1e93d46edda946d9069f9b157ddfacb451fee0278e657941f09bfdb5d8f",
"value":1005,
"isSpentInMempoolTx":false,
"status":"confirmed"
}
]
}
}
*
*/
interface WhatsOnChainUtxoStatus {
script: string
result: {
value: number
height: number
tx_pos: number
tx_hash: string
isSpentInMempoolTx: boolean
status: string // 'confirmed'
}[]
}