HIP-97: Node Identity and the did:hanzo: DID Method. Status Draft. Hanzo's own standard — read this before implementing against it.
Every Hanzo node, profile, agent, and device is currently named with the Shinkai-legacy HanzoName format: a @@-prefixed label with a network suffix baked into the string, e.g. @@alice.sep-hanzo/main/agent/myChatGPTAgent. That string is signed into every HanzoMessage and is the lookup key for on-chain identity registration (Base Sepolia, contract 0x425fb20ba3874e887336aaa7f3fab32d08135ba9). It therefore is not a cosmetic label — it is consensus- and wire-critical.
This HIP proposes one clean replacement scheme: the canonical node identity is a did:hanzo:<name> Decentralized Identifier — the same W3C DID method this HIP specifies for every Hanzo subject. The legacy @@…<suffix> and the network-in-the-string design are retired. The network/chain is expressed as DID query parameters or resolved from the registry, never embedded in the human-readable name. A precise grammar and charset are defined, the signing and on-chain registration impact is specified, and a backward-compatible, three-phase migration and rollout is given so that no in-flight message or already-registered name breaks.
The same method serves every Hanzo subject — humans, agents, services and devices — so this HIP also specifies the DID Document a did:hanzo: identifier resolves to (§8), the resolution contract and lifecycle against the on-chain registry (§9), and the Verifiable Credential format that carries everything which is an attribute of a subject rather than its identity (§10).
The @@<name>.<network>-hanzo format is inherited verbatim from Shinkai. It has four concrete problems, all of which touch code that is live today in hanzo-libs/hanzo-messages/src/schemas/hanzo_name.rs:
.hanzo, .sepolia-hanzo, .arb-sep-hanzo, .sep-hanzo (HanzoName::VALID_ENDINGS). The same node on testnet vs. mainnet has a different identity string, a different signed sender, and a different registry key. Migrating a node from Base Sepolia to the Hanzo L1 mainnet (HIP-24, chain 36963) today means it is literally a different identity. This is the single worst property of the legacy scheme.
@@ is non-standard and non-interoperable. It is not a URI, not a DID, not a DNS name. Nothing outside Hanzo can resolve it. Meanwhile did:hanzo: is the W3C-compliant identity method for every other Hanzo subject — humans, agents, services and devices — with a W3C resolver and an on-chain registry (IHanzoDIDRegistry). Node identity and DID identity are needlessly two different namespaces.
HanzoName already accepts did:hanzo:<network> and did:lux:<network> (see is_did_format, validate_did_name), but only as a network selector (did:hanzo:mainnet, did:hanzo:sepolia, did:hanzo:local:node1) — the node's own name still has no home in that form, and a separate hanzo-libs/hanzo-did crate parses general W3C DIDs with yet another grammar. Three parsers, one concept.
^@@[a-zA-Z0-9\_\.]+(\.hanzo|…)$ permits embedded dots in the node label (@@a.b.c.hanzo), mixes case then lowercases at construction, and silently auto-corrects (correct_node_name prepends @@ and appends .hanzo). Loose, auto-correcting identity parsing is a security smell for something that is signed.
did:hanzo:<name>, not <name>.hanzoTwo candidate clean schemes were considered:
<name>.hanzo — e.g. alice.hanzo, alice.hanzo/main/agent/x.did:hanzo:<name> — e.g. did:hanzo:alice, did:hanzo:alice/main/agent/x.
This HIP selects B, did:hanzo:<name>. Rationale:
| Criterion | <name>.hanzo (A) | did:hanzo:<name> (B) — chosen | |---|---|---| | Aligns with existing standard | No — new third namespace | Yes — the did:hanzo: method itself | | Already parseable by HanzoName | No | Partly — did:hanzo: path exists today | | Network kept out of the name | Yes | Yes (query param / registry, never the label) | | Globally resolvable / W3C interop | No (looks like a DNS host but isn't) | Yes — resolves at did.hanzo.ai, did: URI scheme | | Collides with real DNS / TLD confusion | Yes (.hanzo reads as a gTLD) | No | | One identity across testnet/mainnet | Yes | Yes | | Verifiable Credentials, alsoKnownAs cross-chain (did:lux:, did:ai:) | No native hook | Native (§8, §10) |
A reads more cleanly to a human, but it manufactures a third identity grammar (after @@… and did:…) and looks deceptively like a DNS hostname while resolving through none of DNS. B collapses node identity into the DID method the ecosystem already standardized, makes node identity a first-class W3C DID, and gets cross-chain alsoKnownAs, Verifiable Credentials, and the did.hanzo.ai resolver for free. The human-friendly display form is recovered cheaply (see §6, UX) by rendering did:hanzo:alice as alice / @alice in UIs without changing the wire identity.
Note on
did:hanzo:mainnetlegacy usage: the existing code usesdid:hanzo:<network>where the method-specific-id is a network. Under this HIP the method-specific-id is the node name, and the network moves to a query parameter (?chain=/?network=).did:hanzo:mainnetis reinterpreted during migration as the reserved node namemainneton the default chain; deployments that used it purely as a network selector MUST migrate todid:hanzo:<node>?network=mainnet(see Migration §M0).
The canonical, signed, on-chain identity is the hanzo-id production:
hanzo-id = did-node [ "/" profile [ "/" sub-type "/" sub-name ] ]
did-node = "did:hanzo:" name [ params ]
name = label *( "_" label ) ; node name, the method-specific-id
label = lletter *( letterdigit ) ; MUST start with a letter
profile = label *( "_" label )
sub-type = "agent" / "device"
sub-name = label *( "_" label )
params = "?" param *( "&" param ) ; OPTIONAL; carried, never signed (§3)
param = pkey "=" pval
pkey = "network" / "chain" / "versionId" / "versionTime"
pval = 1*( unreserved )
letterdigit = lowletter / digit
lowletter = %x61-7A ; a-z (lowercase ONLY)
letter = lowletter
digit = %x30-39 ; 0-9
unreserved = lowletter / digit / "-" / "." / ":"
Equivalently, the node-name regex (replacing the legacy ^@@[a-zA-Z0-9\_\.]+(\.hanzo|…)$):
^did:hanzo:[a-z][a-z0-9]*(_[a-z0-9]+)*(\?[a-z]+=[a-z0-9.:-]+(&[a-z]+=[a-z0-9.:-]+)*)?$
[a-z][a-z0-9_]. No uppercase, no auto-lowercasing at parse time — a mixed-case input is rejected, not silently corrected. (The legacy lower-casing in HanzoName::new is removed; canonicalization is now the caller's* job before signing.)
@@a.b.hanzo, the node name MUST NOT contain .. Dots appear only inside params values (e.g. a chain RPC alias).
_ inside a segment, / between segments. Matches the existing 4-part structure (node / profile / agent|device / name) so downstream parsing in inbox_name.rs, tool_router_key.rs, etc. keeps the same split('/') shape.
name and each path segment 1–63 bytes; total hanzo-id ≤ 255 bytes(DNS-label-compatible bound, keeps registry keys bounded).
localhost, mainnet, testnet, sepolia, local, node, agent, device, did MUST NOT be used as a node name except for the reserved-mapping defined in Migration §M0. The default local dev identity becomes did:hanzo:localhost?network=local (replacing @@localhost.sep-hanzo).
did:hanzo:alice
did:hanzo:alice/main
did:hanzo:alice/main/agent/my_chatgpt_agent
did:hanzo:alice/main/device/my_phone
did:hanzo:alice?network=mainnet
did:hanzo:alice?chain=36963
did:hanzo:node1?network=sepolia ; Base Sepolia during migration
did:hanzo:localhost?network=local
@@alice.sep-hanzo ; legacy @@ form — rejected post-migration (accepted read-only in Phase 1)
did:hanzo:Alice ; uppercase
did:hanzo:al!ce ; illegal char
did:hanzo:a.b.c ; dot in node name
did:hanzo:_alice ; must start with a letter
did:hanzo:alice/main/myPhone ; 3-part must be …/agent/… or …/device/…
did:hanzo:alice/main/agent ; agent/device requires a 4th sub-name part
did:hanzo:alice.hanzo ; no network/TLD suffix in the name
The single most important rule: the chain/network is NOT part of the signed, on-chain identity. did:hanzo:alice is the same node whether it is registered on Base Sepolia (migration), the Hanzo L1 mainnet (chain 36963, HIP-24), or running locally.
params (?network=…, ?chain=…) are a resolution hint only. external_metadata) and the value used as the registry key, the params are stripped. The canonical signed form is the bare did:hanzo:<name>[/path] with no query string.
same identity, the same signed sender, and the same registry key.
In hanzo_message_signing.rs, the name is not hashed field-by-field; rather external_metadata.sender / external_metadata.recipient (full HanzoName strings) are part of the message whose hash is signed with ed25519 (sign_outer_layer / sign_inner_layer). Therefore the exact bytes of the identity string are inside the signature preimage. Consequences:
@@alice.sep-hanzo to did:hanzo:alice changes the signed bytes. A verifier expecting one form will fail signatures produced under the other. This is why a flag-day rename is unacceptable and why §7 defines an IdentityProtocolVersion negotiation.
sender/recipient MUST be the params-stripped, NFC, lowercase did:hanzo: form (§3). Because §2 forbids uppercase and auto-correction, canonicalization is now deterministic and side-effect-free — there is exactly one byte sequence per identity, removing the legacy ambiguity where @@Alice… and @@alice… could both be constructed and then lower-cased.
this HIP. The did:hanzo:<name> DID Document MAY additionally carry an MLDSAVerificationKey2025 key (HIP-5, §8) so the same node identity can be verified post-quantum without another rename. PQ message signing is deferred to a follow-up but is unblocked by using the DID form now.
Registration today goes through hanzo-bin/hanzo-node/src/managers/identity_network_manager.rs:
https://sepolia.base.org, contract 0x425fb20ba3874e887336aaa7f3fab32d08135ba9, via HanzoRegistry::get_identity_record(identity).
node_base.ends_with(".sepolia-hanzo") to decide proxy handling.
Changes required:
did:hanzo:<name>. The existing Base Sepolia registry contract is retained during Phase 1–2 (no contract redeploy needed to start), but the key string it stores transitions from @@alice.sepolia-hanzo to did:hanzo:alice. A node registers its DID form; the suffix-sniffing (.sepolia-hanzo) branch is replaced by reading the ?network=/?chain= hint (default sepolia during migration, 36963 after).
IHanzoDIDRegistry. The end-state registry is the did:hanzo: registry on Hanzo L1 (chain 36963), which stores documentHash, controller, version, active per DID (§9). Node registration and DID registration become the same on-chain record: a node is a DID. This removes the separate Base Sepolia identity contract entirely at end-of-life (Phase 3).
resolution of both the new did:hanzo:<name> key and the legacy @@<name>.<suffix> key (deterministic mapping, §M1) so that a node which has re-registered under the DID form is still reachable by peers that only know its legacy name, and vice-versa.
To preserve readability lost by dropping the short @@alice look:
did:hanzo:alice as alice (or @alice in social contexts); did:hanzo:alice/main/agent/researcher renders as alice › researcher.
alsoKnownAs in the DID Document MAY list did:lux:alice, did:ai:alice(§8 cross-chain links). These are display/interop aliases, never the signed identity.
service / profile credential (the HanzoAgentCredential of §10), so the short name is a resolvable attribute, not part of the cryptographic identity.
HanzoName type changes (normative, Rust)In hanzo-libs/hanzo-messages/src/schemas/hanzo_name.rs:
pub network_hint: Option<String> and pub chain_id_hint: Option<u64> to HanzoName, populated from params and excluded from Display, Hash, PartialEq, Serialize, and from the signing preimage (they are resolution-only).
node_name now holds the canonical did:hanzo:<name> (params stripped). The fields profile_name, subidentity_type, subidentity_name are unchanged.
validate_name gains a did:hanzo:<name> branch (the §2 regex) and **stops auto-correcting**: correct_node_name's @@/.hanzo injection is gated behind a legacy-compat flag and is off by default in Phase 2+.
IdentityProtocolVersion { Legacy = 1, Did = 2 } carried in the message envelope / handshake. A peer advertises which forms it can verify. Two Did peers sign/verify the DID form; if either side is Legacy-only, both fall back to the legacy string for that exchange (so signatures still match) until Phase 3.
default_testnet_localhost() returns HanzoName::new("did:hanzo:localhost/main?network=local").
HanzoName::same_identity(a, b) that compares thecanonical DID form after applying the §M1 legacy→DID mapping, so routing/dedup treat a legacy name and its migrated DID as one identity.
A did:hanzo:<name> resolves to a document conforming to W3C DID Core 1.0. The identity is the identifier; the document is the key material and the endpoints it currently points at, and it MAY be rotated without the identity changing.
{
"@context": [
"https://www.w3.org/ns/did/v1",
"https://w3id.org/security/suites/jws-2020/v1",
"https://hanzo.ai/ns/did/v1"
],
"id": "did:hanzo:dev",
"controller": "did:hanzo:hanzo",
"alsoKnownAs": [
"did:lux:dev",
"did:ai:dev"
],
"verificationMethod": [
{
"id": "did:hanzo:dev#key-1",
"type": "EcdsaSecp256k1VerificationKey2019",
"controller": "did:hanzo:dev",
"blockchainAccountId": "eip155:36963:0xAgentAddress"
},
{
"id": "did:hanzo:dev#key-2",
"type": "JsonWebKey2020",
"controller": "did:hanzo:dev",
"publicKeyJwk": {
"kty": "OKP",
"crv": "Ed25519",
"x": "base64url-encoded-public-key"
}
},
{
"id": "did:hanzo:dev#key-pq",
"type": "MLDSAVerificationKey2025",
"controller": "did:hanzo:dev",
"publicKeyMultibase": "z6Mk..."
}
],
"authentication": [
"did:hanzo:dev#key-1",
"did:hanzo:dev#key-2"
],
"assertionMethod": [
"did:hanzo:dev#key-1"
],
"keyAgreement": [
{
"id": "did:hanzo:dev#key-agree-1",
"type": "X25519KeyAgreementKey2020",
"controller": "did:hanzo:dev",
"publicKeyMultibase": "z6LS..."
}
],
"capabilityInvocation": [
"did:hanzo:dev#key-1"
],
"capabilityDelegation": [
"did:hanzo:dev#key-1"
],
"service": [
{
"id": "did:hanzo:dev#rpc",
"type": "AgentRPCService",
"serviceEndpoint": "https://bot.hanzo.ai/rpc/dev"
},
{
"id": "did:hanzo:dev#wallet",
"type": "SafeWallet",
"serviceEndpoint": "safe:eip155:36963:0xSafeAddress"
},
{
"id": "did:hanzo:dev#iam",
"type": "OIDCProvider",
"serviceEndpoint": "https://hanzo.id"
}
]
}
The #key-2 ed25519 method is the node's message-signing key from §4, and #key-pq is the MLDSAVerificationKey2025 that makes the same identity verifiable post-quantum without a second rename.
Each verification relationship answers a different question, and a verifier MUST check the one that matches what it is about to allow:
| Relationship | Purpose | Example | |-------------|---------|---------| | authentication | Prove you are the DID subject | Node login to another node's API | | assertionMethod | Sign Verifiable Credentials | Agent attesting it completed a task | | keyAgreement | Establish encrypted channels | Node-to-node encrypted messaging | | capabilityInvocation | Invoke capabilities on resources | Agent executing a delegated action | | capabilityDelegation | Delegate capabilities to others | Organization granting agent authority |
A single key MAY appear in several relationships. Separate keys for authentication and keyAgreement are RECOMMENDED, so compromising one does not hand over the other.
The registry record of §5 is authoritative: documentHash, controller, version, active, keyed by the params-stripped did:hanzo:<name>. The document itself lives off-chain, so a resolver MUST recompute SHA-256(document) and compare it against the registry documentHash before returning it. A document that does not match its on-chain hash MUST NOT be returned.
Resolution follows the W3C DID Resolution specification, and the versionId and versionTime params of §2 are its query parameters:
| Method | Endpoint | Description | |--------|----------|-------------| | GET | /1.0/identifiers/{did} | Resolve to the current document | | GET | /1.0/identifiers/{did}?versionId={n} | Resolve a specific version | | GET | /1.0/identifiers/{did}?versionTime={iso8601} | Resolve as of a point in time | | POST | /1.0/create | Register a DID | | POST | /1.0/update | Publish a new document version | | POST | /1.0/deactivate | Deactivate a DID |
The response carries the document plus resolution metadata: content type, created and updated timestamps, version, and the deactivated flag. Like every other resolution hint, these params are stripped from the signed and registry-key forms (§3).
The lifecycle is four operations:
constructs the document, computes documentHash, calls IHanzoDIDRegistry.register(did, documentHash), and publishes the document.
hash, and takes the verification method for the relationship it needs.
calls update(did, newDocumentHash). The version counter increments and prior versions stay in chain history, which is what makes versionId and versionTime answerable. Only the controller may update; control moves only via changeController().
deactivate(did); resolution then returns deactivated=true. Credentials issued before deactivation remain historically verifiable, but a verifier MUST NOT accept a credential issued after it.
Deactivation is irreversible. A reactivated DID would leave verifiers unable to tell which validity window a credential belongs to; a subject that needs an identity after deactivation creates a new one.
Attributes that are not identity — capabilities, org membership, evaluation results — are Verifiable Credentials about the DID rather than fields inside it. IAM (HIP-26) is the issuer for identity and membership claims:
{
"@context": [
"https://www.w3.org/ns/credentials/v2",
"https://hanzo.ai/ns/credentials/v1"
],
"type": ["VerifiableCredential", "HanzoAgentCredential"],
"issuer": "did:hanzo:iam",
"issuanceDate": "2026-02-23T00:00:00Z",
"expirationDate": "2027-02-23T00:00:00Z",
"credentialSubject": {
"id": "did:hanzo:dev",
"type": "AIAgent",
"name": "dev",
"organization": "hanzo",
"capabilities": [
"code-generation",
"code-review",
"mcp-tool-use"
],
"safetyEvaluation": {
"framework": "HIP-0210",
"result": "pass",
"evaluatedAt": "2026-02-20T12:00:00Z"
},
"computeTier": "tier-3",
"maxTokenBudget": 1000000
},
"credentialStatus": {
"id": "https://did.hanzo.ai/credentials/status/1",
"type": "StatusList2021Entry",
"statusPurpose": "revocation",
"statusListIndex": "42",
"statusListCredential": "https://did.hanzo.ai/credentials/status-list/1"
},
"proof": {
"type": "EcdsaSecp256k1Signature2019",
"created": "2026-02-23T00:00:00Z",
"verificationMethod": "did:hanzo:iam#key-1",
"proofPurpose": "assertionMethod",
"proofValue": "z58DAdFfa9SkqZMVPxAQpic7ndTn..."
}
}
The proof.verificationMethod MUST appear in the issuer's assertionMethod set; a credential signed by a key the issuer never listed for assertion is invalid however well-formed it looks.
| Credential Type | Issuer | Subject | Purpose | |----------------|--------|---------|---------| | HanzoAgentCredential | IAM | Agent DID | Attest agent identity and capabilities | | SafetyEvaluationCredential | Safety framework (HIP-210) | Agent DID | Attest safety evaluation results | | OrganizationMembershipCredential | IAM | User or agent DID | Attest org membership and role | | ComputeAuthorizationCredential | Cloud (HIP-106) | Agent DID | Authorize compute resource access | | ModelTrainingCredential | Training pipeline | Model DID | Attest training data provenance | | BiasAuditCredential | Bias framework (HIP-220) | Model DID | Attest bias evaluation results |
Revocation is StatusList2021: a bitstring where flipping bit n revokes the credential holding statusListIndex n. A verifier downloads the whole list rather than asking about one credential, so checking revocation does not tell the issuer which credential is being verified.
This is a signed, on-chain identifier; the migration is explicitly versioned and phased. No flag day.
Existing did:hanzo:<network> selectors (did:hanzo:mainnet, did:hanzo:sepolia, did:hanzo:local:node1) are reinterpreted:
did:hanzo:mainnet → reserved; if it was a network selector it becomes did:hanzo:<node>?network=mainnet. Operators MUST update config to name the node.
did:hanzo:local:node1 → did:hanzo:node1?network=local.A pure function maps any legacy name to its canonical DID form and back:
legacy: @@<name>.<suffix>[/<profile>[/<type>/<sub>]]
canon: did:hanzo:<name>[/<profile>[/<type>/<sub>]] with params: network=<from suffix>
suffix → network/chain:
.hanzo → mainnet (chain 36963)
.sep-hanzo → sepolia (Base Sepolia) ; current default
.sepolia-hanzo → sepolia (Base Sepolia)
.arb-sep-hanzo → arb-sepolia
<name> is copied verbatim iff it already satisfies §2 (lowercase, no dots). Legacy names containing dots or uppercase are flagged for operator-assisted rename (they are rare; the dominant case @@alice.sep-hanzo maps cleanly to did:hanzo:alice?network=sepolia). This mapping is the basis for dual-read (§5.3) and same_identity (§7).
hanzod identity migrate — reads the node's current @@… identity, derives the did:hanzo: form via §M1, re-registers it on-chain, and writes both keys until Phase 3.
did:hanzo: ↔ @@ lookup table is published by the resolver at did.hanzo.ai for the duration of Phases 1–2.
hanzo-libs/hanzo-messages/tests/hanzo_name_tests.rs so both forms hash to the expected signature inputs.
HanzoName changes (DID branch, hints, same_identity, IdentityProtocolVersion) behind a feature flag; ship in a normal node release (Phase 1 behavior default).
HanzoName (identity manager,inbox/tool-router keys, payments managers, tests) to use canonicalized DID construction helpers; behavior stays Legacy-signed.
hanzod identity migrate + did.hanzo.ai lookuptable.
IHanzoDIDRegistry (chain 36963).
auto-correction, each identity has exactly one canonical byte sequence; this closes the legacy ambiguity where multiple inputs canonicalized to the same lower-cased name, which is dangerous for a signed field.
auto-completed into a valid-looking one (legacy correct_node_name could turn a typo into a different, valid node).
?network/?chain from the signed andregistry forms means a signature or registration cannot be replayed as if it were for a different chain by swapping a suffix — the identity is chain-agnostic by construction.
Legacy fallback are the only periods where two strings denote one node; same_identity (§M1 mapping) MUST be used for all auth/routing decisions to prevent a peer from being treated as two identities (or two peers as one).
MLDSAVerificationKey2025 key(HIP-5) to the same identity, so the eventual move to PQ message signing needs no further rename.
ML-DSA-65, MLDSAVerificationKey2025)36963)hanzo-libs/hanzo-messages/src/schemas/hanzo_name.rs (current HanzoName)hanzo-bin/hanzo-node/src/managers/identity_network_manager.rs (Base Sepolia registration)hanzo-libs/hanzo-did/ (existing W3C DID parser to converge on)