npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

zetrix-tradetrust-sdk

v0.1.0

Published

Node.js/TypeScript SDK for TradeTrust on Zetrix L2 (thin client over the core-engine REST API).

Readme

zetrix-tradetrust-sdk

Node.js / TypeScript client for TradeTrust on the Zetrix L2 — issue and verify verifiable documents, mint and transfer electronic Bills of Lading, and resolve ZID identities, without calling the REST API by hand.

A deliberately thin, fully-typed client over the core-engine REST API. It wraps core-engine rather than duplicating TrustVC or chain logic, and carries exactly one runtime dependency (undici, for TLS material).

This SDK holds no private keys and never signs. Neither does core-engine: issuing, status changes and eBL writes all require a signature your application produces. See Signing — it is the single biggest thing to understand before using this library.

Installation & prerequisites

Requires Node 18 or newer.

npm install zetrix-tradetrust-sdk

Quick start

import { Sdk } from "zetrix-tradetrust-sdk";

const sdk = new Sdk({
  baseUrl: "https://api-tradetrust.zetrix.com/api/z2-core-engine",
  clientId: process.env.CORE_ENGINE_CLIENT_ID,
  clientSecret: process.env.CORE_ENGINE_HMAC_SECRET,
});

const health = await sdk.health();
console.log(health.status);   // "ok" — and no issuer: core-engine holds no key

The base URL must include /api/z2-core-engine. Route paths are joined onto it as-is, and the resulting path is part of what gets signed. Omitting the prefix does not merely 404: on a CDN-fronted deployment only the prefixed paths are routed through, so the request never reaches core-engine at all and comes back as an HTML challenge page.

Runnable, typechecked versions of everything below live in this SDK's examples/ directory — not included in the published npm package, so clone the source to run them.

Configuration

Everything is set through the Sdk constructor. Only baseUrl is required — issuer keys, chain RPC, signer credentials and resolver URLs all live server-side in core-engine and are never configured here.

| Option | Default | Notes | |---|---|---| | baseUrl | — | Required, prefix included. Trailing slashes are stripped. | | clientId | none | Caller identity for HMAC request signing. Set together with clientSecret. | | clientSecret | none | Shared secret for HMAC request signing. | | clock | system clock | Injectable, for tests. Skew shows up as a 401. | | clientCert | none | PKCS#12 (pfx bytes or pfxPath) + passphrase, if a proxy terminates mTLS. | | trustedCa / trustedCaPath | system CAs | PEM CA used to verify core-engine's certificate. | | timeoutMs | 30_000 | Bounds the whole exchange, response body included. | | fetch | global fetch | Custom implementation, for proxies or tests. |

Caller authentication (HMAC-SHA256)

core-engine authenticates callers by signing every request — there is no API key. Set clientId and clientSecret and the SDK attaches x-client-id, x-timestamp and x-signature to every call:

const sdk = new Sdk({
  baseUrl: "https://api-tradetrust.zetrix.com/api/z2-core-engine",
  clientId: process.env.CORE_ENGINE_CLIENT_ID,
  clientSecret: process.env.CORE_ENGINE_HMAC_SECRET,
});

Leave both unset for a local core-engine started without HMAC_CLIENT_SECRETS_DIR, which enforces nothing. Setting one without the other throws at construction rather than sending unsigned requests.

Which routes are exempt is deployment configuration, not a property of core-engine: the operator sets NO_AUTH_PATHS. Do not assume a route is open because it only reads. Measured against the public sandbox deployment:

| Exempt (200 unsigned) | Enforced (401 unsigned) | |---|---| | GET /health | GET /ebl/{tokenId}/owner | | GET /finality/{txHash} | GET /ebl/{tokenId}/endorsement-chain | | GET /identity/resolve/{address} | POST /credentials/prepare | | POST /credentials/verify | …and every other write | | POST /ebl/verify | |

Two of those enforced routes are pure reads, which is the reason to measure rather than reason about it. Configure clientId/clientSecret unless you are certain every route you touch is exempt on the deployment you are calling.

Every rejection looks the same. No signature, wrong secret, a clock more than five minutes off, a replayed signature — all return an identical 401 {"error": "a valid client signature is required"}, deliberately, so a caller cannot probe which check failed. Nothing in the response narrows it down. Check the secret, then the clock, then whether baseUrl carries the /api/z2-core-engine prefix.

Two properties worth designing around:

  • Each signature is admitted exactly once, and the timestamp has one-second resolution. Those two facts together are the one real operational gotcha here. core-engine refuses a (clientId, signature) pair it has already seen, and the signature is a function of method + path + query + timestamp + body-hash — so two byte-identical requests issued inside the same clock second produce the same signature, and the second one is rejected as a replay. The 401 is indistinguishable from a wrong secret.

    The SDK signs per call, so it never resends cached headers — but signing per call is not enough on its own. If your retry policy resends the same body to the same path fast enough to land in the same second, it will 401. Measured against the sandbox: the identical request twice in one second gives 400 then 401; the same body one second later gives 400 both times.

    Practically, for a service doing retries or concurrent submits: space retries beyond one second, or vary something in the body per attempt (a nonce, a client-side request id). Idempotent-looking retries are the case that bites.

  • Rotation is a hard cutover. core-engine holds one secret per clientId and swaps it atomically, with no window where both are accepted. sdk.setClientSecret(next) rotates a long-lived client in place; coordinate the moment with whoever operates the deployment. Fetching from a vault or KMS? Read the value and call setClientSecret() — the SDK deliberately takes no auto-refreshing provider, because a provider that can return a stale value just hides the coordination problem one layer down.

Diagnosing a 401

Because every rejection returns the same message, the SDK adds the one clue the server cannot. When a signed request is refused and this host's clock is more than 60s from core-engine's — measured from the response Date header — the UnauthorizedError message carries a hint:

a valid client signature is required [SDK diagnostic: this host's clock appears to be 412s behind
core-engine's, measured from the response Date header. ... Sync this host's time (NTP) and retry.]

It stays a hint: skew and a wrong secret are indistinguishable from the response, so the wording says "plausible cause" rather than asserting one. The error type and status are unchanged, nothing is suppressed, and the SDK never adjusts the timestamp it signs with to compensate — correcting for skew client-side would hide a broken host clock from whoever can fix it.

sdk.getClockSkewSeconds() exposes the same measurement (positive = this host is ahead, undefined before the first response) for callers who would rather watch it as a metric than meet it as a failure. It is measured on every response, including successful ones, so drift is visible before it starts costing 401s.

Signing your own requests — a different HTTP client, a proxy, another service — is supported without reimplementing the canonical message:

import { buildHmacHeaders } from "zetrix-tradetrust-sdk";

const headers = buildHmacHeaders({
  method: "POST",
  path: "/api/z2-core-engine/credentials/prepare",   // full path, prefix included
  body: JSON.stringify(payload),
  clientId,
  clientSecret,
});

TLS

clientCert and trustedCa remain for deployments that terminate mTLS at a proxy in front of core-engine. trustedCa is what you need when core-engine's server certificate is signed by a private CA: omit it and the handshake fails, which the SDK detects and names in the error message rather than reporting a generic "fetch failed".

Signing: the SDK holds no keys

core-engine holds no issuer key, and neither does this SDK. Three operations therefore need a signature from your application, through three function types exported from the package root:

| Type | Signs | Used by | |---|---|---| | VcSigner | an ecdsa-sd-2023 proof over an unsigned VC | credentials.issue/complete, ebl.mint/completeMint | | StatusSigner | the canonical status-mutation message (P-256) | credentials.setStatus/revoke/unrevoke | | TypedDataSigner | EIP-712 ForwardRequest | ebl.relay (gasless) |

A VcSigner is a thin wrapper over @trustvc/w3c-vc — the same library core-engine verifies with, so interop is not in question. It is deliberately not a dependency here: it pulls a ~90 MB JSON-LD and crypto tree that consumers who only verify should not carry.

import { signCredential, deriveCredential } from "@trustvc/w3c-vc";

const signer: VcSigner = async (unsigned, spec) => {
  const { signed, error } = await signCredential(unsigned, issuerKeyPair, spec.cryptoSuite, {
    mandatoryPointers: spec.mandatoryPointers,   // verbatim from the spec — never rebuild it
  });
  if (error) throw new Error(error);
  const { derived } = await deriveCredential(signed, []);
  return derived;
};

Or let core-engine sign — signRemote()

If you cannot hold an issuer key in your process, core-engine can apply the signature for you. credentials.issueRemote(request, keyPair) runs prepare → sign → complete, and credentials.signRemote(preparationId, keyPair) is the middle step on its own:

const { verifiableCredential } = await sdk.credentials.issueRemote(
  { issuerDid, credentialSubject: { type: "Coo", cooId: "COO/MY/2026/0001" } },
  {
    "@context": "https://w3id.org/security/multikey/v1",
    id: `${issuerDid}#key-1`,
    type: "Multikey",
    controller: issuerDid,          // must match issuerDid — checked locally, before any call
    publicKeyMultibase,
    secretKeyMultibase,             // your PRIVATE key, sent to core-engine
  },
);

This sends the issuer's private key over the wire. core-engine holds it in memory for the call and discards it on a best-effort basis — an implementation behaviour, not a cryptographic guarantee. issue() with your own VcSigner keeps the key in your process and is the default for a reason.

The transport has to be https://. A remote http:// base URL is refused before anything is sent, rather than attempted: custody is only half the question, and a key read off the network leaves no error behind to notice. Loopback is exempt, so the http://localhost default every example here uses still works. This is the only request body in the SDK carrying secret key material — no other route is affected.

It is a separate method, never a fallback: the SDK will not quietly send your key because a local signer is missing. Off unless the deployment sets ALLOW_ISSUER_SIGNING, in which case the route is not registered and you get IssuerSigningUnavailableError naming the flag rather than a bare 404.

A StatusSigner needs no dependency at all — it is plain P-256 over SHA-256, and WebCrypto returns the raw 64-byte r‖s core-engine expects:

const statusSigner: StatusSigner = async (message) => {
  const key = await webcrypto.subtle.importKey("jwk", jwk, { name: "ECDSA", namedCurve: "P-256" }, false, ["sign"]);
  const sig = await webcrypto.subtle.sign({ name: "ECDSA", hash: "SHA-256" }, key, new TextEncoder().encode(message));
  return Buffer.from(sig).toString("hex");
};

Usage — verifiable documents

Issuing is prepare → you sign → complete. core-engine builds the unsigned document and tells you how it must be signed; your key never leaves your process.

const { verifiableCredential: vc } = await sdk.credentials.issue(
  {
    issuerDid: "did:web:trade.zetrix.com",   // required: core-engine cannot infer it
    credentialSubject: { type: "Coo", id: "did:web:acme-exports.com", cooId: "COO/MY/2026/0001" },
    context: ["https://www.w3.org/ns/credentials/v2", "https://trustvc.io/context/coo.json"],
    statusPurpose: "revocation",             // makes it revocable later
  },
  signer,                                     // VcSigner
);

issue is one call over three steps. Use prepare and complete separately when signing cannot be a synchronous in-process call — an HSM queue, or a different service:

const prepared = await sdk.credentials.prepare(request);
const signed = await signer(prepared.unsignedVerifiableCredential, prepared.signingSpec);
const { verifiableCredential } = await sdk.credentials.complete(prepared.preparationId, signed);

Pass signingSpec.mandatoryPointers to the signer verbatim. core-engine derives it from what it actually built, decorations included (renderMethod, qrCode, expirationDate), so reconstructing the list silently drops them from the signed base proof.

A preparationId is single-use and expires (~15 min), after which the reserved status-list index is reclaimed and complete answers 404.

Verification is open and needs no signature:

const result = await sdk.credentials.verify({
  verifiableCredential: vc,
  trustedIssuers: ["did:web:trade.zetrix.com"],   // optional allowlist
});
if (result.valid && result.trusted) { /* accept */ }

valid means cryptographically valid + issuer-bound + not revoked. It does not mean trusted — require both.

Revoking

Status changes are authorized by the issuer's own signature, not by an API key:

import { statusMutationMessage } from "zetrix-tradetrust-sdk";

const nonce = crypto.randomUUID();
const timestamp = new Date().toISOString();
const message = statusMutationMessage(issuerDid, "revocation", 42, true, nonce, timestamp);
const auth = { nonce, timestamp, signature: await statusSigner(message) };

await sdk.credentials.revoke(vc, auth);              // index + issuer read off the credential
await sdk.credentials.revoke(issuerDid, 42, auth);   // or by revocation-list index
await sdk.credentials.setStatus(issuerDid, "suspension", 7, true, auth);   // any other list

revoke and unrevoke can only ever address the revocation list. Neither takes a purpose — the verb is the purpose — so there is no call named revoke that writes somewhere else. revoke(vc, auth) takes only the index from the credential and rejects one issued with statusPurpose: "suspension" rather than flipping the wrong bit, because each list allocates its indexes independently. Reach the suspension list through setStatus, where the purpose is explicit at the call site.

The signature covers the purpose, so build the message with "revocation" when calling revoke — a mismatch comes back as a bare "signature invalid".

Status lists are per-issuer, so reading one takes the issuer DID:

const listVc = await sdk.credentials.statusList(issuerDid, "revocation");

Issuing is strict about JSON-LD: every credentialSubject field must be a term the supplied context defines, because TrustVC signs in safe mode. An undefined term currently surfaces as a ServerError (500 internal error) rather than a 400, so the message will not name the offending field — check every subject property against the context you passed.

Usage — transferable records / eBL

Every write returns an unsigned transaction, not a receipt. core-engine builds calldata and signs nothing, so the from party signs and broadcasts the returned UnsignedTx itself. Nothing has happened on-chain until it is mined — a caller who ignores the result gets HTTP 200 and no state change.

const tx = await sdk.ebl.transferHolder(tokenId, newHolder, from, tokenRegistry);
// ethers / your wallet: sign { tx.to, tx.data, tx.value } for tx.chainId, then broadcast.

tokenRegistry is required everywhere. A tokenId is only unique within one registry contract and anyone can self-onboard a registry, so there is no safe fallback; omitting it is a 400.

// Mint: prepare -> your VC signer -> complete, and you still broadcast the mint transaction.
const minted = await sdk.ebl.mint(
  {
    credentialSubject: { type: "BillOfLading", blNumber: "BL-2026-0001" },
    context: ["https://www.w3.org/ns/credentials/v2",
              "https://trustvc.io/context/bill-of-lading.json"],
    tokenRegistry,
  },
  from,      // must hold MINTER_ROLE on the registry
  signer,    // VcSigner — the same one verifiable documents use
);
minted.tokenId;
minted.unsignedMintTx;   // still to be signed and broadcast

// Reads are open — no signature, no transaction.
const { owner } = await sdk.ebl.owner(tokenId, tokenRegistry);
const result = await sdk.ebl.verify({ verifiableCredential: minted.verifiableCredential });
result.onChain.status;   // "active" | "surrendered" | "burned" | "not-minted"

// Transfers and the surrender flow — each returns an UnsignedTx to sign and broadcast.
await sdk.ebl.transferBeneficiary(tokenId, newBeneficiary, from, tokenRegistry);
await sdk.ebl.nominate(tokenId, newBeneficiary, from, tokenRegistry);
await sdk.ebl.surrender(tokenId, from, tokenRegistry);
await sdk.ebl.acceptSurrender(tokenId, from, tokenRegistry);   // -> burned (retired)
await sdk.ebl.rejectSurrender(tokenId, from, tokenRegistry);   // -> active, back to the last holder

// Provenance, and gasless submission via the relay.
const chain = await sdk.ebl.endorsementChain(tokenId, tokenRegistry);
const relayed = await sdk.ebl.relay(relayRequest, typedDataSigner);

Transfers cannot be undone. As the one guard it can offer, the SDK rejects the zero address before any request — sending a record there would burn title to the goods permanently. It checks address shape only, not the EIP-55 checksum: that needs keccak256, and this SDK stays dependency-free.

As with credentials.verify, a failed eBL verification is data, not an exception — a burned or never-minted record returns valid: false.

Usage — finality

Whether a transaction is on-chain, and how deep. Unlike every other method it takes only a hash, so it works for a transaction this SDK never submitted.

const status = await sdk.finality("0x614edb52…");   // open endpoint
status.final;          // true once mined — QBFT is deterministic, no confirmation threshold
status.confirmations;  // 0 while unmined
status.l2Block;        // number | null
status.l1Settled;      // boolean | null — null means NOT CHECKED, not "not settled"

You do need this after an eBL write. Since writes return an unsigned transaction that you broadcast, nothing has waited for mining — poll until final.

An unknown hash and a still-pending one look identical — both are HTTP 200 with final: false, so a while (!status.final) loop over a typo'd hash never terminates. Bound your polling.

/finality/* is registered unconditionally: a deployment with no eBL registry still answers 200 with final: false.

Usage — ZID identity (optional layer)

Ties a wallet address to a did:zid. Additive: it never affects whether a document verifies.

import { bindingMessage } from "zetrix-tradetrust-sdk";

const { did } = await sdk.identity.resolve("0x2E737fb3353cdC660448e36563A03ceB4fE96fEe");
if (did === null) {
  // not a registered participant — this is a 200, NOT a 404
}

const message = bindingMessage(did, address, "n-0001");
const result = await sdk.identity.bind({
  did, address, nonce: "n-0001",
  ed25519Signature: /* the ZID key over `message` */ "0x…",
  walletSignature: await wallet.signMessage(message),   // ethers personal_sign
});

An unbound address is did: null, not a 404. Lookup is case-insensitive, but the response echoes the address exactly as sent — compare case-insensitively.

A rejected proof is data, not an exception — 200 with bound: false and a reason. A correct proof still returns bound: false against a deployment with no ZID resolver configured (every binding fails with "did not resolvable"), so check the server's configuration before suspecting your signatures.

Errors

Two rules cover the whole surface:

  • A failed verification is data, not an exception. credentials.verify and ebl.verify resolve with valid: false. A revoked credential or a burned token is a successful call with a negative answer.
  • Transport failures and non-2xx responses reject, always with a CoreEngineError subclass.

| Error | Rejected on | Carries | |---|---|---| | BadRequestError | 400 | core-engine's error message | | UnauthorizedError | 401 | caller auth failed — one message for every cause (see above) | | NotFoundError | 404 | unknown token, status list, expired preparationId, or a pillar not enabled | | IssuerSigningUnavailableError | 404 | subtype: /credentials/sign is not registered — ALLOW_ISSUER_SIGNING is unset | | RateLimitError | 429 | retryAfterSeconds (may be undefined) | | ServiceDisabledError | 503 | a feature switched off server-side | | ServerError | 5xx | status | | CoreEngineError | anything else, incl. transport | status0 when no HTTP response was received |

A timeout rejects with a CoreEngineError (status 0) whose message names the budget that expired, so it is distinguishable from a refused connection. A certificate-path failure is detected too and points at trustedCa.

Tests & development

npm test                    # unit tests (hermetic — no network)
npm run test:coverage       # + coverage report
npm run typecheck           # src/
npm run typecheck:examples  # examples/ — keeps them from rotting
npm run build               # emits dist/ with .d.ts

Coverage is measured and reported, but not gated — no threshold fails the build. (src/models.ts reads as 0%: it declares types only and emits no runtime code, so the figure is meaningless there.)

Every method is written against openapi.json, committed at the root of this SDK's source — a snapshot of core-engine's /openapi.json. test/coverage.test.ts (also source-only, not part of the published package) reconciles the two: every spec endpoint is implemented or deferred, no deferred entry is already implemented, no claimed endpoint has vanished from the spec, and every property core-engine marks required has a field on the model that builds that request. That last guard exists because the others cannot see its failure mode — a route can keep its path and gain a required property, turning every request the SDK could build into a silent 400.

The examples take CORE_ENGINE_BASE_URL, so they can be pointed at a local core-engine container or at a real deployment:

CORE_ENGINE_BASE_URL=http://localhost:8080/api/z2-core-engine \
  npx tsx examples/01-issue-prepare-sign-complete.ts

CORE_ENGINE_BASE_URL=https://api-tradetrust-sandbox.zetrix.com/api/z2-core-engine \
  CORE_ENGINE_CLIENT_ID=<id> CORE_ENGINE_HMAC_SECRET=<secret> \
  npx tsx examples/05-hmac-and-remote-signing.ts

prepare needs no keys, so the issuing example runs end to end against a local container up to the signing step. A deployment without ZID_RESOLVER_URL rejects every identity binding ("did not resolvable"), and /ebl/* answers 404 without a registry — both expected.

License

MIT