Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a27a20faee | |||
| 5ae6dbfc3f | |||
| 5a943f45a6 | |||
| a35dc508b0 |
49
README.md
49
README.md
@@ -145,6 +145,55 @@ await sdk.transactionType.delete('my-type');
|
|||||||
const block = await sdk.block.get('block-id');
|
const block = await sdk.block.get('block-id');
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Interchain trace
|
||||||
|
|
||||||
|
`transaction.getInterchain` / `block.getInterchain` trace a prime block to the
|
||||||
|
public-chain anchors covering it. By default they return the **first anchor per
|
||||||
|
chain** (anchor proofs are chained, so the earliest per chain is the meaningful
|
||||||
|
one). Tune with options:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Default: first anchor per chain (ETH, BTC, …)
|
||||||
|
const trace = await sdk.block.getInterchain('42');
|
||||||
|
|
||||||
|
// Up to 3 anchors per chain
|
||||||
|
await sdk.block.getInterchain('42', { perChain: 3 });
|
||||||
|
|
||||||
|
// All anchors, only the ETH-mainnet chain ("1"); "0" = BTC
|
||||||
|
await sdk.block.getInterchain('42', { perChain: 0, chains: ['1'] });
|
||||||
|
```
|
||||||
|
|
||||||
|
### Proof Measure (public, unauthenticated)
|
||||||
|
|
||||||
|
`proof-measure` is a separate, **public, unauthenticated** Dragonchain service
|
||||||
|
(the measured-immutability / "securedBy" metric) at
|
||||||
|
`https://proof-measure.dragonchain.com`. It needs no credentials. Decimal fields
|
||||||
|
are returned as strings (full precision) and timestamps as unix-second numbers.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Via the main SDK (targets the default public endpoint):
|
||||||
|
const sec = await sdk.proofMeasure.getSecurity('BTC', Math.floor(Date.now() / 1000) - 3600);
|
||||||
|
console.log(`BTC secured by ${sec.valueUsdFormatted} (${sec.raw.value} ${sec.raw.unit})`);
|
||||||
|
|
||||||
|
// Or standalone — no prime credentials needed:
|
||||||
|
import { ProofMeasureClient } from '@dragonchain-inc/prime-sdk';
|
||||||
|
|
||||||
|
const pm = new ProofMeasureClient(); // or new ProofMeasureClient({ baseURL: '...' })
|
||||||
|
|
||||||
|
const report = await pm.report({
|
||||||
|
transactionId: 'tx-123',
|
||||||
|
primeId: 'my-prime',
|
||||||
|
blockId: '42',
|
||||||
|
anchors: [
|
||||||
|
{ network: 'BTC', txHash: '0x...', timestamp: anchorUnix },
|
||||||
|
{ network: 'ETH', txHash: '0x...', timestamp: anchorUnix },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
console.log(`Secured by ${report.totalValueUsdFormatted} across ${report.anchors.length} anchors`);
|
||||||
|
|
||||||
|
await pm.health();
|
||||||
|
```
|
||||||
|
|
||||||
## TypeScript Support
|
## TypeScript Support
|
||||||
|
|
||||||
This SDK is written in TypeScript and includes complete type definitions:
|
This SDK is written in TypeScript and includes complete type definitions:
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@dragonchain-inc/prime-sdk",
|
"name": "@dragonchain-inc/prime-sdk",
|
||||||
"version": "1.2.0",
|
"version": "1.5.0",
|
||||||
"description": "Official Dragonchain Prime SDK for Node.js and TypeScript",
|
"description": "Official Dragonchain Prime SDK for Node.js and TypeScript",
|
||||||
"main": "./dist/index.js",
|
"main": "./dist/index.js",
|
||||||
"module": "./dist/index.mjs",
|
"module": "./dist/index.mjs",
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { DragonchainClient } from './client';
|
import { DragonchainClient } from './client';
|
||||||
|
import { InterchainOptions, buildInterchainQuery } from './interchain';
|
||||||
import { Block, InterchainTrace } from './types';
|
import { Block, InterchainTrace } from './types';
|
||||||
|
|
||||||
export class BlockClient {
|
export class BlockClient {
|
||||||
@@ -23,7 +24,9 @@ export class BlockClient {
|
|||||||
* Traces a block to the validator (verification) blocks that validated it and
|
* Traces a block to the validator (verification) blocks that validated it and
|
||||||
* the public-chain interchain anchors those validator blocks were bundled into.
|
* the public-chain interchain anchors those validator blocks were bundled into.
|
||||||
*/
|
*/
|
||||||
async getInterchain(blockId: string): Promise<InterchainTrace> {
|
async getInterchain(blockId: string, options?: InterchainOptions): Promise<InterchainTrace> {
|
||||||
return this.client.get<InterchainTrace>(`/api/v1/block/${blockId}/interchain`);
|
return this.client.get<InterchainTrace>(
|
||||||
|
`/api/v1/block/${blockId}/interchain${buildInterchainQuery(options)}`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,15 @@ export interface ClientConfig {
|
|||||||
authKey: string;
|
authKey: string;
|
||||||
baseURL: string;
|
baseURL: string;
|
||||||
timeout?: number;
|
timeout?: number;
|
||||||
|
/**
|
||||||
|
* Optional http(s).Agent used for every request. Inject an agent whose
|
||||||
|
* connection factory refuses to connect to internal IPs when baseURL is
|
||||||
|
* attacker-influenced (a tenant's prime_endpoint), to defend against SSRF
|
||||||
|
* (incl. DNS rebinding) at connect time. The SSRF policy belongs in the
|
||||||
|
* server that points this client at untrusted endpoints, not baked into
|
||||||
|
* the client library, so the default (no agent) is unguarded.
|
||||||
|
*/
|
||||||
|
agent?: http.Agent | https.Agent;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class DragonchainClient {
|
export class DragonchainClient {
|
||||||
@@ -21,6 +30,7 @@ export class DragonchainClient {
|
|||||||
private readonly authKey: string;
|
private readonly authKey: string;
|
||||||
private readonly baseURL: string;
|
private readonly baseURL: string;
|
||||||
private readonly timeout: number;
|
private readonly timeout: number;
|
||||||
|
private readonly agent?: http.Agent | https.Agent;
|
||||||
|
|
||||||
constructor(config: ClientConfig) {
|
constructor(config: ClientConfig) {
|
||||||
this.publicId = config.publicId;
|
this.publicId = config.publicId;
|
||||||
@@ -28,6 +38,7 @@ export class DragonchainClient {
|
|||||||
this.authKey = config.authKey;
|
this.authKey = config.authKey;
|
||||||
this.baseURL = config.baseURL.replace(/\/$/, ''); // Remove trailing slash
|
this.baseURL = config.baseURL.replace(/\/$/, ''); // Remove trailing slash
|
||||||
this.timeout = config.timeout || 30000; // Default 30 seconds
|
this.timeout = config.timeout || 30000; // Default 30 seconds
|
||||||
|
this.agent = config.agent;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -123,6 +134,7 @@ export class DragonchainClient {
|
|||||||
'Content-Length': bodyBuffer.length,
|
'Content-Length': bodyBuffer.length,
|
||||||
},
|
},
|
||||||
timeout: this.timeout,
|
timeout: this.timeout,
|
||||||
|
...(this.agent && { agent: this.agent }),
|
||||||
};
|
};
|
||||||
|
|
||||||
return new Promise<T>((resolve, reject) => {
|
return new Promise<T>((resolve, reject) => {
|
||||||
@@ -137,6 +149,19 @@ export class DragonchainClient {
|
|||||||
res.on('end', () => {
|
res.on('end', () => {
|
||||||
const responseBody = Buffer.concat(chunks);
|
const responseBody = Buffer.concat(chunks);
|
||||||
|
|
||||||
|
// Refuse redirects. node's http.request does not follow them, but
|
||||||
|
// treating a 3xx as success would mis-parse the (empty) body — and
|
||||||
|
// a followed redirect to an internal host would be an SSRF vector.
|
||||||
|
// Prime never legitimately redirects, so a 3xx is an error.
|
||||||
|
if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400) {
|
||||||
|
reject(
|
||||||
|
new Error(
|
||||||
|
`Unexpected redirect (status ${res.statusCode}) to ${res.headers.location ?? '?'}; refusing to follow`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Check for errors
|
// Check for errors
|
||||||
if (res.statusCode && res.statusCode >= 400) {
|
if (res.statusCode && res.statusCode >= 400) {
|
||||||
const errorMessage = responseBody.toString('utf8').trim();
|
const errorMessage = responseBody.toString('utf8').trim();
|
||||||
|
|||||||
14
src/index.ts
14
src/index.ts
@@ -10,6 +10,7 @@ import { TransactionTypeClient } from './transactionType';
|
|||||||
import { ContractClient } from './contract';
|
import { ContractClient } from './contract';
|
||||||
import { BlockClient } from './block';
|
import { BlockClient } from './block';
|
||||||
import { SystemClient } from './system';
|
import { SystemClient } from './system';
|
||||||
|
import { ProofMeasureClient, PROOF_MEASURE_DEFAULT_BASE_URL } from './proofMeasure';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Main Dragonchain SDK class
|
* Main Dragonchain SDK class
|
||||||
@@ -22,6 +23,13 @@ export class DragonchainSDK {
|
|||||||
public readonly contract: ContractClient;
|
public readonly contract: ContractClient;
|
||||||
public readonly block: BlockClient;
|
public readonly block: BlockClient;
|
||||||
public readonly system: SystemClient;
|
public readonly system: SystemClient;
|
||||||
|
/**
|
||||||
|
* Client for the public proof-measure service (measured immutability /
|
||||||
|
* "securedBy"). It is a separate, unauthenticated service, so this handle
|
||||||
|
* targets its default public endpoint; for a custom endpoint construct a
|
||||||
|
* `ProofMeasureClient` directly.
|
||||||
|
*/
|
||||||
|
public readonly proofMeasure: ProofMeasureClient;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a new Dragonchain SDK instance
|
* Creates a new Dragonchain SDK instance
|
||||||
@@ -53,6 +61,9 @@ export class DragonchainSDK {
|
|||||||
this.contract = new ContractClient(this.client);
|
this.contract = new ContractClient(this.client);
|
||||||
this.block = new BlockClient(this.client);
|
this.block = new BlockClient(this.client);
|
||||||
this.system = new SystemClient(this.client);
|
this.system = new SystemClient(this.client);
|
||||||
|
// proof-measure is a separate, unauthenticated public service; default it
|
||||||
|
// to its public endpoint (pass-through the request timeout).
|
||||||
|
this.proofMeasure = new ProofMeasureClient({ baseURL: PROOF_MEASURE_DEFAULT_BASE_URL, timeout });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -72,6 +83,9 @@ export { TransactionTypeClient } from './transactionType';
|
|||||||
export { ContractClient } from './contract';
|
export { ContractClient } from './contract';
|
||||||
export { BlockClient } from './block';
|
export { BlockClient } from './block';
|
||||||
export { SystemClient } from './system';
|
export { SystemClient } from './system';
|
||||||
|
export { ProofMeasureClient, PROOF_MEASURE_DEFAULT_BASE_URL } from './proofMeasure';
|
||||||
|
export { UnauthHttpClient, UnauthClientConfig } from './unauthHttpClient';
|
||||||
|
export { InterchainOptions, buildInterchainQuery } from './interchain';
|
||||||
|
|
||||||
// Default export
|
// Default export
|
||||||
export default DragonchainSDK;
|
export default DragonchainSDK;
|
||||||
|
|||||||
37
src/interchain.ts
Normal file
37
src/interchain.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
/**
|
||||||
|
* Options + query builder for the interchain-trace endpoints
|
||||||
|
* (transaction.getInterchain / block.getInterchain).
|
||||||
|
*
|
||||||
|
* Anchor proofs are chained, so by default the trace returns the first anchor
|
||||||
|
* per public chain; these options change how many and which chains are returned.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface InterchainOptions {
|
||||||
|
/**
|
||||||
|
* Max interchain anchors returned per public chain, earliest-first.
|
||||||
|
* 1 = the first anchor per chain (the service default); 0 = all anchors.
|
||||||
|
*/
|
||||||
|
perChain?: number;
|
||||||
|
/**
|
||||||
|
* Interchain chain ids to include ("1" = ETH mainnet, "0" = BTC; testnet ids
|
||||||
|
* differ). Omit to include every chain found.
|
||||||
|
*/
|
||||||
|
chains?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the "?perChain=...&chains=..." suffix for the interchain trace
|
||||||
|
* endpoints. Returns "" when no options are set (the server then applies its
|
||||||
|
* defaults: one anchor per chain, all chains).
|
||||||
|
*/
|
||||||
|
export function buildInterchainQuery(options?: InterchainOptions): string {
|
||||||
|
if (!options) return '';
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (options.perChain !== undefined) {
|
||||||
|
parts.push(`perChain=${encodeURIComponent(String(options.perChain))}`);
|
||||||
|
}
|
||||||
|
if (options.chains && options.chains.length > 0) {
|
||||||
|
parts.push(`chains=${options.chains.map(encodeURIComponent).join(',')}`);
|
||||||
|
}
|
||||||
|
return parts.length ? `?${parts.join('&')}` : '';
|
||||||
|
}
|
||||||
69
src/proofMeasure.ts
Normal file
69
src/proofMeasure.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
/**
|
||||||
|
* Client for the Dragonchain proof-measure service — the measured-immutability
|
||||||
|
* / "securedBy" metric for L1–L5 verification chains.
|
||||||
|
*
|
||||||
|
* proof-measure is a separate, public, UNauthenticated service (no API keys),
|
||||||
|
* so this client needs only a base URL. It exposes a network's accumulated
|
||||||
|
* security as both a raw measure (cumulative hashes / stake-seconds) and a USD
|
||||||
|
* valuation, plus a per-transaction "securedBy" report over interchain anchors.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { UnauthHttpClient, UnauthClientConfig } from './unauthHttpClient';
|
||||||
|
import {
|
||||||
|
ContentType,
|
||||||
|
ProofMeasureSecurityResult,
|
||||||
|
ProofMeasureReportRequest,
|
||||||
|
ProofMeasureTransactionReport,
|
||||||
|
ProofMeasureHealthResponse,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
/** Public production proof-measure endpoint. */
|
||||||
|
export const PROOF_MEASURE_DEFAULT_BASE_URL = 'https://proof-measure.dragonchain.com';
|
||||||
|
|
||||||
|
export class ProofMeasureClient {
|
||||||
|
private client: UnauthHttpClient;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param clientOrConfig - an existing UnauthHttpClient, a config object, or
|
||||||
|
* nothing. With no argument (or no baseURL) the public production endpoint is
|
||||||
|
* used, so `new ProofMeasureClient()` works with zero configuration.
|
||||||
|
*/
|
||||||
|
constructor(clientOrConfig?: UnauthHttpClient | UnauthClientConfig) {
|
||||||
|
if (clientOrConfig instanceof UnauthHttpClient) {
|
||||||
|
this.client = clientOrConfig;
|
||||||
|
} else {
|
||||||
|
this.client = new UnauthHttpClient({
|
||||||
|
baseURL: clientOrConfig?.baseURL || PROOF_MEASURE_DEFAULT_BASE_URL,
|
||||||
|
timeout: clientOrConfig?.timeout,
|
||||||
|
agent: clientOrConfig?.agent,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the security a public network (network = 'BTC' or 'ETH') has
|
||||||
|
* accumulated since the given unix timestamp, as a raw measure + USD
|
||||||
|
* valuation. Omit `since` (or pass <= 0) to use the service default window.
|
||||||
|
*/
|
||||||
|
async getSecurity(network: string, since?: number): Promise<ProofMeasureSecurityResult> {
|
||||||
|
const query = since && since > 0 ? `?since=${since}` : '';
|
||||||
|
return this.client.get<ProofMeasureSecurityResult>(`/api/v1/security/${network}${query}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computes the per-transaction "securedBy" report for the supplied interchain
|
||||||
|
* anchors: each anchor's raw + USD security since it was placed, plus totals.
|
||||||
|
*/
|
||||||
|
async report(request: ProofMeasureReportRequest): Promise<ProofMeasureTransactionReport> {
|
||||||
|
return this.client.post<ProofMeasureTransactionReport>(
|
||||||
|
'/api/v1/report',
|
||||||
|
ContentType.JSON,
|
||||||
|
request
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reports service liveness and DB reachability. */
|
||||||
|
async health(): Promise<ProofMeasureHealthResponse> {
|
||||||
|
return this.client.get<ProofMeasureHealthResponse>('/api/v1/health');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { DragonchainClient } from './client';
|
import { DragonchainClient } from './client';
|
||||||
|
import { InterchainOptions, buildInterchainQuery } from './interchain';
|
||||||
import {
|
import {
|
||||||
ContentType,
|
ContentType,
|
||||||
TransactionCreateRequest,
|
TransactionCreateRequest,
|
||||||
@@ -56,8 +57,13 @@ export class TransactionClient {
|
|||||||
* blocks were bundled into. If the transaction is still pending (not yet in a
|
* blocks were bundled into. If the transaction is still pending (not yet in a
|
||||||
* block) the trace's arrays are empty.
|
* block) the trace's arrays are empty.
|
||||||
*/
|
*/
|
||||||
async getInterchain(transactionId: string): Promise<InterchainTrace> {
|
async getInterchain(
|
||||||
return this.client.get<InterchainTrace>(`/api/v1/transaction/${transactionId}/interchain`);
|
transactionId: string,
|
||||||
|
options?: InterchainOptions
|
||||||
|
): Promise<InterchainTrace> {
|
||||||
|
return this.client.get<InterchainTrace>(
|
||||||
|
`/api/v1/transaction/${transactionId}/interchain${buildInterchainQuery(options)}`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
74
src/types.ts
74
src/types.ts
@@ -149,6 +149,12 @@ export interface VerificationBlock {
|
|||||||
version: string;
|
version: string;
|
||||||
primeChainId: string;
|
primeChainId: string;
|
||||||
primeBlockId: string;
|
primeBlockId: string;
|
||||||
|
/**
|
||||||
|
* The prime block's proof this validator block attests to. Part of the signed
|
||||||
|
* message (blake2b-256 over version|primeChainId|primeBlockId|primeBlockProof|
|
||||||
|
* timestamp|verifierPublicKey), so it is required to verify verifierSignature.
|
||||||
|
*/
|
||||||
|
primeBlockProof: string;
|
||||||
timestamp: string;
|
timestamp: string;
|
||||||
verifierPublicKey: string;
|
verifierPublicKey: string;
|
||||||
verifierSignature: string;
|
verifierSignature: string;
|
||||||
@@ -163,9 +169,15 @@ export interface InterchainTransaction {
|
|||||||
id: number;
|
id: number;
|
||||||
version: string;
|
version: string;
|
||||||
timestamp: string;
|
timestamp: string;
|
||||||
|
/** Numeric external network id ("1"=ETH, "0"=BTC, ...). May be absent for chains without one. */
|
||||||
chainId: string;
|
chainId: string;
|
||||||
|
/** Explicit external chain name ("ETH", "BTC", "BASE", "BNB") — authoritative. */
|
||||||
|
chainName: string;
|
||||||
transHash: string;
|
transHash: string;
|
||||||
|
/** External chain block the anchor confirmed in; empty until confirmed (see status). */
|
||||||
blockId: string;
|
blockId: string;
|
||||||
|
/** Confirmation state: "pending" | "confirmed" | "dropped". */
|
||||||
|
status: string;
|
||||||
validatorBlocks: string[];
|
validatorBlocks: string[];
|
||||||
validatorBlockhash: string;
|
validatorBlockhash: string;
|
||||||
signature: string;
|
signature: string;
|
||||||
@@ -213,3 +225,65 @@ export interface ListResponse {
|
|||||||
items: unknown[];
|
items: unknown[];
|
||||||
total_count: number;
|
total_count: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Proof Measure Types (measured immutability / "securedBy").
|
||||||
|
// Decimal-valued fields are strings (full precision); timestamps are unix-second numbers.
|
||||||
|
|
||||||
|
export interface ProofMeasureRawMeasure {
|
||||||
|
value: string; // human-scaled mantissa, e.g. "484.44"
|
||||||
|
unit: string; // e.g. "Zettahashes", "ETH·s"
|
||||||
|
base: string; // unscaled base amount (hashes or stake-seconds)
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProofMeasureSecurityResult {
|
||||||
|
network: string;
|
||||||
|
consensus: string; // 'pow' | 'pos'
|
||||||
|
raw: ProofMeasureRawMeasure;
|
||||||
|
valueUsd: string;
|
||||||
|
valueUsdFormatted: string;
|
||||||
|
label: string;
|
||||||
|
normalizedScore: string;
|
||||||
|
since: number; // window/anchor start (unix seconds)
|
||||||
|
asOf: number; // latest sample time (unix seconds)
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProofMeasureReportAnchorInput {
|
||||||
|
network?: string; // 'BTC' | 'ETH'
|
||||||
|
chainId?: string; // public-chain numeric id (alternative to network)
|
||||||
|
txHash: string;
|
||||||
|
timestamp: number; // anchor time, unix seconds
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProofMeasureReportRequest {
|
||||||
|
transactionId: string;
|
||||||
|
primeId: string;
|
||||||
|
blockId: string;
|
||||||
|
anchors: ProofMeasureReportAnchorInput[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProofMeasureAnchorSecurity {
|
||||||
|
network: string;
|
||||||
|
anchorTimestamp: number;
|
||||||
|
anchorTxHash: string;
|
||||||
|
security: ProofMeasureSecurityResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProofMeasureHashPower {
|
||||||
|
value: string;
|
||||||
|
units: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProofMeasureTransactionReport {
|
||||||
|
transactionId: string;
|
||||||
|
primeId: string;
|
||||||
|
blockId: string;
|
||||||
|
anchors: ProofMeasureAnchorSecurity[];
|
||||||
|
totalValueUsd: string;
|
||||||
|
totalValueUsdFormatted: string;
|
||||||
|
hashPower: ProofMeasureHashPower | null; // null when there are no PoW anchors
|
||||||
|
totalNormalizedScore: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProofMeasureHealthResponse {
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|||||||
139
src/unauthHttpClient.ts
Normal file
139
src/unauthHttpClient.ts
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
/**
|
||||||
|
* Minimal HTTP client for public Dragonchain services that require no HMAC
|
||||||
|
* credentials (e.g. the proof-measure service). Mirrors DragonchainClient's
|
||||||
|
* body handling, redirect refusal, status checks, and JSON parsing, but sends
|
||||||
|
* no Authorization/Dragonchain/Timestamp headers.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as https from 'https';
|
||||||
|
import * as http from 'http';
|
||||||
|
import { URL } from 'url';
|
||||||
|
|
||||||
|
export interface UnauthClientConfig {
|
||||||
|
baseURL: string;
|
||||||
|
timeout?: number;
|
||||||
|
/**
|
||||||
|
* Optional http(s).Agent used for every request. Inject an SSRF-guarded
|
||||||
|
* agent when baseURL is attacker-influenced. The default (no agent) is
|
||||||
|
* unguarded — the SSRF policy belongs in the caller, not this library.
|
||||||
|
*/
|
||||||
|
agent?: http.Agent | https.Agent;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UnauthHttpClient {
|
||||||
|
private readonly baseURL: string;
|
||||||
|
private readonly timeout: number;
|
||||||
|
private readonly agent?: http.Agent | https.Agent;
|
||||||
|
|
||||||
|
constructor(config: UnauthClientConfig) {
|
||||||
|
this.baseURL = config.baseURL.replace(/\/$/, ''); // Remove trailing slash
|
||||||
|
this.timeout = config.timeout || 30000; // Default 30 seconds
|
||||||
|
this.agent = config.agent;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getEndpoint(): string {
|
||||||
|
return this.baseURL;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async doRequest<T>(
|
||||||
|
method: string,
|
||||||
|
path: string,
|
||||||
|
contentType: string,
|
||||||
|
body: unknown
|
||||||
|
): Promise<T> {
|
||||||
|
let bodyBuffer: Buffer;
|
||||||
|
|
||||||
|
if (body === null || body === undefined) {
|
||||||
|
bodyBuffer = Buffer.from('');
|
||||||
|
} else if (Buffer.isBuffer(body)) {
|
||||||
|
bodyBuffer = body;
|
||||||
|
} else if (typeof body === 'string') {
|
||||||
|
bodyBuffer = Buffer.from(body);
|
||||||
|
} else {
|
||||||
|
bodyBuffer = Buffer.from(JSON.stringify(body));
|
||||||
|
if (!contentType) {
|
||||||
|
contentType = 'application/json';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fullURL = `${this.baseURL}${path}`;
|
||||||
|
const parsedURL = new URL(fullURL);
|
||||||
|
const isHttps = parsedURL.protocol === 'https:';
|
||||||
|
|
||||||
|
const options: https.RequestOptions = {
|
||||||
|
hostname: parsedURL.hostname,
|
||||||
|
port: parsedURL.port || (isHttps ? 443 : 80),
|
||||||
|
path: parsedURL.pathname + parsedURL.search,
|
||||||
|
method: method.toUpperCase(),
|
||||||
|
headers: {
|
||||||
|
...(contentType && { 'Content-Type': contentType }),
|
||||||
|
Accept: 'application/json',
|
||||||
|
'Content-Length': bodyBuffer.length,
|
||||||
|
},
|
||||||
|
timeout: this.timeout,
|
||||||
|
...(this.agent && { agent: this.agent }),
|
||||||
|
};
|
||||||
|
|
||||||
|
return new Promise<T>((resolve, reject) => {
|
||||||
|
const httpModule = isHttps ? https : http;
|
||||||
|
const req = httpModule.request(options, (res) => {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
res.on('data', (chunk: Buffer) => {
|
||||||
|
chunks.push(chunk);
|
||||||
|
});
|
||||||
|
res.on('end', () => {
|
||||||
|
const responseBody = Buffer.concat(chunks);
|
||||||
|
|
||||||
|
// Refuse redirects (would mis-parse the empty body and could be an
|
||||||
|
// SSRF vector if followed to an internal host).
|
||||||
|
if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400) {
|
||||||
|
reject(
|
||||||
|
new Error(
|
||||||
|
`Unexpected redirect (status ${res.statusCode}) to ${res.headers.location ?? '?'}; refusing to follow`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.statusCode && res.statusCode >= 400) {
|
||||||
|
const errorMessage = responseBody.toString('utf8').trim();
|
||||||
|
reject(new Error(`API error (status ${res.statusCode}): ${errorMessage}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (responseBody.length > 0) {
|
||||||
|
try {
|
||||||
|
resolve(JSON.parse(responseBody.toString('utf8')) as T);
|
||||||
|
} catch (error) {
|
||||||
|
reject(new Error(`Failed to parse response: ${(error as Error).message}`));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
resolve({} as T);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
req.on('error', (error) => {
|
||||||
|
reject(new Error(`Request failed: ${error.message}`));
|
||||||
|
});
|
||||||
|
|
||||||
|
req.on('timeout', () => {
|
||||||
|
req.destroy();
|
||||||
|
reject(new Error(`Request timeout after ${this.timeout}ms`));
|
||||||
|
});
|
||||||
|
|
||||||
|
if (bodyBuffer.length > 0) {
|
||||||
|
req.write(bodyBuffer);
|
||||||
|
}
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async get<T>(path: string): Promise<T> {
|
||||||
|
return this.doRequest<T>('GET', path, '', null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async post<T>(path: string, contentType: string, body: unknown): Promise<T> {
|
||||||
|
return this.doRequest<T>('POST', path, contentType, body);
|
||||||
|
}
|
||||||
|
}
|
||||||
27
tests/interchain.test.ts
Normal file
27
tests/interchain.test.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { buildInterchainQuery } from '../src/interchain';
|
||||||
|
|
||||||
|
describe('buildInterchainQuery', () => {
|
||||||
|
it('returns empty string with no options', () => {
|
||||||
|
expect(buildInterchainQuery()).toBe('');
|
||||||
|
expect(buildInterchainQuery({})).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('encodes perChain (including 0 for all)', () => {
|
||||||
|
expect(buildInterchainQuery({ perChain: 1 })).toBe('?perChain=1');
|
||||||
|
expect(buildInterchainQuery({ perChain: 0 })).toBe('?perChain=0');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('joins chains with commas', () => {
|
||||||
|
expect(buildInterchainQuery({ chains: ['1', '0'] })).toBe('?chains=1,0');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('combines perChain and chains', () => {
|
||||||
|
expect(buildInterchainQuery({ perChain: 2, chains: ['1', '0'] })).toBe(
|
||||||
|
'?perChain=2&chains=1,0'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores an empty chains array', () => {
|
||||||
|
expect(buildInterchainQuery({ chains: [] })).toBe('');
|
||||||
|
});
|
||||||
|
});
|
||||||
79
tests/proofMeasure.test.ts
Normal file
79
tests/proofMeasure.test.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import { ProofMeasureClient, PROOF_MEASURE_DEFAULT_BASE_URL } from '../src/proofMeasure';
|
||||||
|
import { UnauthHttpClient } from '../src/unauthHttpClient';
|
||||||
|
import { ProofMeasureSecurityResult, ProofMeasureTransactionReport } from '../src/types';
|
||||||
|
|
||||||
|
// Build a fake transport that is an instanceof UnauthHttpClient (so the
|
||||||
|
// ProofMeasureClient constructor uses it directly) with mocked get/post.
|
||||||
|
function fakeClient(get?: jest.Mock, post?: jest.Mock): UnauthHttpClient {
|
||||||
|
const c = Object.create(UnauthHttpClient.prototype) as UnauthHttpClient;
|
||||||
|
(c as unknown as { get: jest.Mock }).get = get ?? jest.fn(async () => ({}));
|
||||||
|
(c as unknown as { post: jest.Mock }).post = post ?? jest.fn(async () => ({}));
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('ProofMeasureClient', () => {
|
||||||
|
it('getSecurity builds the path with since and returns the parsed result', async () => {
|
||||||
|
const sec: ProofMeasureSecurityResult = {
|
||||||
|
network: 'BTC',
|
||||||
|
consensus: 'pow',
|
||||||
|
raw: { value: '71.63', unit: 'Yottahashes', base: '7.16e25' },
|
||||||
|
valueUsd: '13928170.26',
|
||||||
|
valueUsdFormatted: '$13,928,170.26',
|
||||||
|
label: '$13,928,170.26 energy consumed',
|
||||||
|
normalizedScore: '0.9234',
|
||||||
|
since: 1780504894,
|
||||||
|
asOf: 1780591253,
|
||||||
|
};
|
||||||
|
const get = jest.fn(async () => sec);
|
||||||
|
const client = fakeClient(get);
|
||||||
|
const res = await new ProofMeasureClient(client).getSecurity('BTC', 1780504894);
|
||||||
|
expect(get).toHaveBeenCalledWith('/api/v1/security/BTC?since=1780504894');
|
||||||
|
expect(res.network).toBe('BTC');
|
||||||
|
expect(res.raw.unit).toBe('Yottahashes');
|
||||||
|
expect(res.valueUsdFormatted).toBe('$13,928,170.26');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getSecurity omits the since param when not provided', async () => {
|
||||||
|
const get = jest.fn(async () => ({}));
|
||||||
|
await new ProofMeasureClient(fakeClient(get)).getSecurity('ETH');
|
||||||
|
expect(get).toHaveBeenCalledWith('/api/v1/security/ETH');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('report POSTs the request to /api/v1/report and parses the report', async () => {
|
||||||
|
const report: ProofMeasureTransactionReport = {
|
||||||
|
transactionId: 't',
|
||||||
|
primeId: 'p',
|
||||||
|
blockId: '42',
|
||||||
|
anchors: [],
|
||||||
|
totalValueUsd: '100.00',
|
||||||
|
totalValueUsdFormatted: '$100.00',
|
||||||
|
hashPower: { value: '40.06', units: 'Yottahashes' },
|
||||||
|
totalNormalizedScore: '0.9',
|
||||||
|
};
|
||||||
|
const post = jest.fn(async () => report);
|
||||||
|
const client = fakeClient(undefined, post);
|
||||||
|
const req = {
|
||||||
|
transactionId: 't',
|
||||||
|
primeId: 'p',
|
||||||
|
blockId: '42',
|
||||||
|
anchors: [{ network: 'BTC', txHash: '0xabc', timestamp: 1712345678 }],
|
||||||
|
};
|
||||||
|
const rep = await new ProofMeasureClient(client).report(req);
|
||||||
|
expect(post).toHaveBeenCalledWith('/api/v1/report', 'application/json', req);
|
||||||
|
expect(rep.totalValueUsdFormatted).toBe('$100.00');
|
||||||
|
expect(rep.hashPower?.units).toBe('Yottahashes');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('health calls /api/v1/health', async () => {
|
||||||
|
const get = jest.fn(async () => ({ status: 'ok' }));
|
||||||
|
const res = await new ProofMeasureClient(fakeClient(get)).health();
|
||||||
|
expect(get).toHaveBeenCalledWith('/api/v1/health');
|
||||||
|
expect(res.status).toBe('ok');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('defaults to the public endpoint with no config', () => {
|
||||||
|
const pm = new ProofMeasureClient();
|
||||||
|
expect(pm).toBeInstanceOf(ProofMeasureClient);
|
||||||
|
expect(PROOF_MEASURE_DEFAULT_BASE_URL).toBe('https://proof-measure.dragonchain.com');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user