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

@smartledger/notaryhash

v2.5.1

Published

Client SDK for NotaryHash — sign hashes locally, anchor them to BSV, and verify certificates offline against Bitcoin block headers. Privacy-preserving and post-quantum (ML-DSA / SLH-DSA).

Readme

@smartledger/notaryhash

Client SDK for NotaryHash — anchor a signed hash to the BSV blockchain and verify the resulting certificate, in any Node.js app.

  • Privacy-preserving — you hash and sign locally; only the hash, signature, and public key are ever sent. Your document and private key never leave your process.
  • Post-quantum signatures — ECDSA plus ML-DSA (FIPS 204) and SLH-DSA (FIPS 205; see the note on SLH-DSA under "Version 2.0").
  • Trustless verification — verify a certificate against Bitcoin block headers alone, with no dependency on the issuing service.
  • Tiny — depends only on @noble crypto. No server code.
npm install @smartledger/notaryhash

Notarize (sign locally, anchor via a service)

import { generateKey, notarizeBytes } from '@smartledger/notaryhash';
import { readFileSync } from 'node:fs';

const key = generateKey('ECDSA-secp256k1'); // keep key.privateKey secret — it never leaves you

const { certificate } = await notarizeBytes(readFileSync('contract.pdf'), key.privateKey, {
  url: 'https://notaryhash.com',
  apiKey: process.env.NOTARYHASH_API_KEY, // write endpoint requires a key
});

console.log('anchored:', certificate.anchor.txid);

Swap generateKey('ML-DSA-65') (or any of the 16 algorithms) for post-quantum signatures — pass the same algorithm to notarizeBytes via the options.

Retrying safely

A certificate's id covers its createdAt, to the second. The service returns the existing certificate only for a request with the same createdAt; without one, every call is stamped with the current time, so calling notarizeBytes again after a timeout anchors the same record a second time. If a record must be anchored once, fix its time before the first attempt and reuse it:

import { canonicalCreatedAt, notarizeBytes } from '@smartledger/notaryhash';

const createdAt = canonicalCreatedAt();   // choose once; store it with your record
// ...on every attempt, including retries after a timeout or a 5xx:
const { certificate, alreadyExisted } = await notarizeBytes(bytes, key.privateKey, {
  url, apiKey, createdAt,
});

That is enough for ECDSA, whose signatures here are deterministic. ML-DSA and SLH-DSA signatures can differ from one call to the next, which also changes the id, so for those sign once, store the body, and resend it:

import { hashFile, sign, submitNotarize } from '@smartledger/notaryhash';

const body = sign('ML-DSA-65', key.privateKey, hashFile(bytes));   // store this
const { certificate } = await submitNotarize(body, { url, apiKey }); // safe to repeat

Version 2.0: a signature that says what it is for

By default ("1.0") the signature is over the payload hash itself. That has two limits: nothing in it says it was made for NotaryHash, and createdAt is not signed, so anyone can have a published signature anchored again under another time. Ask for 2.0 and both go:

const body = sign('ML-DSA-65', key.privateKey, hashFile(bytes), canonicalCreatedAt(), { version: '2.0' });
const { certificate } = await submitNotarize(body, { url, apiKey });   // certificate.version === '2.0'
// or: notarizeBytes(bytes, key.privateKey, { url, apiKey, algorithm: 'ML-DSA-65', version: '2.0' })

The signer signs SHA-256(lp("NotaryHash/2.0 signature") || lp(algorithm) || lp(hashAlgorithm) || lp(payloadHash) || lp(publicKey) || u64be(createdAt seconds)) (signedMessage() computes it). ML-DSA signs it with the FIPS 204 context string NotaryHash/2.0; ECDSA signs it as the digest and has one encoding (64-byte low-s signature, 33-byte key). Verification needs nothing new: verifyDocument and verifyCertificateStandalone read the certificate's version and apply its rule.

  • Everyone who verifies your 2.0 certificates needs SDK 2.4.0 or later. Earlier versions, and other verifiers written for 1.0, refuse a 2.0 certificate; they do not misread it.
  • If you issue 2.0 only, tell your verifiers to pass acceptVersions: ['2.0'] in the policy. An ECDSA 2.0 signature can otherwise be re-presented as a 1.0 certificate of a hash that is no document's. (An ML-DSA one cannot: the context string differs.)
  • createdAt is now your signed claim. It is still not evidence of time; the block is.
  • SLH-DSA. In 1.0, an SLH-DSA signature made by this SDK is FIPS 205's internal signing function over the payload hash, without the standard context prefix, so a standard FIPS 205 verifier rejects it unless it exposes that function. It verifies with this SDK and is documented in SPEC §2. 2.0 specifies the standard function; this SDK does not offer 2.0 SLH-DSA yet and says so if asked.

Sealed chains

When a history must not fork (checkpoints of a log, the state of a registry), make each record the sealed successor of the one before. The anchoring transaction spends a seal output of the previous record's transaction, and an output is spent once, so a record can have only one successor. Anyone can check the link from the two certificates.

import { canonicalCreatedAt, hashFile, sealBody, sign, submitNotarize, verifySealedLink } from '@smartledger/notaryhash';

// Sign once, seal once, STORE the body, and send that on every attempt.
const signed = sign('ECDSA-secp256k1', key.privateKey, hashFile(recordBytes), canonicalCreatedAt());
const body = sealBody(signed, key.privateKey, previousCertificateId); // null starts a chain
const { certificate } = await submitNotarize(body, { url, apiKey });

// Later, with both certificates confirmed:
const v = await verifySealedLink(previousCertificate, certificate, headers);
// v.link: 'linked' | 'invalid' | 'indeterminate'    v.ok === (v.link === 'linked')
  • The previous record must be in a block before it can be given a successor (the service answers 409 until then), so a chain advances at most once per block.
  • In a sealed record the signer signs the certificate id, which covers createdAt. In an ordinary certificate createdAt is NOT signed: anyone can re-anchor a published signature under another createdAt. If you issue certificates, treat only the id you stored as yours.
  • Pin the certificate id of the first record of the chain you mean, and the signer's key. linked says "this is the one successor of that record"; it does not say the newer record has no successor of its own, which only a chain source can tell you, at the time you ask.
  • notarizeBytes(bytes, key, { url, apiKey, createdAt, seal: { previous } }) does the same in one call for ECDSA; for ML-DSA and SLH-DSA store the sealed body as above.

Verify (offline, against block headers — no trust in the service)

import {
  verifyCertificateStandalone, MultiSourceHeaderProvider, WocHeaderProvider, BitailsHeaderProvider,
  DEFAULT_HEADER_POLICY,
} from '@smartledger/notaryhash';

// Two independent sources; both must agree.
const headers = new MultiSourceHeaderProvider([
  new WocHeaderProvider('https://api.whatsonchain.com/v1/bsv/main'),
  new BitailsHeaderProvider('https://api.bitails.io'),
]);

const verdict = await verifyCertificateStandalone(certificate, headers);
// { ok, signatureValid, proofHashValid, anchorValid, anchorStatus: 'valid' | 'invalid' | 'indeterminate',
//   blockTime (from the block header; null unless the anchor is valid),
//   verificationMethod: 'spv', blockHeight, confirmations, reasons }

// Stricter: six confirmations, each header linked to the one below.
await verifyCertificateStandalone(certificate, headers, { ...DEFAULT_HEADER_POLICY, minConfirmations: 6 });

The anchor check accepts a block header only as its 80 bytes: they must hash to the certificate's block hash, meet their own proof-of-work target and a minimum difficulty (DEFAULT_HEADER_POLICY.minDifficulty, 5e9), and the sources must hold the block on the main chain at that height. A custom HeaderProvider must therefore return raw (the 80 bytes as hex) and implement getHeaderAtHeight; without them the anchor is indeterminate. anchorStatus: 'indeterminate' means the check could not be completed (source outage, disagreement, not yet deep enough) and says nothing against the certificate.

To check a certificate is about a particular file, use verifyDocument. It needs the file's bytes, hashes them, and is ok only if the certificate verifies and the hash is its payloadHash:

import { verifyDocument } from '@smartledger/notaryhash';
const v = await verifyDocument(certificate, readFileSync('contract.pdf'), headers);
// v.document: 'matched' | 'mismatched' | 'unsupported-hash'

verifyCertificateStandalone's verdict says document: 'unchecked'; read certificateOk there, which says in its name what it covers (ok from verifyDocument covers the file too). A valid certificate shows that a key signed some hash and it was anchored, not that it is about a file next to it. Never display "verified" beside a file you have not hashed.

verifyCertificateOffline(certificate) checks the signature and proof hash with no network at all (the anchor check needs a block header).

CLI

This package also installs a notaryhash CLI:

notaryhash keygen   --algorithm ECDSA-secp256k1 --out key.json
notaryhash notarize contract.pdf --algorithm ECDSA-secp256k1 \
  --key "$(node -e "console.log(require('./key.json').privateKey)")" \
  --url https://notaryhash.com --api-key "$NOTARYHASH_API_KEY"
notaryhash verify cert.json --offline

What this SDK does and does not do

It produces and verifies proofs. It does not run the notarization service (anchoring, fee-paying wallet, key issuance) — that is operated by a NotaryHash provider such as notaryhash.com. Verification is free and open; anchoring goes through a provider's authenticated API.

See the protocol specification (BRC-141).

License

MIT © SmartLedger.Technology

Changes in 2.5.1

  • TSC proofs: a literal sibling equal to the working hash is refused where the working hash is the right operand, at every level. In a valid block two real siblings are never equal, so that node is the odd-count padding of a left node written out in full, and the index is an alias one bit past the real position (CVE-2012-2459's shape). The root is the same, so the transaction was in the block, but the stated position was false. '*' on the right was already refused. Tested against vectors from the bitcoin-sv v1.2.3 reference implementation's own merkle and header code.

Changes in 2.5.0

  • checkAnchorBySpv results carry ok, true exactly when status is 'valid'. status is a string, so if (check.status) is true for 'invalid' and 'indeterminate' too; it had no boolean to read. sealedLink results carry ok likewise (true exactly for 'linked'). status is unchanged in both. Found by the smartledger-bsv session.
  • Documentation only: sealedLink is not an exclusivity check by itself (two certificates spending the same seal both report linked; verifySealedLink rules the second out), and confirmations is the depth proved, which stops at the policy's minimum, not the chain's depth. Both found by the smartledger-bsv session.

Changes in 2.4.2

  • MultiSourceHeaderProvider: the verdict no longer depends on the order of the sources. Every source that answers with a header must give the same one; one that answers differently makes the lookup undecided ("header sources disagree"). The quorum still says how many must answer, so a source that is down can be tolerated. Before, only answers equal to the FIRST source's were counted: the same three sources gave valid, indeterminate or invalid depending on their order. Found by the smart-git session. With the default quorum (all sources) nothing changes. This changes what the quorum argument means: it used to be read as "this many must report the same block" (a majority could outvote a dissenter); it is now "this many must answer", and all answers must agree. A caller who passed a smaller quorum to ride over a source that answers differently now gets "undecided" instead of a header. That only ever refuses more, and the old behaviour depended on source order, so there was no well-defined behaviour to keep.

  • A header's bytes are taken only from a source whose bytes hash to the agreed block hash, so a custom HeaderProvider that reports the right hash with unrelated bytes cannot supply them.

  • Scope: of this release's commits, only the header provider and the anchor check reach the package; the rest (sealed smartsats checkpoints, service settings) is the service.

  • What the header sources hold at a height condemns a certificate only if it is a block. If two sources agreed on 80 bytes that are not one (they do not hash to the hash given, or fail their own proof-of-work target), the verifier called a true certificate invalid; it now answers indeterminate, since that is evidence about the sources. A real other block at the height is still invalid. Found by the decentralize-compute session with the smart-git session's header-source cases. It could not be used to forge anything.

Changes in 2.4.1

Found by an independent verifier's differential fuzz against 2.4.0 (4,541 mutated real certificates); neither verifier accepted an anchor the other refused.

  • A malformed certificate gets a verdict, never an exception. 288 mutations (a null anchor, a non-array Merkle path, non-hex hashes, a non-string algorithm...) made verifyCertificateStandalone reject its promise. Every verifier function now checks the certificate's form first. New export certificateShapeProblem.
  • protocol must be "NotaryHash". It was never looked at; no hash covers it.
  • An spv that is present but not an object is invalid (it was read as "no proof yet").
  • A block hash the header sources do not know, at a height where they hold another block, is invalid, not "try again".
  • verifyDocument can now answer document: 'unchecked' (never ok) for a malformed certificate.

Changes in 2.4.0

  • Certificate version 2.0 (opt-in): sign(..., { version: '2.0' }), notarizeBytes(..., { version: '2.0' }). The signature covers a message that names NotaryHash and binds the algorithm, the public key and createdAt; ML-DSA signs with the context string NotaryHash/2.0. See "Version 2.0" above and SPEC §2a. New exports: signedMessage, SIGNED_MESSAGE_TAG_V2, SIGNING_CONTEXT_V2.
  • Verifiers read both versions. New policy member acceptVersions restricts which.
  • Nothing changes for 1.0: the same requests, the same certificates, the same ids.
  • Documented, not changed: a 1.0 SLH-DSA signature is FIPS 205's internal signing function, not the standard external one. No certificate on notaryhash.com uses SLH-DSA.

Changes in 2.3.3

  • A certificate whose createdAt is before 1970 gets a verdict (proof hash invalid, the signature reported as it is). It used to make the verifiers THROW: the proof bytes hold unsigned seconds, and the encoder refused the negative number after the spelling rule had passed it. canonicalCreatedAt refuses such a time too.
  • A direct certificate must say anchor.type: "direct" and carry no merkle member. One with a merkle member attached used to verify, the member ignored.
  • For implementers: the published tamper set has three more cases, T29 (anchor type relabelled), T30 (merkle removed from a batch certificate) and T31 (a 1969 createdAt). T00-T28 are unchanged; only T00 and T04 are accepted.

Both faults were found by an independent implementation of this format being compared with this one, rule by rule.

Changes in 2.3.2

  • A certificate whose spv.format is not "TSC" is invalid. The proof was always folded as a TSC proof whatever that member said, and no signature covers it. Every certificate the service has issued says TSC.
  • When a quorum of header sources is not met, a source that answered "no such header" is now counted by name beside the ones that failed ("0 of 2 header sources answered with a header and 2 must agree; no such header at: ...; failed: ...").
  • For implementers: the published tamper set (test/vectors/mainnet-tamper-cases.json in the service repository) has two more cases, T27 (network relabelled) and T28 (proof format changed), marked policyDependent. T00-T26 are unchanged.

Changes in 2.3.1

  • A failed header source is named. When MultiSourceHeaderProvider cannot reach its quorum because a source failed, or because sources disagree, the verdict is still indeterminate, but its reason now says which source and why ("1 of 2 header sources answered and 2 must agree; failed: WocHeaderProvider #1: ..."), where it used to say only "unavailable or not agreed across sources". null from the provider now means only that the sources answered and there is no such header.
  • new WocHeaderProvider() without its base URL throws at once with a sentence. It used to construct and then fail inside a request with a TypeError, which read as a source being down. It has no default on purpose: pass https://api.whatsonchain.com/v1/bsv/main.
  • Remember what the multi-source provider is: agreement, not failover. Its quorum defaults to ALL sources; pass a smaller quorum as the second argument if you want availability instead. (From 2.4.2 that tolerates a source being down, not one that answers differently: any dissent is undecided at every quorum.)

Changes in 2.3.0

  • Sealed chains. A record can be anchored as the one successor of another: its transaction spends a seal output of the previous record's transaction, which can be spent once. New: sealBody(body, privateKey, previousId), a seal option on notarizeBytes and notarizeHash, verifySealedLink(previous, certificate, headers, policy?), and sealDigest, sealProblem, sealedLink for implementers. See "Sealed chains" above.
  • computeProofHash and encodeCanonical are exported, so a client can compute a certificate's id before sending the request.
  • verifyCertificateStandalone(cert, headers, null) uses the default policy; it used to throw.
  • A certificate carrying anchor.seal is checked for it by verifyCertificateStandalone and verifyDocument: the signer's authorisation of the link, the fixed transaction layout, and the spend of the seal it names. No verdict changes for certificates without a seal.

Changes in 2.2.0

Anchoring can now be retried without anchoring twice. See "Retrying safely" above.

  • createdAt option on notarizeBytes and notarizeHash. A certificate's id covers its createdAt, and until now both functions stamped the current time on every call, so calling one again after a timeout made a second certificate and a second anchor, with no way to prevent it. Choose createdAt once, store it with your record, and pass it on every attempt: a retry returns the first certificate (alreadyExisted: true).
  • submitNotarize(body, { url, apiKey, batch }) sends an already signed body. Sign once with sign(), store the body, resend it: safe for every algorithm, and the only safe route for ML-DSA and SLH-DSA, whose signatures can differ between calls.
  • sign(algorithm, privateKey, payloadHashHex, createdAt?) takes the time as an optional fourth argument, and when given none now stamps the current whole second in the canonical .000Z form (it used to include milliseconds, which the service dropped).
  • canonicalCreatedAt(when?) is exported: the one accepted form for now or any time.

Nothing is removed and no verdict changes. If you call notarizeBytes once per record and never retry, nothing changes for you.

Changes in 2.1.4

  • A policy that omits network is compared against the default. In 2.1.3 the network check ran only when the policy object carried a network, so a policy built by hand (for example { minDifficulty, requireMainChain, minConfirmations }, the three fields that existed before) switched the check off without saying so, and a relabelled certificate verified. A policy without the property is now compared against "bsv-mainnet"; only an explicit network: undefined skips the comparison. If you spread DEFAULT_HEADER_POLICY, nothing changes for you.

Changes in 2.1.3

  • anchor.network is checked. It was the last certificate field that no signature covers and nothing compared: a mainnet certificate relabelled bsv-testnet verified. The policy gains network (default "bsv-mainnet", the chain of the default header sources); a certificate that names another network is invalid. A verifier using other header sources sets it to match ({ ...DEFAULT_HEADER_POLICY, network: "bsv-testnet" }), and an explicit network: undefined skips the comparison (a policy object that merely omits the property is compared against the default). This compares the label with your setting; it does not establish which chain a header source serves, which is your choice of source.
  • Header lookups retry a rate limit. A 429 or a 502/503/504 from a header source is retried twice (300 ms, then 900 ms, or a Retry-After of up to 2 s) before the source counts as not answering. A 404, a timeout, and a refusal asking for a longer pause are not retried. Public explorers throttle; this stops ordinary throttling from turning a sound certificate into indeterminate.
  • When you build a notarize request yourself, send createdAt and reuse it on every retry (see the service README): the certificate id covers it, so only a request repeated with the same createdAt returns the existing certificate. sign() has always set it.

Changes in 2.1.2

A security release: upgrade. Several certificate fields are covered by no signature or hash, and earlier versions did not check them, so an edited certificate could verify. Each is now checked against the chain or against the one form it may take. No certificate the NotaryHash service has issued is affected (every one was checked against each rule), and there is no API change beyond one added verdict field.

  • blockTime in verdicts, read from the authenticated 80-byte block header. Show this as the time a record was anchored, never the certificate's own anchor.blockTime.
  • A certificate whose anchor.blockTime or anchor.blockHeight is not the block's is invalid. A re-dated certificate used to verify.
  • The record is read from output anchor.vout and no other, and that output must be exactly OP_FALSE OP_RETURN followed by data pushes. The first OP_RETURN found used to be taken.
  • The block Merkle proof is refused when its index does not fit its path (an index with extra high bits used to verify as well as the true one), when a node is not 32 bytes, or when * stands beside a right-hand node. A raw transaction of 64 bytes or fewer is refused.
  • In a batched certificate, merkle.leafIndex is checked: the path must have exactly the length and sides that index has in a tree of the on-chain leaf count. The same path used to verify under any index.
  • createdAt must be the canonical YYYY-MM-DDTHH:MM:SS.000Z form of its second. Any milliseconds used to verify for the same proof.

These rules came from independent verifiers of this format being run against this one, and the batch-mode test vector published in BRC-220 is now part of the test suite.

Changes in 2.1.1

  • require() works on Node 22.12 or later (require(esm)): the exports map gains a default condition, and @smartledger/notaryhash/package.json is exported. The package is still ES modules; older Node versions need import().
  • A certificate whose createdAt does not decode is still not ok, but its signature is now reported as it is (signatureValid true when it verifies). It used to read as a cryptographic failure, although the signature covers payloadHash alone.

Changes in 2.1.0

  • verifyDocument(certificate, bytes, headers, policy?) checks that a certificate is about a particular file: it hashes the bytes and is ok only if the certificate verifies and the hash is its payloadHash (document: 'matched' | 'mismatched' | 'unsupported-hash'). The bytes are required; there is no call without them that says yes.
  • Verdicts gain certificateOk, which says in its name that it covers the certificate alone. verifyCertificateStandalone now reports document: 'unchecked'; its ok keeps the 2.0 meaning (the certificate is valid), so nothing that relied on it changes.
  • CLI: verify --offline --document FILE, and certificate files are read strictly: a duplicated JSON name or nesting deeper than 32 levels is refused rather than read as the last copy.

Changes in 2.0.0

A security release. 1.x verified a certificate's anchor against a block header's reported fields without checking that they belonged to a real block: a made-up block hash, or an anchor in a block orphaned by a reorganisation, could verify as valid. Upgrade.

  • The anchor check now rests on header bytes. A header counts only as its 80 bytes, which must hash to the certificate's block hash; the Merkle root is read from them. The block must meet its own proof-of-work target and a minimum difficulty (DEFAULT_MIN_DIFFICULTY, 5e9), and the header sources must hold it as the main chain's block at that height.
  • Breaking: custom header providers. BlockHeader gains raw (the 80 bytes as hex) and HeaderProvider gains getHeaderAtHeight (and tipHeight). A provider without them makes the anchor indeterminate. The bundled WocHeaderProvider and the new BitailsHeaderProvider implement both; DEFAULT_HEADER_SOURCES uses the two together.
  • Verdicts gain anchorStatus (valid | invalid | indeterminate) and confirmations. indeterminate — a source outage, disagreement, or not yet deep enough — says nothing against the certificate. A third argument, a HeaderPolicy, sets minDifficulty, requireMainChain and minConfirmations.
  • New exports: checkAnchorBySpv, DEFAULT_HEADER_POLICY, headerProviderForUrl, and the header helpers parseHeader, hashOfHeader, headerFromFields, meetsOwnTarget.
  • CLI: verify --offline defaults to WhatsOnChain and Bitails together, and takes --min-confirmations and --min-difficulty.