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.
| Value | Name | Meaning |
|---|---|---|
| 0 | LEVEL_BLACKLISTED | Rejected by the compliance gates on the supported stablecoin transfer paths |
| 1 | LEVEL_UNVERIFIED | Enrolled but not verified |
| 2 | LEVEL_RETAIL_BASIC | Retail, minimal Travel Rule |
| 3 | LEVEL_RETAIL_ENHANCED | Retail, full Travel Rule |
| 4 | LEVEL_INSTITUTIONAL | Licensed VASPs and institutional clients |
| 5 | LEVEL_LICENSED_ISSUER | May 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
| Method | GET path | Parameters | Returns |
|---|---|---|---|
Identity | /hkchain/kyc/v1/identity/{address} | address bech32 | One 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, pagination | identities[] 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_keyis not used. Page by incrementingpagination.offset.pagination.totalis only populated when you ask: sendpagination.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
| Message | Signer field | Authorised when |
|---|---|---|
MsgEnroll | provider | Signer is listed in params.kyc_provider_addresses |
MsgUpgradeLevel | provider | Same allow-list |
MsgBlacklist | authority | Signer is the compliance authority |
MsgWhitelistIssuer | authority | Signer is the governance authority |
MsgUpdateParams | authority | Signer 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
LevelDefinitionrow matches it, so its Travel Rule tier resolves to the empty string, which ranks belowminimal. The account cannot originate any positive-amount transfer on a recognised shape. LevelDistributioniterates the six declared levels, so the record is counted in no row and the distribution silently under-reports.- The provider path cannot repair it.
MsgUpgradeLevelrefuses 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.
MsgWhitelistIssuerassigns level 5 unconditionally — it carries no monotonicity guard, so it normalises a6or higher down to a declared level while leaving the rest of the record intact — andMsgBlacklistsets 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:
| Level | travel_rule_tier |
|---|---|
LEVEL_BLACKLISTED, LEVEL_UNVERIFIED | none |
LEVEL_RETAIL_BASIC | minimal |
LEVEL_RETAIL_ENHANCED, LEVEL_INSTITUTIONAL, LEVEL_LICENSED_ISSUER | full |
Params validation requires the matrix to cover all six levels exactly once, so a governance proposal cannot drop a row.
Typed events
| Event | Emitted by | Carries |
|---|---|---|
EventEnrolled | MsgEnroll | address, level, vasp_identifier, provider |
EventLevelChanged | MsgUpgradeLevel, MsgBlacklist, and MsgWhitelistIssuer when the level moves | address, old_level, new_level, reason, evidence_hash |
EventIssuerWhitelisted | MsgWhitelistIssuer | address, 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.