From 5a943f45a69813a0ac30e41b602da586768b6aa0 Mon Sep 17 00:00:00 2001 From: Andrew Miller Date: Thu, 4 Jun 2026 13:45:09 -0400 Subject: [PATCH] Add proof-measure client + bump to 1.4.0 proof-measure is a separate, public, unauthenticated Dragonchain service. Adds: - UnauthHttpClient: HMAC-free transport mirroring DragonchainClient (timeout, agent, redirect refusal). - ProofMeasureClient: getSecurity / report / health; default base URL https://proof-measure.dragonchain.com. Standalone (new ProofMeasureClient()) and via DragonchainSDK.proofMeasure. - Proof-measure types (decimals as strings, timestamps as numbers). - jest tests. --- README.md | 31 +++++++++ package.json | 2 +- src/index.ts | 13 ++++ src/proofMeasure.ts | 69 ++++++++++++++++++ src/types.ts | 62 +++++++++++++++++ src/unauthHttpClient.ts | 139 +++++++++++++++++++++++++++++++++++++ tests/proofMeasure.test.ts | 79 +++++++++++++++++++++ 7 files changed, 394 insertions(+), 1 deletion(-) create mode 100644 src/proofMeasure.ts create mode 100644 src/unauthHttpClient.ts create mode 100644 tests/proofMeasure.test.ts diff --git a/README.md b/README.md index 73b939b..cef2e2b 100644 --- a/README.md +++ b/README.md @@ -145,6 +145,37 @@ await sdk.transactionType.delete('my-type'); const block = await sdk.block.get('block-id'); ``` +### 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 This SDK is written in TypeScript and includes complete type definitions: diff --git a/package.json b/package.json index 623c8b7..ef5609d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@dragonchain-inc/prime-sdk", - "version": "1.2.0", + "version": "1.4.0", "description": "Official Dragonchain Prime SDK for Node.js and TypeScript", "main": "./dist/index.js", "module": "./dist/index.mjs", diff --git a/src/index.ts b/src/index.ts index b67bb2f..40e16c7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,6 +10,7 @@ import { TransactionTypeClient } from './transactionType'; import { ContractClient } from './contract'; import { BlockClient } from './block'; import { SystemClient } from './system'; +import { ProofMeasureClient, PROOF_MEASURE_DEFAULT_BASE_URL } from './proofMeasure'; /** * Main Dragonchain SDK class @@ -22,6 +23,13 @@ export class DragonchainSDK { public readonly contract: ContractClient; public readonly block: BlockClient; 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 @@ -53,6 +61,9 @@ export class DragonchainSDK { this.contract = new ContractClient(this.client); this.block = new BlockClient(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,8 @@ export { TransactionTypeClient } from './transactionType'; export { ContractClient } from './contract'; export { BlockClient } from './block'; export { SystemClient } from './system'; +export { ProofMeasureClient, PROOF_MEASURE_DEFAULT_BASE_URL } from './proofMeasure'; +export { UnauthHttpClient, UnauthClientConfig } from './unauthHttpClient'; // Default export export default DragonchainSDK; diff --git a/src/proofMeasure.ts b/src/proofMeasure.ts new file mode 100644 index 0000000..6b46ce5 --- /dev/null +++ b/src/proofMeasure.ts @@ -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 { + const query = since && since > 0 ? `?since=${since}` : ''; + return this.client.get(`/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 { + return this.client.post( + '/api/v1/report', + ContentType.JSON, + request + ); + } + + /** Reports service liveness and DB reachability. */ + async health(): Promise { + return this.client.get('/api/v1/health'); + } +} diff --git a/src/types.ts b/src/types.ts index 22d8810..5f5ad88 100644 --- a/src/types.ts +++ b/src/types.ts @@ -213,3 +213,65 @@ export interface ListResponse { items: unknown[]; 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; +} diff --git a/src/unauthHttpClient.ts b/src/unauthHttpClient.ts new file mode 100644 index 0000000..1514070 --- /dev/null +++ b/src/unauthHttpClient.ts @@ -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( + method: string, + path: string, + contentType: string, + body: unknown + ): Promise { + 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((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(path: string): Promise { + return this.doRequest('GET', path, '', null); + } + + public async post(path: string, contentType: string, body: unknown): Promise { + return this.doRequest('POST', path, contentType, body); + } +} diff --git a/tests/proofMeasure.test.ts b/tests/proofMeasure.test.ts new file mode 100644 index 0000000..ff55434 --- /dev/null +++ b/tests/proofMeasure.test.ts @@ -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'); + }); +});