@mintid-net/holder — the holder in WebAssembly
The holder library compiled to WebAssembly, with a TypeScript surface for the browser and Node: it issues, refreshes, presents and self-revokes; the host injects the transport and keeps the custody record.
What it is
The MintID holder library (mintid-holder, the same Rust code the Python binding proof_core.holder and the mintid-holder process run) compiled to WebAssembly, with a TypeScript surface for the browser and Node. It issues an identity credential, keeps it fresh, presents it, answers an issuer's mint challenge and builds the credential's self-revocation.
Status. Version 0.1.1. The proof core's independent audit is a scheduled milestone, not a gate. On the public testnet, nothing issued carries weight.
It is the holder, not a wallet: it has no storage, no network and no user interface. The page or the service that embeds it injects its HTTP, keeps the custody record (encrypted under a key of its own) and draws the screens.
Install
On npm from the first public release that lists @mintid-net/holder in its signed release manifest (release/<release>/release-manifest.json of the main repository). Node 20 or later, or a browser or bundler with WebAssembly and top-level await; ES modules with type declarations; no runtime dependencies. The package carries the WebAssembly module (pkg/mintid_holder_wasm_bg.wasm and its glue) built by the release pipeline on the reference host, the same bytes as identity-proof-core/holder-wasm/REFERENCE.md5 names; the release manifest lists the package's npm integrity and the module's sha256 and md5 (verify a release). Source: https://gitlab.com/mintid/mintid (sdk/holder, the module's source in identity-proof-core/holder-wasm).
What the host injects, what the module never does
| The host gives | Why |
|---|---|
transport: (method, url, body?, headers?) => Promise<{status, json}> | every network step — the issuer's metadata, token, credential and witness; the relay; the node read — goes through it. The module makes no network call of its own. |
verifiers: VerifierKeySource | where present reads the verifier's registered request keys. Without one, every challenge is refused (challenge_key_unavailable). |
| the custody record | storageBytes() returns it; the host encrypts and stores it; Holder.fromStorage(bytes) restores it. The module never persists anything. |
now (optional) | the clock of the challenge deadline; Date.now() by default. |
| the module (optional) | @mintid-net/holder loads pkg/mintid_holder_wasm_bg.wasm at import (top-level await: fetched beside the glue in a browser or a bundler, read from disk under Node). @mintid-net/holder/core leaves it to the host: await init(source) with bytes, a Response, a URL or a compiled WebAssembly.Module. |
The surface
import { Holder, mintToken, submitSelfRevocation, staticVerifierKeys, type Transport } from "@mintid-net/holder";
const holder = await Holder.issue(offer, { transport, verifiers }); // identity credential, the OpenID4VCI holder half
const bytes = holder.storageBytes(); // Uint8Array: the custody record (contains the link secret)
const again = Holder.fromStorage(bytes, { transport, verifiers });
await holder.refreshWitness(); // adopts the issuer's current definition + witness; self-checked
const env = await holder.present(challenge, { expectedOrigin }); // {proof_hex, issuer_id, status_root, claimed_predicates}
const mintp = holder.presentForMint(gradeMin, mintChallenge, { ageOver }); // the one presentation that discloses status_ref, to the issuer only
const mat = await mintToken(offer, holder, { transport }); // MintMaterial, JSON-ready
const msg = await holder.selfRevoke(chainId); // {message (five fields), nullifier, ciphertext}; the holder refuses to present from now on
const rcpt = await submitSelfRevocation(relayUrl, msg, { transport, chain: rpcUrl }); // "submitted" until the chain shows it
holder.info(); // issuer, grade, validity, witness state; never the secret| Call | What it does | Notes |
|---|---|---|
Holder.issue(offer, {transport, verifiers?}) | metadata → token (c_nonce) → a blind request built in the module → the credential, unblinded in the module | the link secret is born inside the module; an offer that is not an identity offer is offer_invalid |
Holder.fromStorage(bytes, opts?) / storageBytes() | the custody record | the same bytes as every other binding's (below) |
refreshWitness() | GET the witness endpoint, adopt the material once a finalized root anchors it | returns {status, usable, refreshed, root_height, status_root}; a credential the issuer no longer serves is credential_revoked; material that does not verify is witness_refused and the holder is unchanged; caches the root's self-revocation material (selfRevokeContext) |
present(challenge, {expectedOrigin, verifiers?, now?}) | answers a verifier's {request, signature_hex} | the challenge rule below, then one 10-second, fully bound proof; the proof is verified inside the module before it is returned |
presentForMint(gradeMin, mintChallenge, {ageOver?}) | answers the issuer's mint challenge | an explicit call: no verifier policy can elicit these bytes; ageOver defaults to the challenge's age_over |
mintToken(offer, principal, {transport, attempts?}) | the split mint's token step | follows the issuer's re-challenges (at most 3 attempts; a stale principal witness is refreshed first) and returns MintMaterial, which carries nothing of the principal |
selfRevoke(chainId, {anchoring?, statusRoot?}) | the SRP-1 self-revocation against one ring root | uses the material cached at the last refresh (refreshes once when there is none); checked in the module with the chain's own rules before it is returned; the refusal to present survives storageBytes() (record version 3) |
submitSelfRevocation(relayUrl, msg, {transport, chain?}) | the five fields to the relay, then the holder's own read of a node | the read is the issuer-scoped RecordedNullifiers (the node never learns which nullifier), unproven; pending / folded only when the node shows it, else submitted with the reason |
staticVerifierKeys(records), provenVerifierKeys(reader) | the VerifierKeySources | see below |
estimateProveMs({runs?, only?}), PRESENTATION_WINDOW_MS | a dry present and a dry presentForMint on a built-in test credential, timed on this device | no network, nothing kept; {present, presentForMint} in ms; see "Prove time" |
checkPresentation, checkMintPresentation, challengeBinding, verifyChallengeSignature | the same module's verification over its own output | a self-check, never the verifier's decision |
The storage-bytes rule. storageBytes() is the custody encoding of the holder library: byte for byte what the Python Holder.storage_bytes(), the Go binding and the mintid-holder process write, and any of them loads what this package writes (version 1 for a live identity credential, version 3 once it self-revoked). The tests replay records written by the Python binding and, live, have Python load records this package wrote. It contains the link secret: encrypt it before it leaves the page, under a key the host derives (a passkey's PRF output, for example); a ciphertext the service cannot open is not custody.
Verifier keys. staticVerifierKeys(records) pins records the host trusts (records it proved itself, or its trusted-verifier list). provenVerifierKeys(reader) is a fail-closed adapter over a reader the host provides — getVerifier(verifierIdHex) resolving only to a record proven against a header it trusts (as verifier-core's ChainClient does in Python). This package carries no light client, so in a browser the proven read is the host's to supply; until it has one, staticVerifierKeys with records proven elsewhere is the alternative. Without either, the holder presents nothing.
The challenge rule and the refusals
present refuses, building nothing and sending nothing, in this order: the self-revoked holder (credential_self_revoked); the request-signature rule — challenge_unsigned, challenge_key_unavailable, challenge_verifier_not_active, challenge_key_unknown, challenge_key_not_active, challenge_signature_invalid (the six of challenge-signature-v1, in their order); the exact origin (challenge_origin_mismatch: the request's audience is not expectedOrigin); the profile (challenge_profile_mismatch); the deadline (challenge_expired: now ≥ expires_at); one answer per challenge (challenge_replayed, remembered in memory for the life of the Holder). Then the proof: a policy the credential cannot satisfy is policy_unsatisfiable:<output key>.
Every refusal is a HolderError {code, step} (step: the operation or the network step — metadata, token, credential, witness, present, present_for_mint, verifier_keys, self_revoke, relay, chain, storage, module). Its message is "<step>: <code>" and never carries a value. The closed vocabulary (HOLDER_ERROR_CODES):
| Group | Codes |
|---|---|
| challenge | the ten above |
| credential | policy_unsatisfiable:<output key>, credential_revoked, credential_self_revoked, no_status_root, no_self_revoke_context, wrong_profile |
| the operation refused (value-free) | issuance_refused, issuance_completed, witness_refused, proof_refused, proof_invalid (the checks), self_revoke_refused, storage_invalid, invalid_input, unsupported |
| network | offer_invalid, metadata_invalid, peer_refused (with status and the peer's own reason code in peerError, never its text), transport_failed |
| the split mint's consumer side | material_expired, material_consumed — named here for one vocabulary; this package produces MintMaterial and never consumes it |
| the module | module_not_initialised, module_trapped, internal_error |
module_trapped: WebAssembly aborts on a panic, so the proof core's malformed-input guards, which catch an unwind natively, cannot catch it here; a hostile byte string that would be refused natively traps the instance instead. The package then refuses every later call; the host reloads the page (or the worker) and restores the holder from its custody record.
Prove time
A presentation has 10 seconds (SPEC-1): the verifier's challenge expires 10 seconds after it was issued, and in that window the page fetches the challenge, the module builds the proof and the page submits it. The proof's share depends on the device's CPU.
How MintID measures it. npm run bench:browser -- 5 1,4,6 (tests/browser/bench.mjs): headless Chrome; for each CPU factor a fresh tab, slowed through the DevTools protocol (Emulation.setCPUThrottlingRate) before the page loads; holder-v1's A3 credential with the launch identity policy (grade ≥ A3, age over 18, active, unexpired), a fresh holder each run; five runs each, the figure is the median of the four warm runs (the first is the engine's cold call), timed around the module call, no network. The CPU factor is the reference for slower devices: 1× is a recent high-end laptop, 4× is MintID's proxy for a mid-range laptop, 6× for a slower device. It is a proxy, not a device: no physical mid-range device has been measured yet, and a page should rely on estimateProveMs() (below), which measures the device it runs on. npm run bench (tests/bench.mjs) times the same calls in Node.
Measured on an Apple M3 Max, headless Chrome 155, 18 October 2026:
| CPU | present 0.1.0 | present 0.1.1 | presentForMint 0.1.0 | presentForMint 0.1.1 |
|---|---|---|---|---|
| 1× | 2.79 s | 0.64 s | 1.85 s | 0.41 s |
| 4× (mid-range proxy) | 10.90 s | 2.54 s | 7.21 s | 1.63 s |
| 6× | 16.47 s | 3.77 s | 10.75 s | 2.47 s |
| Node 22.9, 1× | 3.11 s | 0.57 s | 2.03 s | 0.36 s |
(0.1.0: its sources rebuilt on the same machine; a measurement of the published 0.1.0 module on the same hardware agrees within 5 %.)
For comparison, the same machine proves natively (the Python binding) in 0.14 s and 0.09 s; selfRevoke takes about 50 ms in WebAssembly. Where the time went (0.1.0, profiled): the range proofs (bulletproofs) were about 85 % of a presentation, most of it their inner-product argument; the module's own check of the proof before it returns it about 9 %; the BBS signature proof and the VB20 non-revocation proof together about 4 %; the custody decoding under 1 %. Underneath, 90 % of the time was one function: blst's portable Montgomery multiplication (BLS12-381, 32-bit limbs), called with the limb count as an argument. 0.1.1 changes two things, and neither changes a proof — the same proof bytes from the same randomness:
- The build: the multiplication is inlined with the limb count known, the Rust is built with link-time optimisation, then
wasm-opt. The same code and the same arithmetic, about 35 % less time. - The range proofs' arithmetic: each round of the inner-product argument halves its generators with two-point products that the range-proof library computed with a general multi-scalar method costing about six times two plain scalar multiplications; the proof core's copy of the library (
identity-proof-core/vendor/bulletproofs-bls) computes them as plain multiplications — the same points — and the range-proof generators are built once instead of for every proof and every check. About 2.8× less time again; natively too (0.37 s → 0.14 s).
At 4× an identity presentation now takes about 2.5 seconds.
Check before asking. estimateProveMs() builds one dry present and one dry presentForMint on a built-in test credential (holder-v1's A3, a test issuer no verifier accepts; no network, nothing kept) and returns their times in milliseconds. In the measurements above it was within 7 % of the warm median at every factor (mostly on the slow side). It costs what it measures, so run it once, while the page is idle — at load, in the Web Worker that will present — and keep the result for the session. Do not ask a person to approve a presentation the device cannot deliver in time: when the estimate plus the page's network margin exceeds the window, say so instead of asking.
import { estimateProveMs, PRESENTATION_WINDOW_MS } from "@mintid-net/holder";
// The page's own margin: the challenge fetch, the submission and clock skew.
// On the public testnet the part of challenge-to-decision that was not the
// proof took 2.0 s and 3.3 s (the two runs below); 3 s is a reasonable start.
const NETWORK_MARGIN_MS = 3_000;
const estimate = await estimateProveMs(); // { present, presentForMint } in ms
const canPresent = estimate.present + NETWORK_MARGIN_MS <= PRESENTATION_WINDOW_MS;
// canPresent false → do not offer the approval on this device; say why.
// The issuer's mint challenge has the same 10-second window: compare presentForMint the same way.With 0.1.1 and a 3-second margin, every row above fits the window: the 4× row with about 4.5 seconds to spare (2.5 s + 3 s), the 6× row with about 3 (3.8 s + 3 s). A device slower than that still exists — check with the estimate rather than assume. Design the flow to spend nothing inside the window it does not need: approve first, then fetch the challenge, then present at once; run the module in a Web Worker so the page stays responsive. On the public testnet (17 October 2026, two runs from Node, module 0.1.0): issuance 1.5 s, present 4.3 s and 3.2 s, challenge to the verifier's decision 6.3 s and 6.5 s, accepted both times.
The self-revocation prover runs in WebAssembly (about 0.1 s): the testnet journey built it, the relay submitted it and the holder's own node read confirmed it.
Differences from the Python binding
presenttakesexpectedOrigin(required) and checks the origin, the profile, the deadline and replay after the signature; the Python binding checks the signature only.- No
insecure_skip_challenge_signature: a browser holder never answers an unsigned challenge. info()returns the library's JSON shape (snake_case; absent keys for an identity credential), not a dataclass.- Not in this version: the agent-class holder in the browser (
adopt_scope_witness,refresh_scope,present_for_relay) and several credentials in one holder. A record of an agent credential loads, but this package offers no agent operations. - Freeing memory:
holder.free()releases the module's copy; WebAssembly memory is not zeroised on release.
How the module is built and reproduced, and what its tests cover, is in the package README in the public project.
Links
- Documentation: Credentials and holders, Verify a release
- Source: https://gitlab.com/mintid/mintid (
sdk/holder, the module inidentity-proof-core/holder-wasm), where issues and merge requests go; read-only mirror: https://gitlab.com/mintid/sdk-holder - Public testnet: the public testnet and its KYC sandbox — its endpoints are carried by the package's network registry and given on request
- Security: report a vulnerability privately to security@mintid.net, never in a public issue; policy: https://gitlab.com/mintid/mintid/-/blob/main/SECURITY.md