zetrix-tradetrust-sdk
v0.1.0
Published
Node.js/TypeScript SDK for TradeTrust on Zetrix L2 (thin client over the core-engine REST API).
Maintainers
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-sdkQuick 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 keyThe 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 whetherbaseUrlcarries the/api/z2-core-engineprefix.
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 ofmethod + 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
400then401; the same body one second later gives400both 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
clientIdand 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 callsetClientSecret()— 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 ownVcSignerkeeps the key in your process and is the default for a reason.The transport has to be
https://. A remotehttp://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 thehttp://localhostdefault 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.mandatoryPointersto 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
preparationIdis single-use and expires (~15 min), after which the reserved status-list index is reclaimed andcompleteanswers 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 listrevoke 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 callingrevoke— 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
credentialSubjectfield must be a term the suppliedcontextdefines, because TrustVC signs in safe mode. An undefined term currently surfaces as aServerError(500internal 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.verifyandebl.verifyresolve withvalid: 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
CoreEngineErrorsubclass.
| 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 | status — 0 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.tsCoverage 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.tsprepare 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
