<!-- markdown mirror of https://mintid.net/en/sdk/holder — generated at build time -->

> 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.

index — 20 documents

1.  [overviewsdk/00](/en/sdk)
2.  [getting startedsdk/01](/en/sdk/getting-started)
3.  [python sdksdk/02](/en/sdk/python)
4.  [service modesdk/03](/en/sdk/service-mode)
5.  [typescript clientsdk/04](/en/sdk/typescript-client)
6.  [mcp serversdk/04a](/en/sdk/mcp-server)
7.  [conformancesdk/05](/en/sdk/conformance)
8.  [reason codessdk/06](/en/sdk/reason-codes)
9.  [substrate readssdk/07](/en/sdk/substrate-reads)
10.  [credentials & holderssdk/08](/en/sdk/credentials-and-holders)
11.  [holder · wasmsdk/holder](/en/sdk/holder)
     *   [what it is](#status)
     *   [install](#install)
     *   [what the host injects](#host)
     *   [the surface](#surface)
     *   [the challenge rule and the refusals](#refusals)
     *   [prove time](#prove-time)
     *   [differences from the python binding](#differences)
     *   [links](#links)
12.  [payment identitysdk/09](/en/sdk/payment-identity-profile)
13.  [becoming a verifiersdk/10](/en/sdk/becoming-a-verifier)
14.  [protocol statesdk/11](/en/sdk/protocol-state)
15.  [verify a releasesdk/18](/en/sdk/verify-a-release)
16.  [browser checksdk/19](/en/sdk/check-a-presentation-in-the-browser)
17.  [record · assurance vocabularyrecord/01](/en/sdk/kyc-trust-model)
18.  [record · packagingrecord/02](/en/sdk/packaging)
19.  [record · multi-languagerecord/03](/en/sdk/multi-language-clients)
20.  [record · service contractrecord/04](/en/sdk/verifier-service-contract)

SDK docs · sdk/holder

# @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](/en/sdk/verify-a-release#check-the-holder-module-against-the-manifest)). Source: [https://gitlab.com/mintid/mintid](https://gitlab.com/mintid/mintid) (`sdk/holder`, the module's source in `identity-proof-core/holder-wasm`).

bashsdk/holder · install

```
npm install @mintid-net/holder
```

## 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

tssdk/holder · 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 `VerifierKeySource`s

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.

tssdk/holder · check before 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

*   `present` takes `expectedOrigin` (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](https://gitlab.com/mintid/mintid/-/blob/main/sdk/holder/README.md) in the public project.

## Links

*   Documentation: [Credentials and holders](/en/sdk/credentials-and-holders), [Verify a release](/en/sdk/verify-a-release)
*   Source: [https://gitlab.com/mintid/mintid](https://gitlab.com/mintid/mintid) (`sdk/holder`, the module in `identity-proof-core/holder-wasm`), where issues and merge requests go; read-only mirror: [https://gitlab.com/mintid/sdk-holder](https://gitlab.com/mintid/sdk-holder)
*   Public testnet: [the public testnet and its KYC sandbox](/en/developers) — 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](https://gitlab.com/mintid/mintid/-/blob/main/SECURITY.md)

[previous← credentials & holders](/en/sdk/credentials-and-holders)[nextpayment identity →](/en/sdk/payment-identity-profile)

---
Source: https://mintid.net/en/sdk/holder · @mintid-net/holder — the holder in WebAssembly · MintID SDK docs
