Hkchain

hkchain.kyc.v1

The KYC module is the chain's answer to "who is this address". It holds one record per account: a level, the VASP that vouches for it, and a reference to an off-chain identity file. Every compliance gate that needs an identity reads it from here.

Schema: proto/hkchain/kyc/v1/{query,tx,events,kyc}.proto.

The registry stores no personal data. ivms101_off_chain_ref is a hash pointing at a record the VASP holds; names, documents and addresses never reach the chain.

Levels

Level is an enum with six values, and the numeric order is meaningful — an upgrade must move strictly upward.

ValueNameMeaning
0LEVEL_BLACKLISTEDRejected by the compliance gates on the supported stablecoin transfer paths
1LEVEL_UNVERIFIEDEnrolled but not verified
2LEVEL_RETAIL_BASICRetail, minimal Travel Rule
3LEVEL_RETAIL_ENHANCEDRetail, full Travel Rule
4LEVEL_INSTITUTIONALLicensed VASPs and institutional clients
5LEVEL_LICENSED_ISSUERMay mint and distribute

Blacklisted is zero. That is the enum's default value, so a level field you forget to set does not mean "unknown" — it means blacklisted. This matters most when constructing MsgEnroll: an omitted level is rejected rather than defaulted.

Over JSON the enum travels as its full name ("LEVEL_RETAIL_BASIC"), never as a short slug.

Queries

MethodGET pathParametersReturns
Identity/hkchain/kyc/v1/identity/{address}address bech32One identity; 404 if the address has no record
LevelDistribution/hkchain/kyc/v1/level_distribution—counts[] — one row per level, including zeroes
IdentitiesByLevel/hkchain/kyc/v1/identities/{level}level path segment, paginationidentities[] at that level
Params/hkchain/kyc/v1/params—params

Identity returning 404 rather than an empty record is the useful part: it is the cheapest pre-flight for a transfer, and an unenrolled counterparty is the most common reason a well-formed stablecoin transfer is rejected. Check both sides before building one.

What that check predicts is the gate outcome on the supported transfer shapes — a Cosmos MsgSend, MsgDistribute or MsgBurn, and the extended ERC-20 methods. It is not a statement about every message the chain accepts: a shape outside that set — a bank multi-send, for instance — produces no compliance fact, so no gate reads an identity for it. Transaction lifecycle names the recognised set and the consequences.

curl -s "$REST/hkchain/kyc/v1/identity/hkchain1..."
{
  "identity": {
    "address": "hkchain1...",
    "level": "LEVEL_RETAIL_BASIC",
    "vasp_identifier": "LEI-5493001KJTIIGC8Y1R12",
    "ivms101_off_chain_ref": "sha256:9c2b...",
    "enrolled_at": "2026-07-14T02:11:09Z",
    "last_level_change": "2026-07-14T02:11:09Z",
    "kyc_provider_sig": "..."
  }
}

kyc_provider_sig is base64 over JSON, as protobuf bytes always are. It is recorded for audit — the chain stores it verbatim and does not verify it cryptographically — so treat it as a reference to the provider's attestation, not as on-chain proof of one.

Paging identities by level

The {level} path segment takes the full enum name or its number; grpc-gateway accepts nothing else.

curl -s "$REST/hkchain/kyc/v1/identities/LEVEL_RETAIL_BASIC?pagination.limit=25&pagination.count_total=true"

This query does paginate, by offset and limit:

  • Ordering is fixed — most recent level change first, address ascending as the tiebreak — so offset paging stays coherent between calls.
  • next_key is not used. Page by incrementing pagination.offset.
  • pagination.total is only populated when you ask: send pagination.count_total=true, or the field comes back "0" regardless of how many records exist.

LevelDistribution covers all six levels with explicit zero counts, so a consumer can render the full distribution without knowing the enum. Its response declares a pagination field that the node never populates.

Messages

MessageSigner fieldAuthorised when
MsgEnrollproviderSigner is listed in params.kyc_provider_addresses
MsgUpgradeLevelproviderSame allow-list
MsgBlacklistauthoritySigner is the compliance authority
MsgWhitelistIssuerauthoritySigner is the governance authority
MsgUpdateParamsauthoritySigner is the governance authority

The module default for kyc_provider_addresses is an empty list, which is what a bare hkchaind init genesis carries. That is a default, not a network lifecycle: a running network normally has providers seeded in its genesis file — both the shipped testnet and the portal-enabled localnet do exactly that — so do not assume enrollment is unavailable until a governance proposal has passed. Read the live list instead:

curl -s "$REST/hkchain/kyc/v1/params"

Whatever put an address on the list, an enrollment signed by an address that is not on it fails with unauthorized identity provider.

MsgEnroll — { provider, address, level, vasp_identifier, ivms101_ref, provider_sig }.

Creates a record. The intended range is levels 1–4: enrolling straight to blacklisted or to licensed issuer is refused, because each has its own privileged path. Enrolling an address that already has a record fails — use MsgUpgradeLevel instead.

MsgUpgradeLevel — { provider, address, new_level, evidence_hash }.

Strictly upward, and intended for levels 2–4. The two boundary levels are excluded by name: 5 requires MsgWhitelistIssuer, 0 requires MsgBlacklist. There is no downgrade message — the only downward move is blacklisting.

Both ranges are stated by exclusion, and that leaves a gap above 5. The handlers reject the two named levels (and, on upgrade, anything not strictly greater than the current one); they do not check an upper bound. A protobuf enum field carries any int32, so a level of 6 or higher is accepted and persisted by both paths. Only an allow-listed provider can create such a record, so this is a client-correctness gap rather than an open door — but the resulting record is awkward:

  • No LevelDefinition row matches it, so its Travel Rule tier resolves to the empty string, which ranks below minimal. The account cannot originate any positive-amount transfer on a recognised shape.
  • LevelDistribution iterates the six declared levels, so the record is counted in no row and the distribution silently under-reports.
  • The provider path cannot repair it. MsgUpgradeLevel refuses the two named boundary levels and refuses anything not strictly greater than the current one, so from an out-of-range record the only values it still accepts are higher out-of-range ones. It can push the record further out; it can never bring it back to 2, 3 or 4.
  • Governance can move it, in two directions only. MsgWhitelistIssuer assigns level 5 unconditionally — it carries no monotonicity guard, so it normalises a 6 or higher down to a declared level while leaving the rest of the record intact — and MsgBlacklist sets level 0 from any level. Neither restores a valid non-issuer level: once a record is above the range, 2, 3 and 4 are unreachable, and whitelisting also makes the account an issuer, with everything that implies below.

Send the enum by name, or validate the number against the declared range before signing.

evidence_hash binds the transition to the off-chain due-diligence file. The reason on the resulting event is fixed at edd_complete for this path; the message has no reason field.

MsgBlacklist — { authority, address, reason, evidence_hash }.

Sets level 0. It is idempotent: blacklisting an already-blacklisted address succeeds and emits nothing. If the address has no record at all, one is created at level 0 — so blacklisting works pre-emptively against an address that has never transacted, and the event reports the previous level as LEVEL_UNVERIFIED.

This message is one of three ways an address gets blacklisted. The others live in x/compliance: a sanctions-list update, and an officer closing a flag as blocked. All three converge on the same primitive and emit the same event; they differ in who signs and what evidence is recorded. Blacklist an address walks through the difference.

MsgWhitelistIssuer — { authority, address, issuer_name, license_ref }.

Governance grants level 5. If the address has no record, one is created — so an issuer does not need to be enrolled first. license_ref records the HKMA licence the grant rests on.

A record created this way keeps an empty vasp_identifier and an empty off-chain reference, and no message can fill them in. MsgEnroll is the only message that writes those two fields and it refuses an address that already has a record; MsgUpgradeLevel writes the level and its timestamp and accepts neither field. So the gap is permanent through the public message surface.

The consequence lands on the Travel Rule gate. Level 5 maps to the full tier, and a full-tier payload must carry both originator_vasp and beneficiary_vasp, each of which is then checked against the party's registered VASP identifier. Against an empty registered identifier no payload can satisfy both rules at once, so such an issuer cannot originate a recognised transfer with a counterparty — MsgDistribute and MsgSend are refused. Mint and burn have no counterparty, so they fall through the gate and still work.

Enroll the address first and whitelist it afterwards if the metadata matters: MsgWhitelistIssuer upgrades an existing record in place and leaves its metadata intact. Networks that seed issuers in the genesis file — the shipped testnet among them — write the VASP identifier there and never hit this path.

Two events come out of a successful whitelist: EventIssuerWhitelisted always, and EventLevelChanged only when the level actually changed — re-whitelisting an existing issuer emits the first and not the second.

What the level actually enforces

The level matrix in params has more fields than the chain reads, and the difference is worth knowing before you build against it.

travel_rule_tier is the enforced field. The Travel Rule gate resolves the sender's level to a tier — none, minimal or full — and that tier decides both which payload the sender must claim and which transfer amounts they may originate. A level whose tier is none cannot satisfy any amount, which is why an unverified account cannot send stablecoin on a recognised transfer shape even though no rule says so directly.

As everywhere on this page, that is a statement about the shapes the gates recognise. The tier decides nothing for a message the compliance fact projection does not cover.

The capability booleans are descriptive. can_hold, can_receive, can_transfer_out, can_mint_burn and per_tx_cap on a LevelDefinition are carried in params and are not consulted by any gate or handler. Where a capability is enforced, it is enforced against the level enum directly — the stablecoin module compares the signer's level to LEVEL_LICENSED_ISSUER rather than reading can_mint_burn. Read the matrix as the published intent of each level; do not infer that flipping one of those booleans by governance would change what the chain allows.

The one that does change behaviour is travel_rule_tier, and it is governance-settable. Read it live rather than hard-coding the defaults:

curl -s "$REST/hkchain/kyc/v1/params"

Defaults, for orientation:

Leveltravel_rule_tier
LEVEL_BLACKLISTED, LEVEL_UNVERIFIEDnone
LEVEL_RETAIL_BASICminimal
LEVEL_RETAIL_ENHANCED, LEVEL_INSTITUTIONAL, LEVEL_LICENSED_ISSUERfull

Params validation requires the matrix to cover all six levels exactly once, so a governance proposal cannot drop a row.

Typed events

EventEmitted byCarries
EventEnrolledMsgEnrolladdress, level, vasp_identifier, provider
EventLevelChangedMsgUpgradeLevel, MsgBlacklist, and MsgWhitelistIssuer when the level movesaddress, old_level, new_level, reason, evidence_hash
EventIssuerWhitelistedMsgWhitelistIssueraddress, issuer_name, license_ref

EventLevelChanged is the one to index: it is the single record of every level transition regardless of which message or which module caused it, including the blacklist paths that originate in x/compliance. reason distinguishes them — edd_complete for an upgrade, governance_whitelist for an issuer grant, and whatever the compliance action supplied for a blacklist.

MsgUpdateParams emits nothing; observe a matrix or provider-list change through the Params query.

Errors

Codespace kyc; codes are tabulated in Errors and rejections. The ones that come up in integration are 2 (identity not found — the address was never enrolled), 3 (already exists — use upgrade), 5 (signer is not an authorised provider) and 8 (invalid transition — downward, sideways, or into a level with its own message).

Note that the Identity query answers with a gRPC NotFound / HTTP 404 rather than with module error 2; the module error is what a message handler returns.