hkchain.compliance.v1
The compliance module owns the chain's cross-cutting gate chain, the append-only audit log, the flag queue reviewers work from, the sanctions list, delegated sub-keys and their spending policies, and the gate-by-gate trace that explains any single transaction.
Schema: proto/hkchain/compliance/v1/{query,tx,events,compliance}.proto.
It is the only module whose rules apply to other modules' transactions. That is not the same as being the only module that rejects anything: every module still refuses its own invalid domain operations from inside its handler — a mint signed by a non-issuer, an upgrade into a level that has its own message, an attestation co-signed by an unregistered auditor. Those are authority and state checks in one module's own message flow. What this module adds is a chain of gates that runs across transactions it does not own.
The gates themselves are not an API surface, and two of their properties decide where you will see them act:
- They run at a different point on each path. A Cosmos transaction meets them before execution — at admission, and again when the block executes. An EVM call meets them during execution, inside the extended ERC-20 precompile, so a rejection there is a revert in a mined transaction.
- They inspect a fixed set of message shapes, not everything the chain accepts.
Transaction lifecycle covers both paths and names the recognised set exactly. What this page covers is everything you can read and everything you can sign.
Queries
| Method | Path | Parameters | Returns |
|---|---|---|---|
AuditLog | GET /hkchain/compliance/v1/audit_log | subject, tx_type, tx_hash, from, to | entries[] |
Flags | GET /hkchain/compliance/v1/flags | vasp, status, tx_hash, from, to | flags[] |
Report | GET /hkchain/compliance/v1/report | period_start, period_end, format | sections[] |
TravelRulePayload | GET /hkchain/compliance/v1/travel_rule/{tx_hash} | tx_hash | payload; 404 if none anchored |
SubRoleBindings | GET /hkchain/compliance/v1/sub_roles/{parent} | parent, role | bindings[] |
SubRoleParent | GET /hkchain/compliance/v1/sub_role_parent/{sub_addr} | sub_addr | parent, binding; 404 no sub-role binding for <addr> if unbound |
AgentPolicy | GET /hkchain/compliance/v1/agent_policy/{parent}/{sub_addr} | both addresses | policy, window; 404 if no policy |
SanctionsSet | GET /hkchain/compliance/v1/sanctions | — | set |
TxTrace | GET /hkchain/compliance/v1/tx_trace/{tx_hash} | tx_hash | trace, found |
SimulateTrace | POST /hkchain/compliance/v1/simulate_trace | body { "tx_bytes": "<base64>" } | trace |
Params | GET /hkchain/compliance/v1/params | — | params |
AuditLog, Flags and SubRoleBindings declare pagination and do not
apply it. All three return the complete filtered set and leave the response's
pagination tail empty. Narrow with the filters — subject, tx_hash, the
time window — rather than by paging.
Time bounds are RFC 3339 timestamps: from is inclusive, to is exclusive, and
an omitted bound is unbounded on that side.
The audit log
The durable record of every compliance-relevant thing that happened. Unlike the trace, it is chain state — it survives node restarts and is identical on every node.
curl -s "$REST/hkchain/compliance/v1/audit_log?subject=hkchain1...&from=2026-08-01T00:00:00Z"
{
"entries": [
{
"sequence": "412",
"block_height": "88301",
"block_time": "2026-08-09T10:15:22Z",
"tx_type": "threshold_flag",
"subject": "hkchain1...",
"counterparty": "hkchain1...",
"amount": { "denom": "hkd.hsbc", "amount": "900000" },
"tx_hash": "9F2C...",
"metadata": { "flag_id": "17", "decorator": "threshold" }
}
]
}
tx_type is a short code, and filtering on it requires an exact match. The
values the chain writes today:
tx_type | Written when |
|---|---|
transfer | A stablecoin transfer is screened |
threshold_flag | The threshold gate flags a transfer |
velocity_flag | The velocity gate flags a transfer |
sanctions_update | The sanctions list is updated |
rule_update | Decorator rules are retuned |
flag_review, flag_escalate, flag_close | A flag moves through review |
sub_role_bound, sub_role_revoked, agent_policy_update | Delegation changes |
metadata is a free-form string map — flag_id, decorator, reviewer and
similar live there rather than in schema fields. Read it defensively: keys are
added without a schema change.
tx_hash matching is case-insensitive, so the uppercase form Cosmos REST hands
you works as-is.
Flags
A flag is a transfer an advisory gate decided a human should look at. It does not mean the transfer failed — advisory gates never block.
curl -s "$REST/hkchain/compliance/v1/flags?status=FLAG_STATUS_EMITTED"
The status machine:
| Status | Meaning | Moves on |
|---|---|---|
FLAG_STATUS_EMITTED | Raised by a gate, untouched | MsgReviewFlag, MsgEscalateFlag |
FLAG_STATUS_PENDING_OFFICER | A reviewer escalated it | MsgCloseFlag |
FLAG_STATUS_RESOLVED_APPROVED | Cleared | terminal |
FLAG_STATUS_RESOLVED_BLOCKED | Blocked; the subject was blacklisted | terminal |
Omit status (or send FLAG_STATUS_UNSPECIFIED) to match all.
decorator names which gate raised it — threshold or velocity today.
resolution stays unset until a decision is recorded.
vasp is populated by the threshold gate only. A threshold flag resolves
the subject's identity — through the parent, when the signer is a delegated key
— and stamps that VASP identifier on the flag. The velocity gate constructs its
flag without the field, so every velocity flag carries an empty vasp.
That matters for the filter: Flags?vasp=X keeps only flags whose vasp is
exactly X, so a VASP-scoped reviewer queue silently omits every velocity flag.
Query the whole queue and group client-side, or run the VASP-filtered query
alongside Flags?status=FLAG_STATUS_EMITTED and reconcile by subject.
The gate trace
TxTrace returns the ordered per-gate result for one transaction: name, whether
the gate blocks or only advises, PASS / FAIL / FLAGGED, a reason written
for a compliance reader, and the gas that gate consumed. accepted is true only
if every blocking gate passed.
curl -s "$REST/hkchain/compliance/v1/tx_trace/9F2C..."
The hash is matched case-insensitively, so either the lowercase or the uppercase form works.
The trace is not chain state. It lives in a per-node ring buffer of 256
entries with a 60-minute lifetime, so three things follow: a trace can be
evicted, a node that did not process the transaction has no trace for it, and
found: false is a normal answer rather than an error. The durable record of
the same events is the audit log and the flag queue.
SimulateTrace runs the same gates over transaction bytes you supply, on a
throwaway copy of state:
curl -s -X POST "$REST/hkchain/compliance/v1/simulate_trace" \
-H 'Content-Type: application/json' \
-d '{"tx_bytes":"<base64-encoded signed tx>"}'
It decodes a Cosmos transaction, and it deliberately does not check fees,
signatures or sequence — it answers "would the compliance gates let this
through", not "is this transaction valid". A node that has not wired the
simulation hook answers FailedPrecondition; undecodable bytes answer
InvalidArgument.
Both responses carry the same tx_hash for the same bytes, so a simulated trace
and the later live one join on it.
Delegation lookups
Three queries cover delegated keys from both directions:
SubRoleBindingslists what a parent has delegated. Filter byrole, or omit it for all. Revoked bindings are included — checkrevoked_at, which is the zero timestamp while active.SubRoleParentresolves the other way: given a sub-key, who owns it. A sub-key belongs to at most one parent, so this is a single answer. An unbound address gets404with the messageno sub-role binding for <addr>— match that message, not the status: gRPC renders every NotFound as code 5, so a dead route or a proxy 404 that echoes the requested path is indistinguishable from this answer by status and address alone. Worked examples shows a classifier that fails closed.AgentPolicyreturns an agent's spending envelope together with its current spend window.
curl -s "$REST/hkchain/compliance/v1/agent_policy/hkchain1PARENT.../hkchain1AGENT..."
{
"policy": {
"parent": "hkchain1...", "sub_addr": "hkchain1...",
"daily_cap": { "denom": "hkd.hsbc", "amount": "500000" },
"per_tx_cap": { "denom": "hkd.hsbc", "amount": "50000" },
"revoked": false
},
"window": {
"parent": "hkchain1...", "sub_addr": "hkchain1...",
"spent_last_24h": { "denom": "hkd.hsbc", "amount": "120000" },
"window_start": "2026-08-09T08:00:00Z"
}
}
Compute remaining capacity yourself, and check window_start first. The
period is a fixed 24 hours anchored at the agent's first spend, and it resets
when the next spend arrives after it has elapsed — the query returns the stored
row without applying that reset. If window_start is more than 24 hours old,
spent_last_24h is the previous period's total and the true available
capacity is the full daily_cap.
Reports
Report assembles a period summary from the audit log and the flag queue. It
returns four sections, always in this order and always present even when empty:
| Section | Contents |
|---|---|
par_value_daily | Always empty. Circulation and reserve figures are served by hkchain.reserve.v1 — read the daily statement there. |
sanctions_hits | Audit entries whose tx_type is exactly sanctions. No code writes that value today (list updates are written as sanctions_update), so this section is empty in practice. |
threshold_flags | Every flag still in FLAG_STATUS_EMITTED in the period — including velocity flags, despite the section name |
review_closures | Every flag in the period that has moved out of EMITTED |
The format parameter is accepted and not read: the same four sections come
back whatever you pass. Treat this query as a period roll-up of what the chain
already exposes, and build a regulatory document from the underlying queries
rather than from this shape.
Each row is a cells string map, deliberately schema-free so sections can gain
columns without a proto change.
Messages
| Message | Signer field | Authorised when |
|---|---|---|
MsgUpdateSanctionsList | authority | Signer is the compliance authority from params |
MsgUpdateRule | authority | Signer is the compliance authority |
MsgReviewFlag | reviewer | Signer has an active SUB_ROLE_REVIEWER binding |
MsgEscalateFlag | reviewer | Signer has an active SUB_ROLE_REVIEWER binding |
MsgCloseFlag | officer | Signer has an active SUB_ROLE_OFFICER binding |
MsgBindSubRole | parent | Signer's own KYC level satisfies the role being delegated |
MsgRevokeSubRole | parent | The binding exists under this parent and is not already revoked |
MsgBindAgentPolicy | parent | The binding exists, is an agent binding, and is active |
MsgUpdateParams | authority | Signer is the governance authority |
Two different authorities appear, and the split is deliberate: sanctions pushes
and rule tuning are operational and go to the compliance authority so they can
move at incident speed; changing params — including rotating the compliance
authority itself — is governance. That is why MsgUpdateParams checks a
different address from every other message here.
Read the operational one from params:
curl -s "$REST/hkchain/compliance/v1/params"
Sanctions
MsgUpdateSanctionsList —
{ authority, blocked_addresses[], blocked_vasps[], reason, evidence_ref }.
The two lists behave differently, and this is the part to get right:
- Addresses are additive. Each listed address is blacklisted through the identity registry, idempotently. Omitting a previously-listed address does not un-blacklist it; there is no removal path in this message.
- VASP identifiers are replace-semantics. The set becomes exactly what you sent — anything present before and absent now is removed.
Consequently SanctionsSet.blocked_addresses in the query response is not
the address blocklist to read. No runtime path writes it — this message
replaces the whole set with the VASP list alone, which also clears anything a
genesis file put there. Genesis is in fact the only thing that can populate it:
InitGenesis stores the set verbatim and genesis validation does not inspect
it, so a network can start with a non-empty list, and it stays until the first
sanctions push. No gate consults the field in either state. Address-level state
lives in the identity registry, and the authoritative check is the identity
record's level. Read hkchain.kyc.v1 for an address, and this query
for VASPs.
reason defaults to sanctions_list_push when empty and rides through to the
identity registry's level-change event, so each blacklisting ties back to the
source document named in evidence_ref.
MsgUpdateRule — { authority, rules } — replaces the whole Rules row:
the full-Travel-Rule threshold, the velocity window, and the velocity limit.
Partial updates are not supported; send all three.
The flag workflow
Three messages, two sub-roles, one state machine.
MsgReviewFlag — { reviewer, flag_id, decision, reviewer_evidence_hash }.
Valid only on a flag in EMITTED. The decision may be APPROVED (terminal),
BLOCKED (terminal — and it blacklists the flag's subject, recording
flag/<id> as the evidence chain) or ESCALATED (moves to PENDING_OFFICER).
MsgEscalateFlag — { reviewer, flag_id, officer_note_hash }. The same
escalation, with a note hash for the officer. Also EMITTED-only.
MsgCloseFlag — { officer, flag_id, decision, officer_evidence_hash }.
Valid only on PENDING_OFFICER, and only APPROVED or BLOCKED — an officer
cannot escalate further.
A reviewer can therefore blacklist directly on a clear-cut case; escalation is for the ambiguous ones. Both paths reach the same primitive in the identity registry.
Delegated sub-keys
MsgBindSubRole — { parent, sub_addr, role, label, policy_envelope? }.
The parent's own KYC level decides what it may delegate:
| Role | Parent must be | The sub-key is intended to sign |
|---|---|---|
SUB_ROLE_REVIEWER | LEVEL_LICENSED_ISSUER | MsgReviewFlag, MsgEscalateFlag |
SUB_ROLE_OFFICER | LEVEL_LICENSED_ISSUER | MsgCloseFlag |
SUB_ROLE_AGENT | LEVEL_RETAIL_BASIC or above | MsgSend only, within its policy |
A sub-key belongs to at most one parent: binding an address that another
parent already claims fails, while re-binding under the same parent is an
upsert. Binding an agent may carry policy_envelope so delegation and its
spending limits land in one transaction.
Read that table as the contract a delegated key is built to. Accounts and keys describes what the role gate actually inspects, which is narrower.
MsgRevokeSubRole — { parent, sub_addr } — stamps revoked_at and, for
an agent, flips the policy's revoked flag in the same transaction. It takes
effect from the next block. Revoking twice fails.
MsgBindAgentPolicy — { parent, sub_addr, daily_cap, per_tx_cap } —
replaces the envelope on an existing, active agent binding. Both caps must be
non-negative, share a denom, and satisfy per_tx_cap ≤ daily_cap. The envelope
is exactly these two caps plus the revocation flag; there is no allowlist of
recipients and no expiry.
Note that the caps are denominated: an envelope in hkd.hsbc constrains that
denom, and an agent transfer in a different denom is rejected for the mismatch
rather than checked against a converted amount.
Typed events
| Event | Emitted by | Notes |
|---|---|---|
EventFlagEmitted | Every write to a flag | See the caveat below |
EventFlagResolved | MsgReviewFlag (terminal decisions), MsgCloseFlag | Carries decision, resolver, evidence_hash |
EventFlagEscalated | MsgEscalateFlag, and MsgReviewFlag with ESCALATED | |
EventSubRoleBound | MsgBindSubRole | |
EventSubRoleRevoked | MsgRevokeSubRole | |
EventAgentPolicyUpdated | MsgBindAgentPolicy, and MsgBindSubRole when it carries an envelope | |
EventSanctionsUpdated | MsgUpdateSanctionsList | Carries the computed added/removed deltas, not the whole list |
EventRuleUpdated | MsgUpdateRule | |
EventSARFiled | — | Declared, never emitted |
EventTravelRuleAnchored | — | Declared, never emitted |
EventFlagEmitted means "a flag was written", not "a flag was created". It
is emitted whenever the flag record is persisted, which includes review,
escalation and closure — so resolving a flag emits EventFlagEmitted and
EventFlagResolved in the same transaction. An indexer that counts flags by
this event will over-count; key creation on the first sighting of a flag_id,
or track state from EventFlagResolved / EventFlagEscalated.
The two never-emitted schemas are in the file for planned behaviour. Their comments describe emission that no handler performs today. Do not build a consumer that waits on either.
A stablecoin transfer, note, has no typed transfer event in this module. Transfers are recorded by the bank module's own events plus an audit-log entry; what this module emits is the compliance overlay.
Parameters
| Parameter | Default | Effect |
|---|---|---|
authority | Substituted with the governance address at genesis when left empty | The operational compliance authority — signs sanctions pushes, rule updates, freezes and single-address blacklists |
rules.travel_rule_full_threshold | 800000 (HKD 8,000.00) | At or above this amount a transfer requires the full Travel Rule field set |
rules.velocity_window_seconds | 60 | Rolling window for the velocity gate |
rules.velocity_tx_limit | 10 | Transfers within that window before a flag is raised |
The velocity defaults are deliberately tight so density flags are observable on
a demo network; a production network would retune them through
MsgUpdateRule.
authority is the address the freeze and single-address blacklist messages in
the other modules check too — it is chain-wide, not module-local.
Errors
Codespace compliance. Gate rejections and the module's own execution errors
share it, and the code table for both is in
Errors and rejections. Codes 18–21 and 11/13/14
are gate rejections — reported at admission on the Cosmos path, and carried as
the revert reason of a mined transaction on the EVM path. The rest are ordinary
handler failures: flag not found, wrong flag status, sub-role binding
conflicts, invalid policy envelope.
Queries in this module use gRPC status codes rather than module errors: 404
for a missing payload, binding or policy, 400 for an unparseable address or
missing tx_hash.