wallet-storage
Version:
BRC100 conforming wallet, wallet storage and wallet signer components
462 lines (406 loc) • 13.3 kB
text/typescript
import {
Transaction as BsvTransaction,
Beef,
ChainTracker,
Utils
} from '@bsv/sdk'
import {
asArray,
asString,
doubleSha256BE,
sdk,
sha256Hash,
wait
} from '../index.client'
import { ServiceCollection } from './ServiceCollection'
import { createDefaultWalletServicesOptions } from './createDefaultWalletServicesOptions'
import { ChaintracksChainTracker } from './chaintracker'
import {
getTaalArcServiceConfig,
makePostBeefToTaalARC,
makePostTxsToTaalARC
} from './providers/arcServices'
import {
getMerklePathFromWhatsOnChainTsc,
getRawTxFromWhatsOnChain,
getUtxoStatusFromWhatsOnChain,
updateBsvExchangeRate
} from './providers/whatsonchain'
import {
updateChaintracksFiatExchangeRates,
updateExchangeratesapi
} from './providers/echangeRates'
export class Services implements sdk.WalletServices {
static createDefaultOptions(chain: sdk.Chain): sdk.WalletServicesOptions {
return createDefaultWalletServicesOptions(chain)
}
options: sdk.WalletServicesOptions
getMerklePathServices: ServiceCollection<sdk.GetMerklePathService>
getRawTxServices: ServiceCollection<sdk.GetRawTxService>
postTxsServices: ServiceCollection<sdk.PostTxsService>
postBeefServices: ServiceCollection<sdk.PostBeefService>
getUtxoStatusServices: ServiceCollection<sdk.GetUtxoStatusService>
updateFiatExchangeRateServices: ServiceCollection<sdk.UpdateFiatExchangeRateService>
chain: sdk.Chain
constructor(optionsOrChain: sdk.Chain | sdk.WalletServicesOptions) {
this.chain =
typeof optionsOrChain === 'string' ? optionsOrChain : optionsOrChain.chain
this.options =
typeof optionsOrChain === 'string'
? Services.createDefaultOptions(this.chain)
: optionsOrChain
this.getMerklePathServices =
new ServiceCollection<sdk.GetMerklePathService>().add({
name: 'WhatsOnChainTsc',
service: getMerklePathFromWhatsOnChainTsc
})
//.add({ name: 'Taal', service: makeGetMerklePathFromTaalARC(getTaalArcServiceConfig(this.chain, this.options.taalApiKey!)) })
this.getRawTxServices = new ServiceCollection<sdk.GetRawTxService>().add({
name: 'WhatsOnChain',
service: getRawTxFromWhatsOnChain
})
this.postTxsServices = new ServiceCollection<sdk.PostTxsService>().add({
name: 'TaalArcTxs',
service: makePostTxsToTaalARC(
getTaalArcServiceConfig(this.chain, this.options.taalApiKey!)
)
})
this.postBeefServices = new ServiceCollection<sdk.PostBeefService>().add({
name: 'TaalArcBeef',
service: makePostBeefToTaalARC(
getTaalArcServiceConfig(this.chain, this.options.taalApiKey!)
)
})
this.getUtxoStatusServices =
new ServiceCollection<sdk.GetUtxoStatusService>().add({
name: 'WhatsOnChain',
service: getUtxoStatusFromWhatsOnChain
})
this.updateFiatExchangeRateServices =
new ServiceCollection<sdk.UpdateFiatExchangeRateService>()
.add({
name: 'ChaintracksService',
service: updateChaintracksFiatExchangeRates
})
.add({ name: 'exchangeratesapi', service: updateExchangeratesapi })
}
async getChainTracker(): Promise<ChainTracker> {
if (!this.options.chaintracks)
throw new sdk.WERR_INVALID_PARAMETER(
'options.chaintracks',
`valid to enable 'getChainTracker' service.`
)
return new ChaintracksChainTracker(this.chain, this.options.chaintracks)
}
async getBsvExchangeRate(): Promise<number> {
this.options.bsvExchangeRate = await updateBsvExchangeRate(
this.options.bsvExchangeRate,
this.options.bsvUpdateMsecs
)
return this.options.bsvExchangeRate.rate
}
async getFiatExchangeRate(
currency: 'USD' | 'GBP' | 'EUR',
base?: 'USD' | 'GBP' | 'EUR'
): Promise<number> {
const rates = await this.updateFiatExchangeRates(
this.options.fiatExchangeRates,
this.options.fiatUpdateMsecs
)
this.options.fiatExchangeRates = rates
base ||= 'USD'
const rate = rates.rates[currency] / rates.rates[base]
return rate
}
get getProofsCount() {
return this.getMerklePathServices.count
}
get getRawTxsCount() {
return this.getRawTxServices.count
}
get postTxsServicesCount() {
return this.postTxsServices.count
}
get postBeefServicesCount() {
return this.postBeefServices.count
}
get getUtxoStatsCount() {
return this.getUtxoStatusServices.count
}
async getUtxoStatus(
output: string,
outputFormat?: sdk.GetUtxoStatusOutputFormat,
useNext?: boolean
): Promise<sdk.GetUtxoStatusResult> {
const services = this.getUtxoStatusServices
if (useNext) services.next()
let r0: sdk.GetUtxoStatusResult = {
name: '<noservices>',
status: 'error',
error: new sdk.WERR_INTERNAL('No services available.'),
details: []
}
for (let retry = 0; retry < 2; retry++) {
for (let tries = 0; tries < services.count; tries++) {
const service = services.service
const r = await service(output, this.chain, outputFormat)
if (r.status === 'success') {
r0 = r
break
}
services.next()
}
if (r0.status === 'success') break
await wait(2000)
}
return r0
}
/**
* The beef must contain at least each rawTx for each txid.
* Some services may require input transactions as well.
* These will be fetched if missing, greatly extending the service response time.
* @param beef
* @param txids
* @returns
*/
async postTxs(beef: Beef, txids: string[]): Promise<sdk.PostTxsResult[]> {
const rs = await Promise.all(
this.postTxsServices.allServices.map(async service => {
const r = await service(beef, txids, this)
return r
})
)
return rs
}
/**
*
* @param beef
* @param chain
* @returns
*/
async postBeef(beef: Beef, txids: string[]): Promise<sdk.PostBeefResult[]> {
let rs = await Promise.all(
this.postBeefServices.allServices.map(async service => {
const r = await service(beef, txids, this)
return r
})
)
if (rs.every(r => r.status !== 'success')) {
rs = await this.postTxs(beef, txids)
}
return rs
}
async getRawTx(txid: string, useNext?: boolean): Promise<sdk.GetRawTxResult> {
if (useNext) this.getRawTxServices.next()
const r0: sdk.GetRawTxResult = { txid }
for (let tries = 0; tries < this.getRawTxServices.count; tries++) {
const service = this.getRawTxServices.service
const r = await service(txid, this.chain)
if (r.rawTx) {
const hash = asString(doubleSha256BE(r.rawTx!))
// Confirm transaction hash matches txid
if (hash === asString(txid)) {
// If we have a match, call it done.
r0.rawTx = r.rawTx
r0.name = r.name
r0.error = undefined
break
}
r.error = new sdk.WERR_INTERNAL(
`computed txid ${hash} doesn't match requested value ${txid}`
)
r.rawTx = undefined
}
if (r.error && !r0.error && !r0.rawTx)
// If we have an error and didn't before...
r0.error = r.error
this.getRawTxServices.next()
}
return r0
}
async invokeChaintracksWithRetry<R>(method: () => Promise<R>): Promise<R> {
if (!this.options.chaintracks)
throw new sdk.WERR_INVALID_PARAMETER(
'options.chaintracks',
'valid for this service operation.'
)
for (let retry = 0; retry < 3; retry++) {
try {
const r: R = await method()
return r
} catch (eu: unknown) {
const e = sdk.WalletError.fromUnknown(eu)
if (e.code != 'ECONNRESET') throw eu
}
}
throw new sdk.WERR_INVALID_OPERATION('hashToHeader service unavailable')
}
async getHeaderForHeight(height: number): Promise<number[]> {
const method = async () => {
const header = await this.options.chaintracks!.findHeaderForHeight(height)
if (!header)
throw new sdk.WERR_INVALID_PARAMETER(
'hash',
`valid height '${height}' on mined chain ${this.chain}`
)
return toBinaryBaseBlockHeader(header)
}
return this.invokeChaintracksWithRetry(method)
}
async getHeight(): Promise<number> {
const method = async () => {
return await this.options.chaintracks!.currentHeight()
}
return this.invokeChaintracksWithRetry(method)
}
async hashToHeader(hash: string): Promise<sdk.BlockHeader> {
const method = async () => {
const header =
await this.options.chaintracks!.findHeaderForBlockHash(hash)
if (!header)
throw new sdk.WERR_INVALID_PARAMETER(
'hash',
`valid blockhash '${hash}' on mined chain ${this.chain}`
)
return header
}
return this.invokeChaintracksWithRetry(method)
}
async getMerklePath(
txid: string,
useNext?: boolean
): Promise<sdk.GetMerklePathResult> {
if (useNext) this.getMerklePathServices.next()
const r0: sdk.GetMerklePathResult = {}
for (let tries = 0; tries < this.getMerklePathServices.count; tries++) {
const service = this.getMerklePathServices.service
const r = await service(txid, this.chain, this)
if (r.merklePath) {
// If we have a proof, call it done.
r0.merklePath = r.merklePath
r0.header = r.header
r0.name = r.name
r0.error = undefined
break
} else if (r.error && !r0.error)
// If we have an error and didn't before...
r0.error = r.error
this.getMerklePathServices.next()
}
return r0
}
targetCurrencies = ['USD', 'GBP', 'EUR']
async updateFiatExchangeRates(
rates?: sdk.FiatExchangeRates,
updateMsecs?: number
): Promise<sdk.FiatExchangeRates> {
updateMsecs ||= 1000 * 60 * 15
const freshnessDate = new Date(Date.now() - updateMsecs)
if (rates) {
// Check if the rate we know is stale enough to update.
updateMsecs ||= 1000 * 60 * 15
if (rates.timestamp > freshnessDate) return rates
}
// Make sure we always start with the first service listed (chaintracks aggregator)
const services = this.updateFiatExchangeRateServices.clone()
let r0: sdk.FiatExchangeRates | undefined
for (let tries = 0; tries < services.count; tries++) {
const service = services.service
try {
const r = await service(this.targetCurrencies, this.options)
if (this.targetCurrencies.every(c => typeof r.rates[c] === 'number')) {
r0 = r
break
}
} catch (eu: unknown) {
const e = sdk.WalletError.fromUnknown(eu)
console.error(
`updateFiatExchangeRates servcice name ${service.name} error ${e.message}`
)
}
services.next()
}
if (!r0) {
console.error('Failed to update fiat exchange rates.')
if (!rates) throw new sdk.WERR_INTERNAL()
return rates
}
return r0
}
async nLockTimeIsFinal(
tx: string | number[] | BsvTransaction | number
): Promise<boolean> {
const MAXINT = 0xffffffff
const BLOCK_LIMIT = 500000000
let nLockTime: number
if (typeof tx === 'number') nLockTime = tx
else {
if (typeof tx === 'string') {
tx = BsvTransaction.fromHex(tx)
} else if (Array.isArray(tx)) {
tx = BsvTransaction.fromBinary(tx)
}
if (tx instanceof BsvTransaction) {
if (tx.inputs.every(i => i.sequence === MAXINT)) {
return true
}
nLockTime = tx.lockTime
} else {
throw new sdk.WERR_INTERNAL(
'Should be either @bsv/sdk Transaction or babbage-bsv Transaction'
)
}
}
if (nLockTime >= BLOCK_LIMIT) {
const limit = Math.floor(Date.now() / 1000)
return nLockTime < limit
}
const height = await this.getHeight()
return nLockTime < height
}
}
export function validateScriptHash(
output: string,
outputFormat?: sdk.GetUtxoStatusOutputFormat
): string {
let b = asArray(output)
if (!outputFormat) {
if (b.length === 32) outputFormat = 'hashLE'
else outputFormat = 'script'
}
switch (outputFormat) {
case 'hashBE':
break
case 'hashLE':
b = b.reverse()
break
case 'script':
b = sha256Hash(b).reverse()
break
default:
throw new sdk.WERR_INVALID_PARAMETER(
'outputFormat',
`not be ${outputFormat}`
)
}
return asString(b)
}
/**
* Serializes a block header as an 80 byte array.
* The exact serialized format is defined in the Bitcoin White Paper
* such that computing a double sha256 hash of the array computes
* the block hash for the header.
* @returns 80 byte array
* @publicbody
*/
export function toBinaryBaseBlockHeader(header: sdk.BaseBlockHeader): number[] {
const writer = new Utils.Writer()
writer.writeUInt32BE(header.version)
writer.writeReverse(asArray(header.previousHash))
writer.writeReverse(asArray(header.merkleRoot))
writer.writeUInt32BE(header.time)
writer.writeUInt32BE(header.bits)
writer.writeUInt32BE(header.nonce)
const r = writer.toArray()
return r
}