@midnight-ntwrk/midnight-did-api
v0.7.0
Published
Programmatic API for creating, updating, deactivating, and resolving Midnight DIDs.
Readme
@midnight-ntwrk/midnight-did-api
Programmatic API for creating, updating, deactivating, and resolving Midnight DIDs.
Responsibilities
- Build/connect providers (node, indexer, proof server)
- Submit contract circuits for DID operations
- Map inputs/outputs between app/domain and ledger/runtime
- Generate and persist DID controller private state for create/rotation flows
- Provide integration test topology and helpers
- Return DID resolution data (
didDocument,didDocumentMetadata) for API callers
Use It When
- you need programmatic DID deployment or mutation flows
- you need provider bootstrap for standalone, preprod, or env-driven mainnet
- you are building a higher-level application and do not want to manage raw contract/runtime wiring
Architecture
graph TD
App[Manager / Tests / Integrator]
API[API facade]
Domain[Domain validation]
DidPkg[DID mapper]
Contract[Contract bindings]
Providers[Providers]
Chain[(Midnight chain)]
Indexer[(Indexer)]
App --> API
API --> Domain
API --> DidPkg
API --> Contract
API --> Providers
Providers --> Chain
Providers --> IndexerUpdate Sequence
sequenceDiagram
participant Caller
participant API
participant Contract
participant Indexer
Caller->>API: addService / addVerificationMethod / ...
API->>API: validate + normalize
API->>API: sign controller authorization digest for current contract version
API->>Contract: submit circuit tx + controller signature
Contract-->>API: accepted tx
API->>Indexer: fetch current state
API-->>Caller: updated DID state or DID Resolution ResultState Model
API enforces lifecycle rules around:
- active DID: allows updates
- deactivated DID: mutating operations rejected
- controller authorization: signs a domain-separated digest containing contract id, current version, operation name, and operation arguments before each controller-gated mutation
- controller rotation: generates a new wallet-local secret, persists it in a pending slot, derives the next controller public key locally, and submits the rotation circuit with a current-version controller signature. The pending secret is promoted and cleared only after finalized transaction data returns; ambiguous submission/finality failures retain it for ledger reconciliation
- controller recovery: a dedicated
recoveryAuthorityPublicKeycan authorizerecoverControllerKeyto rotate the active controller key; ordinary controller-gated operations require only the active controller secret, while recovery requires the matching recovery secret
(Exact schema/canonicalization rules live in domain.)
Explicit Verification-Method Removal
removeVerificationMethod and removeSchnorrJubjubVerificationMethod each
submit at most one removal circuit call. They never remove DID verification
relationships implicitly. If the method is still referenced, the API rejects
before signing or submission with VerificationMethodReferencedError:
codeisverification_method_referenced;methodIdis the selected physical ledger identifier;relationslists current references in canonical DID relation order.
Applications choose the cleanup order by calling
removeVerificationMethodRelation once per relationship and then calling the
method-removal helper. These independently finalized transactions are not
atomic. After an ambiguous or partial failure, re-read ledger/DID state, skip
operations already reflected on-chain, and submit only the outstanding steps.
Removing an absent relationship remains an explicit error rather than an
idempotent no-op. The Compact removal circuits independently reject referenced
methods, so API preflight is useful typed feedback but not the authority for
direct callers or concurrent updates.
Finalized Deployment With Incomplete Private-State Setup
@midnight-ntwrk/midnight-js-contracts 4.0.2 performs more work after the
ledger reports deployment success but before deployContract returns:
submitDeployTx synchronously calls setContractAddress(target), then awaits
set(initialPrivateState) and setSigningKey(target, signingKey). A wrapper
around only the returned promise cannot distinguish rejection of those
post-finality writes from a pre-success deployment failure, and reserving the
target after return is too late.
deploy therefore passes a deployment-scoped private-state-provider interceptor
to deployContract. On the dependency's synchronous target-binding call it
canonicalizes and reserves the target under the already-held source lease before
delegating the provider mutation. All other provider methods retain their
original receiver. The source and target reservations remain held until the
entire dependency call settles; there is no elapsed-time lease expiry, because
stale dependency work could otherwise mutate state after a competitor takes the
same target. The captured interceptor is deactivated when that call settles and
cannot spend the released lease later.
If target reservation, active-state persistence, signing-key persistence, or
returned-handle construction then fails, deploy/createDID throws
DIDContractDeploymentFinalizedPrivateStateIncompleteError with only a stable
code/name, canonical contractAddress, and interceptor-controlled
setupStage: target_reservation, private_state_persistence,
signing_key_persistence, or contract_handle_construction. The address is
evidence that the dependency reported ledger success by calling
setContractAddress; the stage identifies the local step that did not complete.
The handle-construction stage begins after both writes complete and includes the
second address bind performed while deployContract builds callTx; that repeat
bind never resets the stage to reservation or persistence. The error deliberately
discards the source error,
deployed-contract handle, transaction/finality data, and all public or private
deployment data. It never uses cause, copies provider fields, or includes an
arbitrary provider name/message. Every rejection before the target is observed,
including primitive rejection values, adversarial error objects, and a genuine
DeployTxFailedError, is preserved unchanged. On success
the dependency has already bound the provider and persisted both values, so the
API performs no second bind or active-state write that could overwrite a
concurrently rotated controller key.
Do not retry deployment blindly. Retain the original private state separately and reconcile provider and ledger state by the error's canonical address. Use the stage to determine which local setup step needs verification; any write that rejected can have an uncertain disposition. Resolve a competing binding owner and join the finalized address using state that matches the current ledger controller rather than overwriting its namespace. Inspect restricted external provider logs separately when diagnostics are needed. Never log the source error through this typed error or attach it to the typed error before propagating it. Future diagnostics at this boundary must be explicitly allowlisted and redacted; they must not retain or traverse the provider error graph.
Controller Secret Recovery Posture
The API package can initialize, persist, rotate, recover, and restore private
state that authorizes DID updates. It cannot recover secrets from ledger state,
but it can submit recoverControllerKey when private state contains, or the
caller explicitly supplies, the recoverySecretKey matching the on-ledger
recoveryAuthorityPublicKey. Explicitly supplied recovery secrets are used for
that recovery call and are not newly persisted into active private state, though
an already stored recovery secret that matches the on-ledger recovery authority
is preserved when the new controller secret is promoted.
If rotation or recovery throws after callTx is invoked without returning
finalized transaction data, the API retains the pending replacement secret
because receipt loss cannot prove that the on-chain operation failed. After
connectivity is restored, obtain a trusted finalized read of the on-ledger
controllerPublicKey before retrying; reconnection or the first available read
alone is not proof of non-finalization. A failure definitely before callTx
invocation instead attempts to remove the newly created candidate while the same
lease is held. If that cleanup rejects, its disposition is unknown; the warning
says the record may remain or may already have been removed and keeps explicit
discard guidance for a retained record. If the replacement public key is the
finalized current key, verify that the retained secret derives that key and
promote it with
recoverPendingControllerPrivateState(providers, { contractAddress, rotationFinalized: true }).
The rotationFinalized option is an explicit caller assertion; these helpers do
not query ledger state or verify finality. getMidnightDIDLedgerState returns the
state supplied by the configured public data provider without adding a finality
or freshness guarantee, so the application must obtain provider-specific
authoritative evidence before making that assertion. If authoritative
reconciliation confirms that the operation did not finalize, discard the
candidate explicitly with
discardPendingControllerPrivateState(providers, { contractAddress, rotationFinalized: false })
before starting another attempt. Until then, retain it even if an available read
still shows the old public key. That explicit assertion also permits removal of
a malformed non-null pending record, avoiding a persistent lockout; an absent
record still throws PendingControllerPrivateStateUnavailableError. Promotion
requires a valid pending controller state, so a missing or malformed record
throws the same stable typed error without writing active state or removing the
record. If promotion succeeds but pending cleanup rejects, the helper warns and
returns the promoted state, but cannot confirm the cleanup disposition: the
pending record may remain or may already have been removed. A later
reconciliation either processes retained state or throws
PendingControllerPrivateStateUnavailableError if deletion committed.
A new attempt made after any non-null candidate is persisted fails with
PendingControllerPrivateStateExistsError. A rotation, recovery, promotion, or
discard racing an in-flight pending-state lifecycle fails with
PendingControllerPrivateStateBusyError; neither error can replace, promote, or
remove that operation's candidate. Rotation, recovery, and reconciliation bind
an untracked provider to the operation's canonical DID address and reject a
known different API-tracked binding with
PrivateStateProviderContractMismatchError before provider or ledger access.
Public rotation and recovery auto-bind or assert the canonical contract address;
public promotion/discard reconciliation requires contractAddress. The supported
baseline assumes that one application process is the writer for a given DID.
Calls through API-bound wrappers in that process share a process-local critical
section from preflight through pending persistence, transaction settlement,
promotion, and cleanup. Reservation acquisition is fail-fast: competing
rotation, recovery, or reconciliation immediately throws
PendingControllerPrivateStateBusyError, even if the owner hangs. The owner
remains busy until underlying work is cancelled and its operation settles, the
operation otherwise terminates or settles, or the process exits. There is
deliberately no lease expiry: stale provider or transaction work could complete
later and overwrite, promote, or remove state owned by another operation. After
cancellation, termination, or process restart, reconcile trusted finalized
ledger state and private state before another mutation.
Provider-object fallback is only for internal/deep unbound use. Direct
setContractAddress or storage mutation and independently unbound wrappers are
outside this guarantee. Multiple application processes intentionally writing the
same DID are outside the supported baseline and must use a distributed lock or
equivalent fencing mechanism; the API does not provide one. joinContract
acquires the same fail-fast owner-token lease before binding and holds both its
source and target reservations through private-state loading and
findDeployedContract; competing source/target controller lifecycle or binding
calls fail busy before mutation. Join failure releases only its owned keys. The
provider exposes no atomic conditional write across processes. Its unbound-state
exception is ignored only when it exactly matches the upstream message;
decorated I/O and other provider failures propagate. See
discussion #440
for the architecture decision and future multi-writer considerations.
Applications should back up controller and recovery private state alongside
their wallet backup material, protect it with custody controls appropriate for
production signing keys, and test restore/recovery flows before relying on a DID.
Private state created before the recovery-authority contract surface contains no
recovery secret; it can still authorize ordinary controller-gated operations when
paired with a compatible contract, but it cannot submit recoverControllerKey
unless the recovery secret is imported or supplied explicitly. If both the active
controller secret and recovery secret are lost, the DID remains publicly
resolvable but cannot be updated, rotated, recovered, or deactivated by this
method version. Organizational operators that need multi-person approval or
social recovery should implement that policy before the API call that signs a
controller or recovery authorization; the contract still receives one signature
for the selected operation.
Resolution Responses
The API package exposes both convenience and DID Core envelope helpers.
resolve returns the ledger-derived DID Document and DID Document metadata, or
null when the contract state is missing. resolveDIDResolutionResult returns
the full DID Core Resolution Result envelope with didResolutionMetadata.
Successful abstract resolve responses must not set
didResolutionMetadata.contentType; that field is reserved for
resolveRepresentation responses where the body is a DID Document byte stream.
The API package also exports resolveRepresentation(providers, didContract,
options). It delegates to the shared MidnightDIDResolver and returns
didDocumentStream as a Uint8Array | null (null on resolution errors),
didDocumentMetadata, and didResolutionMetadata. This is the package boundary intended for
midnight-did-resolver: the downstream service owns HTTP routing and status
codes, while this package owns ledger access, representation selection, and DID
resolution errors.
Build & Test
- Build:
pnpm --filter ./packages/api build - Typecheck examples:
pnpm --filter ./packages/api typecheck:examples - API import discipline:
pnpm run check:api-source-imports - DID package import discipline:
pnpm run check:source-imports - Unit tests:
pnpm --filter ./packages/api test - Integration tests:
pnpm --filter ./packages/api test-api
API TypeScript source and tests use explicit .js or .json extensions for
relative imports, including Vitest mocks. This keeps the package aligned with
the emitted ESM graph and avoids resolver-only test behavior.
The wider check:source-imports guard applies the same rule to all DID-owned
TypeScript package sources outside generated src/managed artifacts.
Runtime Profiles
StandaloneConfigTestnetLocalConfigTestnetRemoteConfigPreprodConfigMainnetConfigProfileConfig
Defaults:
- all profile defaults live in
src/config-profiles.ts PreprodConfigandMainnetConfiguse public indexer v4 endpoints (/api/v4/graphql+/ws).MainnetConfigdefaults to local proof server (http://127.0.0.1:6300) so it can be used with local proving while targeting mainnet indexer/node.- constructing any profile config calls
setNetworkId()throughapplyMidnightNetworkProfile(), so wallet and contract operations see the correct Midnight network before they start.
The docs site publishes the generated endpoint matrix at
https://midnightntwrk.github.io/midnight-did/guide/network-endpoints; it is
generated from src/config-profiles.ts during docs preparation and validation.
You can still override MainnetConfig endpoints explicitly when needed. New
tooling should use ProfileConfig when the profile name is data-driven rather
than hard-coded in a class constructor. Every ProfileConfig instance exposes
the resolved profileName so logs and operator tooling can report the active
profile without inferring it from URLs.
Network Mapping Helpers
Use the typed mapping helpers when converting between Midnight runtime network ids and DID-domain network names:
RuntimeToDomain.NetworkMap: maps runtimeNetworkIdvalues to DID-domainMidnightNetworkvalues.DomainToRuntime.NetworkMap: maps DID-domainMidnightNetworkvalues back to runtimeNetworkIdvalues.RuntimeToDomainNetworkMapandDomainToRuntimeNetworkMap: readonly public type aliases exported from the package barrel for downstream configuration and test helpers.
The older NetworkMapping export is a compatibility alias for
RuntimeToDomain.NetworkMap. New code should prefer the direction-specific
helpers so map intent is visible at the call site.
Provider adapters for proof, indexer, and ZK configuration are loaded lazily by
configureProviders(). Importing the API package barrel for mapping helpers,
types, or examples does not load those runtime adapters.
Release Artifact Metadata
The package embeds ZK artifact locations for its own published version:
import {
MIDNIGHT_DID_API_VERSION,
createMidnightDidZkArtifactLocations,
} from "@midnight-ntwrk/midnight-did-api";
const locations = createMidnightDidZkArtifactLocations(
MIDNIGHT_DID_API_VERSION,
);Use locations.ghcr.reference to pull the matching GHCR OCI artifact in Node or
CI tooling. RC and final release versions also include
locations.githubRelease.archiveUrl; snapshot versions publish workflow
artifacts and GHCR artifacts only, so locations.githubRelease is null.
Node consumers can download, verify, and unpack GitHub Release assets directly:
import {
downloadMidnightDidGithubReleaseZkArtifacts,
MIDNIGHT_DID_API_VERSION,
} from "@midnight-ntwrk/midnight-did-api";
const bundle = await downloadMidnightDidGithubReleaseZkArtifacts({
version: MIDNIGHT_DID_API_VERSION,
outputDir: ".midnight-did-zk",
});
process.env.MIDNIGHT_DID_ZK_CONFIG_PATH = bundle.zkConfigPath;bundle.zkConfigPath is the directory to pass to NodeZkConfigProvider or to
expose from an HTTP server for FetchZkConfigProvider. The helper verifies the
release .sha256 file, checks that the downloaded manifest matches the embedded
archive manifest, and validates every circuit file checksum before returning.
When outputDir, tempDir, or pullDir are omitted, helper-created
directories are retained because bundle.archivePath and bundle.zkConfigPath
point into them. Callers that need deterministic cleanup should pass explicit
directories and remove them after the ZK provider no longer needs the files.
For GHCR OCI artifacts, use pullMidnightDidGhcrZkArtifacts() in an environment
with the oras CLI available.
When the ZK bundle is unpacked outside the installed package, set
MIDNIGHT_DID_ZK_CONFIG_PATH to the directory containing manifest.json,
keys/, and zkir/ before importing @midnight-ntwrk/midnight-did-api.
Without this override, the API uses the managed artifacts bundled with the
installed contract package when available.
setLogger() is optional for embedders. Until it is called, API helpers use a
no-op logger so wallet/provider setup can run in minimal scripts without
preconfiguring logging.
Main Source Files
src/index.tssrc/lib.tspublic compatibility facadesrc/config-profiles.tsnetwork profile catalog and network-id applicationsrc/deploy.tscontract deployment, join, and private-state initializationsrc/providers.tsprovider composition for DID runtime dependenciessrc/private-state-storage.tsprivate-state storage account/password wiringsrc/transaction-intents.tsmanual unshielded intent signing workaroundsrc/wallet-context.tsSDK wallet construction and restore context assemblysrc/wallet-dust.tsdust-registration workflow helpersrc/wallet-provider.tswallet facade to Midnight wallet/provider adaptersrc/wallet-state.tswallet snapshot, sync, balance, and funding wait helperssrc/wallet.tswallet construction and restore facadesrc/wallet-keys.tsseed parsing, HD key derivation, and unshielded address helperssrc/wallet-sdk-config.tsshared wallet SDK configuration builderssrc/lightweight.tsstateless crypto helpers only; wallet wait behavior lives insrc/wallet-state.tssrc/did-subject.tsDID subject and bound fragment normalizationsrc/ledger-mappers.tsDID document domain-to-ledger DTO mapping helperssrc/update.tsDID document update, deactivate, and resolve orchestrationsrc/types.tssrc/test/
Type-safety policy:
- production API source must not use
as anycasts as unknown ascasts must be explicitly allowlisted inpnpm run check:did-surface-discipline- keep SDK type mismatches localized behind narrow adapter helpers and update the surface-discipline guard when an intentional compatibility escape hatch is unavoidable
Legacy deep source files src/contract-lifecycle.ts and src/did-operations.ts
remain as short-lived deprecation shims for external deep source-path imports.
Internal code should use the split modules above, and package consumers should
import from @midnight-ntwrk/midnight-did-api.
Deploy And Update Example
See examples/README.md, examples/deploy-did.ts, and
examples/update-did.ts for package-local deploy/update flows that use only API
package exports. Resolver services, DID manager UI, and reusable secret storage
stay in midnight-did-resolver.
Integration Teardown
packages/api/src/test/commons.ts now uses:
- unique compose project names per run
env.down({ removeVolumes: true })- fallback
docker compose down --volumes --remove-orphans
This reduces container/volume leaks when tests fail mid-run.
