3 Commits

Author SHA1 Message Date
5ae6dbfc3f getInterchain: perChain + chains options (default first anchor per chain); bump 1.5.0
Some checks failed
Build and Test / build (20.x) (push) Failing after 41s
Build and Test / build (18.x) (push) Failing after 44s
Publish to NPM Registry / publish (release) Successful in 51s
Build and Test / build (16.x) (push) Failing after 47s
transaction.getInterchain / block.getInterchain take an optional
{ perChain?, chains? } that maps to prime-node's ?perChain=&chains= params.
Default (no options) returns one anchor per chain. Shared buildInterchainQuery
helper + InterchainOptions type, exported; jest-tested.
2026-06-05 10:55:30 -04:00
5a943f45a6 Add proof-measure client + bump to 1.4.0
Some checks failed
Build and Test / build (16.x) (push) Failing after 45s
Build and Test / build (18.x) (push) Failing after 40s
Publish to NPM Registry / publish (release) Successful in 54s
Build and Test / build (20.x) (push) Failing after 38s
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.
2026-06-04 13:45:09 -04:00
a35dc508b0 client: allow injecting an http(s).Agent + reject redirects
All checks were successful
Build and Test / build (18.x) (push) Successful in 50s
Build and Test / build (20.x) (push) Successful in 47s
Build and Test / build (16.x) (push) Successful in 54s
The HTTP client built its connections with no injectable agent, so a
server-side caller pointing the client at an attacker-influenced baseURL
(a tenant's prime_endpoint) had no way to attach an SSRF policy at connect
time. node's http.request doesn't follow redirects, but a 3xx was treated
as success and its body mis-parsed.

- ClientConfig accepts an optional `agent`; inject one whose connection
  factory refuses internal IPs (incl. DNS-rebinding defense) when the
  baseURL is untrusted. Default stays unguarded for trusted/CLI use — the
  guard belongs in the server.
- A 3xx response is now an explicit error ("refusing to follow"), so a
  redirect can't be silently mis-handled or, via a future change, followed
  to an internal host.
2026-06-04 12:41:35 -04:00
12 changed files with 515 additions and 5 deletions

View File

@@ -145,6 +145,55 @@ await sdk.transactionType.delete('my-type');
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
This SDK is written in TypeScript and includes complete type definitions:

View File

@@ -1,6 +1,6 @@
{
"name": "@dragonchain-inc/prime-sdk",
"version": "1.2.0",
"version": "1.5.0",
"description": "Official Dragonchain Prime SDK for Node.js and TypeScript",
"main": "./dist/index.js",
"module": "./dist/index.mjs",

View File

@@ -3,6 +3,7 @@
*/
import { DragonchainClient } from './client';
import { InterchainOptions, buildInterchainQuery } from './interchain';
import { Block, InterchainTrace } from './types';
export class BlockClient {
@@ -23,7 +24,9 @@ export class BlockClient {
* Traces a block to the validator (verification) blocks that validated it and
* the public-chain interchain anchors those validator blocks were bundled into.
*/
async getInterchain(blockId: string): Promise<InterchainTrace> {
return this.client.get<InterchainTrace>(`/api/v1/block/${blockId}/interchain`);
async getInterchain(blockId: string, options?: InterchainOptions): Promise<InterchainTrace> {
return this.client.get<InterchainTrace>(
`/api/v1/block/${blockId}/interchain${buildInterchainQuery(options)}`
);
}
}

View File

@@ -13,6 +13,15 @@ export interface ClientConfig {
authKey: string;
baseURL: string;
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 {
@@ -21,6 +30,7 @@ export class DragonchainClient {
private readonly authKey: string;
private readonly baseURL: string;
private readonly timeout: number;
private readonly agent?: http.Agent | https.Agent;
constructor(config: ClientConfig) {
this.publicId = config.publicId;
@@ -28,6 +38,7 @@ export class DragonchainClient {
this.authKey = config.authKey;
this.baseURL = config.baseURL.replace(/\/$/, ''); // Remove trailing slash
this.timeout = config.timeout || 30000; // Default 30 seconds
this.agent = config.agent;
}
/**
@@ -123,6 +134,7 @@ export class DragonchainClient {
'Content-Length': bodyBuffer.length,
},
timeout: this.timeout,
...(this.agent && { agent: this.agent }),
};
return new Promise<T>((resolve, reject) => {
@@ -137,6 +149,19 @@ export class DragonchainClient {
res.on('end', () => {
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
if (res.statusCode && res.statusCode >= 400) {
const errorMessage = responseBody.toString('utf8').trim();

View File

@@ -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,9 @@ 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';
export { InterchainOptions, buildInterchainQuery } from './interchain';
// Default export
export default DragonchainSDK;

37
src/interchain.ts Normal file
View 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
View File

@@ -0,0 +1,69 @@
/**
* Client for the Dragonchain proof-measure service — the measured-immutability
* / "securedBy" metric for L1L5 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');
}
}

View File

@@ -3,6 +3,7 @@
*/
import { DragonchainClient } from './client';
import { InterchainOptions, buildInterchainQuery } from './interchain';
import {
ContentType,
TransactionCreateRequest,
@@ -56,8 +57,13 @@ export class TransactionClient {
* blocks were bundled into. If the transaction is still pending (not yet in a
* block) the trace's arrays are empty.
*/
async getInterchain(transactionId: string): Promise<InterchainTrace> {
return this.client.get<InterchainTrace>(`/api/v1/transaction/${transactionId}/interchain`);
async getInterchain(
transactionId: string,
options?: InterchainOptions
): Promise<InterchainTrace> {
return this.client.get<InterchainTrace>(
`/api/v1/transaction/${transactionId}/interchain${buildInterchainQuery(options)}`
);
}
/**

View File

@@ -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;
}

139
src/unauthHttpClient.ts Normal file
View 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
View 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('');
});
});

View 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');
});
});