@bsv/overlay-express
Version:
BSV Blockchain Overlay Express
189 lines • 6.99 kB
JavaScript
import { MerklePath } from '@bsv/sdk';
const TERMINAL_STATUSES = new Set([
'DOUBLE_SPEND_ATTEMPTED',
'REJECTED',
'INVALID',
'MALFORMED',
'MINED_IN_STALE_BLOCK'
]);
const SUCCESS_STATUSES = new Set([
'RECEIVED',
'SENT_TO_NETWORK',
'ANNOUNCED_TO_NETWORK',
'ACCEPTED_BY_NETWORK',
'SEEN_ON_NETWORK',
'STORED',
'MINED',
'IMMUTABLE'
]);
export function isTerminalArcStatus(status, extraInfo) {
const statusText = typeof status === 'string' ? status.toUpperCase() : '';
const extraText = typeof extraInfo === 'string' ? extraInfo.toUpperCase() : '';
return TERMINAL_STATUSES.has(statusText) || statusText.includes('ORPHAN') || extraText.includes('ORPHAN');
}
function trimBaseUrl(url) {
let base = url;
while (base.endsWith('/'))
base = base.slice(0, -1);
return base;
}
function parseDescription(data, fallback) {
if (typeof data === 'string')
return data;
if (typeof data === 'object' && data !== null) {
const d = data;
return d.detail ?? d.reason ?? d.error ?? `${d.txStatus ?? ''} ${d.extraInfo ?? ''}`.trim() ?? fallback;
}
return fallback;
}
function rawTxForArcade(tx) {
try {
return tx.toHexEF();
}
catch (error) {
if (error instanceof Error &&
error.message === 'All inputs must have source transactions when serializing to EF format') {
return tx.toHex();
}
throw error;
}
}
/**
* Broadcaster/proof client for bsv-blockchain/arcade.
*
* Arcade exposes transaction propagation at `/tx` and status/proofs at
* `/tx/{txid}`. It returns terminal validation and double-spend statuses in the
* body, so callers must classify those as failed broadcasts even when HTTP
* accepted the request.
*/
export class ArcadeProvider {
config;
name = 'Arcade';
baseUrl;
fetcher;
constructor(url, config = {}) {
this.config = config;
this.baseUrl = trimBaseUrl(url);
this.fetcher = config.fetch ?? fetch;
}
async broadcast(tx) {
const rawTx = rawTxForArcade(tx);
try {
const response = await this.fetcher(`${this.baseUrl}/tx`, {
method: 'POST',
headers: this.requestHeaders(),
body: JSON.stringify({ rawTx })
});
const data = await this.readResponse(response);
if (!response.ok) {
const failure = {
status: 'error',
code: data?.txStatus ?? String(response.status),
txid: data?.txid,
description: parseDescription(data, response.statusText)
};
const more = {
provider: this.name,
httpStatus: response.status,
terminal: response.status === 400 || isTerminalArcStatus(data?.txStatus, data?.extraInfo),
response: data
};
if (data?.competingTxs !== undefined) {
more.competingTxs = data.competingTxs;
}
failure.more = more;
return failure;
}
const txStatus = data?.txStatus;
if (isTerminalArcStatus(txStatus, data?.extraInfo)) {
const failure = {
status: 'error',
code: txStatus ?? 'UNKNOWN',
txid: data?.txid,
description: `${txStatus ?? ''} ${data?.extraInfo ?? ''}`.trim()
};
const more = { provider: this.name, terminal: true, response: data };
if (data?.competingTxs !== undefined) {
more.competingTxs = data.competingTxs;
}
failure.more = more;
return failure;
}
const statusText = typeof txStatus === 'string' ? txStatus.toUpperCase() : undefined;
return {
status: 'success',
txid: data?.txid ?? tx.id('hex'),
message: `${txStatus ?? (SUCCESS_STATUSES.has(statusText ?? '') ? statusText ?? '' : response.statusText)} ${data?.extraInfo ?? ''}`.trim(),
competingTxs: data?.competingTxs
};
}
catch (error) {
return {
status: 'error',
code: '500',
description: error instanceof Error ? error.message : 'Internal Server Error',
more: { provider: this.name, terminal: false }
};
}
}
async fetchMerkleProof(txid) {
const response = await this.fetcher(`${this.baseUrl}/tx/${txid}`, {
method: 'GET',
headers: this.requestHeaders({ accept: 'application/json' })
});
if (response.status === 404)
return undefined;
const data = await this.readResponse(response);
if (!response.ok) {
throw new Error(`Arcade proof lookup failed for ${txid}: ${response.status} ${parseDescription(data, response.statusText)}`);
}
const mined = data?.txStatus === 'MINED' || data?.txStatus === 'IMMUTABLE';
if (!mined || data?.merklePath === undefined || data.merklePath === '') {
return undefined;
}
const merklePath = MerklePath.fromHex(data.merklePath);
return {
txid,
merklePath,
blockHeight: data.blockHeight ?? merklePath.blockHeight,
blockHash: data.blockHash,
merkleRoot: merklePath.computeRoot(txid)
};
}
requestHeaders(options = {}) {
const headers = {
Accept: options.accept ?? 'application/json',
'Content-Type': 'application/json'
};
if (this.config.deploymentId !== undefined && this.config.deploymentId !== '') {
headers['XDeployment-ID'] = this.config.deploymentId;
}
if (this.config.apiKey !== undefined && this.config.apiKey !== '') {
headers.Authorization = `Bearer ${this.config.apiKey}`;
}
if (this.config.callbackUrl !== undefined && this.config.callbackUrl !== '') {
headers['X-CallbackUrl'] = this.config.callbackUrl;
}
if (this.config.callbackToken !== undefined && this.config.callbackToken !== '') {
headers['X-CallbackToken'] = this.config.callbackToken;
}
if (this.config.headers !== undefined) {
for (const [key, value] of Object.entries(this.config.headers)) {
headers[key] = value;
}
}
return headers;
}
async readResponse(response) {
const text = await response.text();
if (text === '')
return undefined;
try {
return JSON.parse(text);
}
catch {
return text;
}
}
}
//# sourceMappingURL=ArcadeProvider.js.map