@bsv/wallet-toolbox
Version:
BRC100 conforming wallet, wallet storage and wallet signer components
349 lines (315 loc) • 10.5 kB
text/typescript
import {
Beef,
BEEF_V1,
BEEF_V2,
defaultHttpClient,
HexString,
HttpClient,
HttpClientRequestOptions,
Random,
Utils
} from '@bsv/sdk'
import { doubleSha256BE, sdk } from '../../index.client'
import { ReqHistoryNote } from '../../sdk'
/** Configuration options for the ARC broadcaster. */
export interface ArcConfig {
/** Authentication token for the ARC API */
apiKey?: string
/** The HTTP client used to make requests to the ARC API. */
httpClient?: HttpClient
/** Deployment id used annotating api calls in XDeployment-ID header - this value will be randomly generated if not set */
deploymentId?: string
/** notification callback endpoint for proofs and double spend notification */
callbackUrl?: string
/** default access token for notification callback endpoint. It will be used as a Authorization header for the http callback */
callbackToken?: string
/** additional headers to be attached to all tx submissions. */
headers?: Record<string, string>
}
function defaultDeploymentId(): string {
return `ts-sdk-${Utils.toHex(Random(16))}`
}
/**
* Represents an ARC transaction broadcaster.
*/
export class ARC {
readonly name: string
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, name?: string)
/**
* Constructs an instance of the ARC broadcaster.
*
* @param {string} URL - The URL endpoint for the ARC API.
* @param {string} apiKey - The API key used for authorization with the ARC API.
*/
constructor(URL: string, apiKey?: string, name?: string)
constructor(URL: string, config?: string | ArcConfig, name?: string) {
this.name = name ?? 'ARC'
this.URL = URL
if (typeof config === 'string') {
this.apiKey = config
this.httpClient = defaultHttpClient()
this.deploymentId = defaultDeploymentId()
this.callbackToken = undefined
this.callbackUrl = undefined
} else {
const configObj: ArcConfig = config ?? {}
const { apiKey, deploymentId, httpClient, callbackToken, callbackUrl, headers } = configObj
this.apiKey = apiKey
this.httpClient = httpClient ?? defaultHttpClient()
this.deploymentId = deploymentId ?? defaultDeploymentId()
this.callbackToken = callbackToken
this.callbackUrl = callbackUrl
this.headers = headers
}
}
/**
* Constructs a dictionary of the default & supplied request headers.
*/
private requestHeaders(): Record<string, string> {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'XDeployment-ID': this.deploymentId
}
if (this.apiKey != null && this.apiKey !== '') {
headers.Authorization = `Bearer ${this.apiKey}`
}
if (this.callbackUrl != null && this.callbackUrl !== '') {
headers['X-CallbackUrl'] = this.callbackUrl
}
if (this.callbackToken != null && this.callbackToken !== '') {
headers['X-CallbackToken'] = this.callbackToken
}
if (this.headers != null) {
for (const key in this.headers) {
headers[key] = this.headers[key]
}
}
return headers
}
/**
* The ARC '/v1/tx' endpoint, as of 2025-02-17 supports all of the following hex string formats:
* 1. Single serialized raw transaction.
* 2. Single EF serialized raw transaction (untested).
* 3. V1 serialized Beef (results returned reflect only the last transaction in the beef)
*
* The ARC '/v1/tx' endpoint, as of 2025-02-17 DOES NOT support the following hex string formats:
* 1. V2 serialized Beef
*
* @param rawTx
* @param txids
* @returns
*/
async postRawTx(rawTx: HexString, txids?: string[]): Promise<sdk.PostTxResultForTxid> {
let txid = Utils.toHex(doubleSha256BE(Utils.toArray(rawTx, 'hex')))
if (txids) {
txid = txids.slice(-1)[0]
} else {
txids = [txid]
}
const requestOptions: HttpClientRequestOptions = {
method: 'POST',
headers: this.requestHeaders(),
data: { rawTx },
signal: AbortSignal.timeout(1000 * 30) // 30 seconds timeout, error.code will be 'ABORT_ERR'
}
const r: sdk.PostTxResultForTxid = {
txid,
status: 'success',
notes: []
}
const url = `${this.URL}/v1/tx`
const nn = () => ({ name: this.name, when: new Date().toISOString() })
const nne = () => ({ ...nn(), rawTx, txids: txids.join(','), url })
try {
const response = await this.httpClient.request<ArcResponse>(url, requestOptions)
const { txid, extraInfo, txStatus, competingTxs } = response.data
const nnr = () => ({
txid,
extraInfo,
txStatus,
competingTxs: competingTxs?.join(',')
})
if (response.ok) {
r.data = `${txStatus} ${extraInfo}`
if (r.txid !== txid) r.data += ` txid altered from ${r.txid} to ${txid}`
r.txid = txid
if (txStatus === 'DOUBLE_SPEND_ATTEMPTED' || txStatus === 'SEEN_IN_ORPHAN_MEMPOOL') {
r.status = 'error'
r.doubleSpend = true
r.competingTxs = competingTxs
r.notes!.push({ ...nne(), ...nnr(), what: 'postRawTxDoubleSpend' })
} else {
r.notes!.push({ ...nn(), ...nnr(), what: 'postRawTxSuccess' })
}
} else if (typeof response === 'string') {
r.notes!.push({ ...nne(), what: 'postRawTxString', response })
r.status = 'error'
// response is not normally a string
r.serviceError = true
} else {
r.status = 'error'
// Treat unknown errors as service errors
r.serviceError = true
const n: ReqHistoryNote = {
...nn(),
...nne(),
...nnr(),
what: 'postRawTxError'
}
const ed: sdk.PostTxResultForTxidError = {}
r.data = ed
const st = typeof response.status
if (st === 'number' || st === 'string') {
n.status = response.status
ed.status = response.status.toString()
} else {
n.status = st
ed.status = 'ERR_UNKNOWN'
}
let d = response.data
if (d && typeof d === 'string') {
n.data = response.data.slice(0, 128)
try {
d = JSON.parse(d)
} catch {
// Intentionally left empty
}
} else if (d && typeof d === 'object') {
ed.more = d
ed.detail = d['detail']
if (typeof ed.detail !== 'string') ed.detail = undefined
if (ed.detail) {
n.detail = ed.detail
}
}
r.notes!.push(n)
}
} catch (eu: unknown) {
const e = sdk.WalletError.fromUnknown(eu)
r.status = 'error'
r.serviceError = true
r.data = `${e.code} ${e.message}`
r.notes!.push({
...nne(),
what: 'postRawTxCatch',
code: e.code,
description: e.description
})
}
return r
}
/**
* ARC does not natively support a postBeef end-point aware of multiple txids of interest in the Beef.
*
* It does process multiple new transactions, however, which allows results for all txids of interest
* to be collected by the `/v1/tx/${txid}` endpoint.
*
* @param beef
* @param txids
* @returns
*/
async postBeef(beef: Beef, txids: string[]): Promise<sdk.PostBeefResult> {
const r: sdk.PostBeefResult = {
name: this.name,
status: 'success',
txidResults: [],
notes: []
}
const nn = () => ({ name: this.name, when: new Date().toISOString() })
if (beef.version === BEEF_V2 && beef.txs.every(btx => !btx.isTxidOnly)) {
beef.version = BEEF_V1
r.notes!.push({ ...nn(), what: 'postBeefV2ToV1' })
}
const beefHex = beef.toHex()
const prtr = await this.postRawTx(beefHex, txids)
r.status = prtr.status
r.txidResults = [prtr]
// Since postRawTx only returns results for a single txid,
// replicate the basic results any additional txids.
// TODO: Temporary hack...
for (const txid of txids) {
if (prtr.txid === txid) continue
const tr: sdk.PostTxResultForTxid = {
txid,
status: 'success',
notes: []
}
// For the extra txids, go back to the service for confirmation...
const dr = await this.getTxData(txid)
if (dr.txid !== txid) {
tr.status = 'error'
tr.data = 'internal error'
tr.notes!.push({
...nn(),
what: 'postBeefGetTxDataInternal',
txid,
returnedTxid: dr.txid
})
} else if (dr.txStatus === 'SEEN_ON_NETWORK' || dr.txStatus === 'STORED') {
tr.data = dr.txStatus
tr.notes!.push({
...nn(),
what: 'postBeefGetTxDataSuccess',
txid,
txStatus: dr.txStatus
})
} else {
tr.status = 'error'
tr.data = dr
tr.notes!.push({
...nn(),
what: 'postBeefGetTxDataError',
txid,
txStatus: dr.txStatus
})
}
r.txidResults.push(tr)
if (r.status === 'success' && tr.status === 'error') r.status = 'error'
}
return r
}
/**
* This seems to only work for recently submitted txids...but that's all we need to complete postBeef!
* @param txid
* @returns
*/
async getTxData(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
}
}
interface ArcResponse {
txid: string
extraInfo: string
txStatus: string
competingTxs?: string[]
}
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'
}