UNPKG

wallet-storage

Version:

BRC100 conforming wallet, wallet storage and wallet signer components

463 lines (401 loc) 15.7 kB
import { Transaction as BsvTransaction, ARC, ArcConfig, Beef, defaultHttpClient, HttpClient, HttpClientRequestOptions, MerklePath } from '@bsv/sdk' import { asArray, randomBytesHex, sdk } from "../../index.client"; import axios from 'axios' import { Readable } from 'stream' import { PostTxResultForTxid } from '../../sdk'; const BEEF_V1 = 4022206465 // 0100BEEF in LE order const BEEF_V2 = 4022206466 // 0200BEEF in LE order // Documentation: // https://docs.taal.com/ // https://docs.taal.com/core-products/transaction-processing/arc-endpoints // https://bitcoin-sv.github.io/arc/api.html export interface ArcServiceConfig { name: string, url: string, arcConfig: ArcConfig } export function getTaalArcServiceConfig(chain: sdk.Chain, apiKey: string): ArcServiceConfig { return { name: 'TaalArc', url: chain === 'main' ? 'https://api.taal.com/arc' : 'https://arc-test.taal.com', arcConfig: { apiKey, deploymentId: `WalletServices-${randomBytesHex(16)}` }, } } export function makePostTxsToTaalARC(config: ArcServiceConfig): sdk.PostTxsService { return (beef: Beef, txids: string[], services: sdk.WalletServices) => { return postTxsToTaalArcMiner(beef, txids, config, services) } } export function makePostBeefToTaalARC(config: ArcServiceConfig) : sdk.PostBeefService { return (beef: Beef, txids: string[], services: sdk.WalletServices) => { return postBeefToTaalArcMiner(beef, txids, config, services) } } export function makeGetMerklePathFromTaalARC(config: ArcServiceConfig) : sdk.GetMerklePathService { return (txid: string, chain: sdk.Chain, services: sdk.WalletServices) => { return getMerklePathFromTaalARC(txid, config, services) } } class ArcServices { readonly URL: string readonly apiKey: string | undefined readonly deploymentId: string readonly callbackUrl: string | undefined readonly callbackToken: string | undefined readonly headers: Record<string, string> | undefined private readonly httpClient: HttpClient /** * Constructs an instance of the ARC broadcaster. * * @param {string} URL - The URL endpoint for the ARC API. * @param {ArcConfig} config - Configuration options for the ARC broadcaster. */ constructor(URL: string, config: ArcConfig) { this.URL = URL const { apiKey, deploymentId, httpClient, callbackToken, callbackUrl, headers } = config this.apiKey = apiKey this.httpClient = httpClient ?? defaultHttpClient() this.deploymentId = deploymentId || `WalletServices-${randomBytesHex(16)}` this.callbackToken = callbackToken this.callbackUrl = callbackUrl this.headers = headers } /** * Unfortunately this seems to only work for recently submitted txids... * @param txid * @returns */ async getTxStatus(txid: string): Promise<ArcMinerGetTxData> { const requestOptions: HttpClientRequestOptions = { method: 'GET', headers: this.requestHeaders(), } const response = await this.httpClient.request<ArcMinerGetTxData>(`${this.URL}/v1/tx/${txid}`, requestOptions) return response.data } requestHeaders() { const headers: Record<string, string> = { 'Content-Type': 'application/json', 'XDeployment-ID': this.deploymentId } if (this.apiKey) { headers.Authorization = `Bearer ${this.apiKey}` } if (this.callbackUrl) { headers['X-CallbackUrl'] = this.callbackUrl } if (this.callbackToken) { headers['X-CallbackToken'] = this.callbackToken } if (this.headers) { for (const key in this.headers) { headers[key] = this.headers[key] } } return headers } } export async function getMerklePathFromTaalARC(txid: string, config: ArcServiceConfig, services: sdk.WalletServices): Promise<sdk.GetMerklePathResult> { const r: sdk.GetMerklePathResult = { name: config.name } try { const arc = new ArcServices(config.url, config.arcConfig) const rr = await arc.getTxStatus(txid) if (rr.status === 200 && rr.merklePath) { const mp = MerklePath.fromHex(rr.merklePath) r.merklePath = mp r.header = await services.hashToHeader(rr.blockHash) } } catch (eu: unknown) { r.error = sdk.WalletError.fromUnknown(eu) } return r } /** * * @param txs All transactions must have source transactions. Will just source locking scripts and satoshis do?? toHexEF() is used. * @param config */ export async function postTxsToTaalArcMiner(beef: Beef, txids: string[], config: ArcServiceConfig, services: sdk.WalletServices): Promise<sdk.PostTxsResult> { const r: sdk.PostTxsResult = { name: config.name, status: 'error', txidResults: [] } try { const arc = new ARC(config.url, config.arcConfig) /** * This service requires an array of EF serialized transactions: * Pull the transactions matching txids array out of the Beef and fill in input sourceTransations, * either from Beef or by external service lookup. */ const txs: BsvTransaction[] = [] for (const txid of txids) { const btx = beef.findTxid(txid) if (btx) { const tx = btx.tx for (const input of tx.inputs) { if (!input.sourceTXID || input.sourceTXID === '0'.repeat(64)) continue; // all zero txid is a coinbase input. let itx = beef.findTxid(input.sourceTXID!) if (!itx) { const rawTx = await services.getRawTx(input.sourceTXID!) if (!rawTx || !rawTx.rawTx) throw new sdk.WERR_INVALID_PARAMETER('sourceTXID', `contained in beef or found on chain. ${input.sourceTXID}`); beef.mergeRawTx(rawTx.rawTx!) itx = beef.findTxid(input.sourceTXID!) } input.sourceTransaction = itx!.tx } txs.push(tx) } else throw new sdk.WERR_INVALID_PARAMETER('beef', `merged with rawTxs matching txids. Missing ${txid}`) } const rrs = await arc.broadcastMany(txs) as ArcMinerPostTxsData[] r.status = 'success' for (const txid of txids) { const txr: PostTxResultForTxid = { txid, status: 'success' } const rr = rrs.find(r => r.txid === txid) if (!rr) { r.status = txr.status = 'error' } else { txr.data = rr if (rr.status !== 200) r.status = txr.status = 'error'; else { txr.blockHash = rr.blockHash txr.blockHeight = rr.blockHeight txr.merklePath = !rr.merklePath ? undefined : MerklePath.fromHex(rr.merklePath) } } r.txidResults.push(txr) } r.data = rrs } catch (eu: unknown) { r.error = sdk.WalletError.fromUnknown(eu) } return r } export interface ArcMinerGetTxData { status: number // 200 title: string // OK blockHash: string blockHeight: number competingTxs: null | string[] extraInfo: string merklePath: string timestamp: string // ISO Z txid: string txStatus: string // 'SEEN_IN_ORPHAN_MEMPOOL' } export interface ArcMinerPostTxsData { status: number // 200 title: string // OK blockHash: string blockHeight: number competingTxs: null | string[] extraInfo: string merklePath: string timestamp: string // ISO Z txid: string txStatus: string // 'SEEN_IN_ORPHAN_MEMPOOL' } export interface ArcMinerPostBeefDataApi { status: number, // 200 title: string, // "OK", blockHash?: string, // "" blockHeight?: number, // 0 competingTxs?: null, extraInfo: string, // "" merklePath?: string, // "" timestamp?: string, // "2024-08-23T12:55:26.229904Z", txid?: string, // "272b5cdca9a0aa51846df9be29ee366ff85902691d38210e8c4be2fead3823a5", txStatus?: string, // "SEEN_ON_NETWORK", type?: string, // url detail?: string, instance?: string, } export async function postBeefToTaalArcMiner( beef: Beef, txids: string[], config: ArcServiceConfig, services: sdk.WalletServices ) : Promise<sdk.PostBeefResult> { const r1 = await postBeefToArcMiner(beef, txids, config) return r1 if (r1.status === 'success') return r1 const datas: object = { r1: r1.data } const r2 = await services.postTxs(beef, txids) const r3: sdk.PostBeefResult = { name: config.name, status: 'success', data: {}, txidResults: [] } for (const txid of txids) { const rawTx = beef.findTxid(txid)!.rawTx! const rt = await postBeefToArcMiner(rawTx, [txid], config) if (rt.status === 'error') r3.status = 'error' r3.data![txid] = rt.data r3.txidResults.push(rt.txidResults[0]) } datas['r3'] = r3.data r3.data = datas return r3 } export async function postBeefToArcMiner( beef: Beef | number[], txids: string[], config: ArcServiceConfig ) : Promise<sdk.PostBeefResult> { const m = {...config} let url = '' let r: sdk.PostBeefResult | undefined = undefined // HACK to resolve ARC error when row has zero leaves. // beef.addComputedLeaves() let beefBinary: number[] if (Array.isArray(beef)) beefBinary = beef else { // HACK to resolve ARC not handling V2 Beef after change Beef class to always serialize as V2 if default constructed: beef.version = BEEF_V1 beefBinary = beef.toBinary() } try { const length = beefBinary.length const makeRequestHeaders = () => { const headers: Record<string, string> = { 'Content-Type': 'application/octet-stream', 'Content-Length': length.toString(), 'XDeployment-ID': m.arcConfig.deploymentId || `WalletServices-${randomBytesHex(16)}`, } if (m.arcConfig.apiKey) { headers['Authorization'] = `Bearer ${m.arcConfig.apiKey}` } return headers } const headers = makeRequestHeaders() const stream = new Readable({ read() { this.push(Buffer.from(beefBinary as number[])) this.push(null) } }) url = `${config.url}/v1/tx` const data = await axios.post( url, stream, { headers, maxBodyLength: Infinity, validateStatus: () => true, } ) if (!data || !data.data) throw new sdk.WERR_BAD_REQUEST('no response data') if (data.data === 'No Authorization' || data.status === 403 || data.statusText === 'Forbiden') throw new sdk.WERR_BAD_REQUEST('No Authorization') if (typeof data.data !== 'object') throw new sdk.WERR_BAD_REQUEST('no response data object') const dd = data.data as ArcMinerPostBeefDataApi r = makePostBeefResult(dd, config, beefBinary, txids) } catch (err: unknown) { console.error(err) const error = new sdk.WERR_INTERNAL(`service: ${url}, error: ${JSON.stringify(sdk.WalletError.fromUnknown(err))}`) r = makeErrorResult(error, config, beefBinary, txids) } return r } export function makePostBeefResult(dd: ArcMinerPostBeefDataApi, miner: ArcServiceConfig, beef: number[], txids: string[]) : sdk.PostBeefResult { let r: sdk.PostBeefResult switch (dd.status) { case 200: // Success r = makeSuccessResult(dd, miner, beef, txids) break case 400: // Bad Request r = makeErrorResult(new sdk.WERR_BAD_REQUEST(), miner, beef, txids, dd) break case 401: // Security Failed r = makeErrorResult(new sdk.WERR_BAD_REQUEST(`Security Failed (401)`), miner, beef, txids, dd) break case 409: // Generic Error case 422: // RFC 7807 Error case 460: // Not Extended Format case 467: // Mined Ancestor Missing case 468: // Invalid BUMPs r = makeErrorResult(new sdk.WERR_BAD_REQUEST(`status ${dd.status}, title ${dd.title}`), miner, beef, txids, dd) break case 461: // Malformed Transaction case 463: // Malformed Transaction case 464: // Invalid Outputs r = makeErrorResult(new sdk.WERR_BAD_REQUEST(`status ${dd.status}, title ${dd.title}`), miner, beef, txids, dd) break case 462: // Invalid Inputs if (dd.txid) r = makeErrorResult(new sdk.WERR_BAD_REQUEST(`status ${dd.status}, title ${dd.title}`), miner, beef, txids, dd) else r = makeErrorResult(new sdk.WERR_BAD_REQUEST(`status ${dd.status}, title ${dd.title}`), miner, beef, txids, dd) break case 465: // Fee Too Low case 473: // Cumulative Fee Validation Failed r = makeErrorResult(new sdk.WERR_BAD_REQUEST(`status ${dd.status}, title ${dd.title}`), miner, beef, txids, dd) break case 469: // Invalid Merkle Root r = makeErrorResult(new sdk.WERR_BAD_REQUEST(`status ${dd.status}, title ${dd.title}`), miner, beef, txids, dd) break default: r = makeErrorResult(new sdk.WERR_BAD_REQUEST(`status ${dd.status}, title ${dd.title}`), miner, beef, txids, dd) break } return r } function makeSuccessResult( dd: ArcMinerPostBeefDataApi, miner: ArcServiceConfig, // eslint-disable-next-line @typescript-eslint/no-unused-vars beef: number[], // eslint-disable-next-line @typescript-eslint/no-unused-vars txids: string[] ): sdk.PostBeefResult { const r: sdk.PostBeefResult = { status: 'success', name: miner.name, data: dd, txidResults: [] } for (let i = 0; i < txids.length; i++) { const rt: sdk.PostTxResultForTxid = { txid: txids[i], status: 'success' } if (dd.txid === txids[i]) { rt.alreadyKnown = !!dd.txStatus && ['SEEN_ON_NETWORK', 'MINED'].indexOf(dd.txStatus) >= 0 rt.txid = dd.txid rt.blockHash = dd.blockHash rt.blockHeight = dd.blockHeight rt.merklePath = MerklePath.fromBinary(asArray(dd.merklePath!)) } r.txidResults.push(rt) } return r } export function makeErrorResult( error: sdk.WalletError, miner: ArcServiceConfig, // eslint-disable-next-line @typescript-eslint/no-unused-vars beef: number[], txids: string[], dd?: ArcMinerPostBeefDataApi ): sdk.PostBeefResult { const r: sdk.PostBeefResult = { status: 'error', name: miner.name, error, data: dd, txidResults: [] } for (let i = 0; i < txids.length; i++) { const rt: sdk.PostTxResultForTxid = { txid: txids[i], status: 'error' } if (dd?.txid === txids[i]) { rt.alreadyKnown = !!dd.txStatus && ['SEEN_ON_NETWORK', 'MINED'].indexOf(dd.txStatus) >= 0 rt.txid = dd.txid rt.blockHash = dd.blockHash rt.blockHeight = dd.blockHeight rt.merklePath = MerklePath.fromBinary(asArray(dd.merklePath!)) } r.txidResults.push(rt) } return r }