Hkchain

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

MethodPathParametersReturns
AuditLogGET /hkchain/compliance/v1/audit_logsubject, tx_type, tx_hash, from, toentries[]
FlagsGET /hkchain/compliance/v1/flagsvasp, status, tx_hash, from, toflags[]
ReportGET /hkchain/compliance/v1/reportperiod_start, period_end, formatsections[]
TravelRulePayloadGET /hkchain/compliance/v1/travel_rule/{tx_hash}tx_hashpayload; 404 if none anchored
SubRoleBindingsGET /hkchain/compliance/v1/sub_roles/{parent}parent, rolebindings[]
SubRoleParentGET /hkchain/compliance/v1/sub_role_parent/{sub_addr}sub_addrparent, binding; 404 no sub-role binding for <addr> if unbound
AgentPolicyGET /hkchain/compliance/v1/agent_policy/{parent}/{sub_addr}both addressespolicy, window; 404 if no policy
SanctionsSetGET /hkchain/compliance/v1/sanctions—set
TxTraceGET /hkchain/compliance/v1/tx_trace/{tx_hash}tx_hashtrace, found
SimulateTracePOST /hkchain/compliance/v1/simulate_tracebody { "tx_bytes": "<base64>" }trace
ParamsGET /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_typeWritten when
transferA stablecoin transfer is screened
threshold_flagThe threshold gate flags a transfer
velocity_flagThe velocity gate flags a transfer
sanctions_updateThe sanctions list is updated
rule_updateDecorator rules are retuned
flag_review, flag_escalate, flag_closeA flag moves through review
sub_role_bound, sub_role_revoked, agent_policy_updateDelegation 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:

StatusMeaningMoves on
FLAG_STATUS_EMITTEDRaised by a gate, untouchedMsgReviewFlag, MsgEscalateFlag
FLAG_STATUS_PENDING_OFFICERA reviewer escalated itMsgCloseFlag
FLAG_STATUS_RESOLVED_APPROVEDClearedterminal
FLAG_STATUS_RESOLVED_BLOCKEDBlocked; the subject was blacklistedterminal

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:

  • SubRoleBindings lists what a parent has delegated. Filter by role, or omit it for all. Revoked bindings are included — check revoked_at, which is the zero timestamp while active.
  • SubRoleParent resolves 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 gets 404 with the message no 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.
  • AgentPolicy returns 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:

SectionContents
par_value_dailyAlways empty. Circulation and reserve figures are served by hkchain.reserve.v1 — read the daily statement there.
sanctions_hitsAudit 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_flagsEvery flag still in FLAG_STATUS_EMITTED in the period — including velocity flags, despite the section name
review_closuresEvery 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

MessageSigner fieldAuthorised when
MsgUpdateSanctionsListauthoritySigner is the compliance authority from params
MsgUpdateRuleauthoritySigner is the compliance authority
MsgReviewFlagreviewerSigner has an active SUB_ROLE_REVIEWER binding
MsgEscalateFlagreviewerSigner has an active SUB_ROLE_REVIEWER binding
MsgCloseFlagofficerSigner has an active SUB_ROLE_OFFICER binding
MsgBindSubRoleparentSigner's own KYC level satisfies the role being delegated
MsgRevokeSubRoleparentThe binding exists under this parent and is not already revoked
MsgBindAgentPolicyparentThe binding exists, is an agent binding, and is active
MsgUpdateParamsauthoritySigner 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:

RoleParent must beThe sub-key is intended to sign
SUB_ROLE_REVIEWERLEVEL_LICENSED_ISSUERMsgReviewFlag, MsgEscalateFlag
SUB_ROLE_OFFICERLEVEL_LICENSED_ISSUERMsgCloseFlag
SUB_ROLE_AGENTLEVEL_RETAIL_BASIC or aboveMsgSend 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

EventEmitted byNotes
EventFlagEmittedEvery write to a flagSee the caveat below
EventFlagResolvedMsgReviewFlag (terminal decisions), MsgCloseFlagCarries decision, resolver, evidence_hash
EventFlagEscalatedMsgEscalateFlag, and MsgReviewFlag with ESCALATED
EventSubRoleBoundMsgBindSubRole
EventSubRoleRevokedMsgRevokeSubRole
EventAgentPolicyUpdatedMsgBindAgentPolicy, and MsgBindSubRole when it carries an envelope
EventSanctionsUpdatedMsgUpdateSanctionsListCarries the computed added/removed deltas, not the whole list
EventRuleUpdatedMsgUpdateRule
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

ParameterDefaultEffect
authoritySubstituted with the governance address at genesis when left emptyThe operational compliance authority — signs sanctions pushes, rule updates, freezes and single-address blacklists
rules.travel_rule_full_threshold800000 (HKD 8,000.00)At or above this amount a transfer requires the full Travel Rule field set
rules.velocity_window_seconds60Rolling window for the velocity gate
rules.velocity_tx_limit10Transfers 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.