Hkchain

Rotate the compliance authority via governance

Question. Hkchain uses a single operational key — the compliance authority — to sign sanctions-list pushes, rule tuning, address blacklists, and address freezes. What happens when that key needs to rotate (planned hand-over, suspected compromise, HKMA-directed change)? Who authorises the change, and what prevents the compromised key from rotating itself?

Short answer. The chain separates two authorities. Governance is the policy authority (the x/gov module account address). The compliance authority is the operational authority (x/compliance.Params.Authority — a single bech32 address). Rotation is a governance-signed MsgUpdateParams on the compliance module; the new authority address takes effect at the block the proposal passes and the message executes. The operational compliance authority cannot rotate itself — only governance can — so a compromised key cannot lock in its compromise.

Why two separate authorities

Collapsing governance and the operational compliance authority into a single key would collapse an audit boundary the framework relies on. The two roles are materially different:

Governance authorityOperational compliance authority
SignsPolicy changes: rotations, param updates, denom registrationsOperational actions: sanctions pushes, rule tuning, blacklists, freezes
SpeedDeliberate — proposal, vote, executionImmediate — a single signed tx
Held byThe chain's governance process (voting stakeholders in MVP; HKMA-acceptable multisig in finalProduct)The issuer's compliance team (single key in MVP; multisig in production)
AccountabilityRecorded as a gov proposal with voter trailRecorded as direct-signed tx with authority address
What it must not doExecute per-address enforcement actionsChange the rules the enforcement operates under

Separation means the chain can distinguish "the policy changed" from "the compliance team acted." A single key doing both would erase that line in every on-chain event stream.

It also means key-compromise blast radius is bounded. A compromised compliance-authority key can (until detected) push rogue sanctions lists or tune rules; it cannot rotate itself, upgrade the module, or change governance. A governance-key compromise is a different, higher-severity incident addressed at the governance layer — outside the scope of this flow.

The rotation flow, step by step

0. Prerequisite: the current state

x/compliance.Params.Authority holds the current compliance authority address. The module constructor also carries a separate authority field — the governance authority used for MsgUpdateParams. These two are distinct by design; the former rotates via governance, the latter is the module's gov-module- account address fixed at chain bootstrap.

x/kyc.msgServer.Blacklist — the keeper primitive called by every compliance path that needs to write LEVEL_BLACKLISTED — reads the current compliance authority at tx time through the ComplianceKeeper expected-keeper interface:

ComplianceKeeper.GetComplianceAuthority(ctx) → Params.Authority

This lookup happens every tx, so rotations take effect immediately at the block they commit; no cached authority anywhere in the stack.

1. A governance proposal is submitted

The rotation starts with a standard governance proposal carrying MsgUpdateParams on the compliance module:

MsgUpdateParams {
  authority: gov_module_account_addr,
  params: Params {
    authority: new_compliance_auth_addr,
    rules: <unchanged>
  }
}

The proposal is authored off-chain, submitted via the usual MsgSubmitProposal carrying the update as its first message, and enters the voting period. Stakeholders vote; on passage, the proposal's messages execute.

At execution time, MsgUpdateParams runs:

  • Authority check: msg.authority must equal the module's constructor-time governance address. Any other signer is rejected with an unauthorised-authority error. This is the check that enforces "only governance can rotate the compliance authority" — the existing compliance authority's signature is not accepted on this message, because authority is the gov module account, not Params.Authority.
  • Params replace: the new Params (including the new Authority) is written to the store. The old authority address is overwritten in place.

From the next block, every consumer of the compliance authority resolves it to the new address.

2. What the rotation affects immediately

The operational actions that gate on the compliance authority shift to the new key:

  • MsgUpdateSanctionsList — the sanctions-push path. The new key's signature passes the authority check; the old key's signature is rejected with the authority-mismatch error.
  • MsgUpdateRule — the rule-tuning path (Rules.TravelRuleFullThreshold, VelocityWindowSeconds, VelocityTxLimit).
  • x/kyc.msgServer.Blacklist — the single-address blacklist path consulted via the ComplianceKeeper interface. The new key is now the recognised signer.
  • x/stablecoin.MsgFreezeAccount / MsgUnfreezeAccount — the address-freeze path, gated on the same compliance-authority address resolved via keeper.

All of these read the current authority at tx time; none of them holds a cached reference.

3. What the rotation does not affect

Existing on-chain state. Addresses already at LEVEL_BLACKLISTED, already-frozen accounts, the current SanctionsSet, prior AuditLog entries — all remain. Rotation changes who is allowed to write to those structures; it does not rewrite them.

Pending flags. Flags emitted before the rotation remain in their current status. Reviewers and officers act on flags through sub-role bindings under an L5 issuer, not through the compliance authority — so rotating the compliance authority does not affect the reviewer / officer workflow.

Governance authority. The module's constructor-time authority field — the governance address permitted to sign MsgUpdateParams — is unchanged. Rotating governance itself is a different problem, solved by the normal governance-proposal mechanics of the SDK's x/gov.

KYC-provider allowlist. x/kyc.Params.kyc_provider_addresses (the distinct allowlist used by MsgEnroll / MsgUpgradeLevel) is not the compliance authority. Rotating that list is a separate MsgUpdateParams on x/kyc — a different governance proposal with a different target.

The audit trail, end to end

For a rotation:

  1. Governance proposal recorded via x/gov with the MsgUpdateParams body visible in the proposal details. Vote tally, voter addresses, and passage block are all public.
  2. Proposal execution at the block the voting period closes. The MsgUpdateParams handler runs; the new params are written.
  3. Params event (or direct Params read) — the new Params.Authority is observable from the next block through QueryParams. No dedicated EventAuthorityRotated is emitted in MVP — the governance proposal trail is the authoritative record.

A reviewer or regulator can reconstruct: which proposal rotated the authority, when it passed, what address was rotated out, what address was rotated in, and which stakeholders voted. The separation also makes it easy to answer "when did key X last hold compliance authority?" by walking backward through MsgUpdateParams executions on the compliance module.

MVP: the audit trail relies on the standard x/gov proposal history; there is no compliance-module-specific rotation event. finalProduct may emit an EventComplianceAuthorityRotated for easier downstream indexing — a small proto extension reserved for a follow-up pass.

Edge cases and what happens

Current compliance authority tries to rotate itself

Rejected with an unauthorised-authority error. MsgUpdateParams requires the governance authority (module constructor address), not Params.Authority. The authority value under rotation cannot produce a valid signature for its own rotation — the two keys are in different checks.

Rotation to an invalid address

Rejected at params validation. The new Params.Authority is checked as a well-formed bech32 address at proposal-execution time; malformed values fail the governance message's own validation.

Rotation to the gov module account

Mechanically possible — the chain does not prevent it — but operationally it would partially collapse the two-authority separation. Governance votes would be required for every sanctions push. Strongly discouraged as an operational choice, though not blocked as a primitive.

Rotation during a flag review in progress

Harmless. Flag review uses sub-role bindings (officer / reviewer), not the compliance authority. The operator finishing a review at the rotation block can proceed without any handler change.

Rotation mid-sanctions-push

If a sanctions-push tx is in the mempool signed by the old authority when the rotation commits, the push is rejected at the next block with the authority-mismatch error. The push has to be re-signed by the new authority. This is the intended behaviour — the rotation is a boundary, and no in-flight operational action crosses it.

The new authority address holds no HKD / has no other role

Supported. The compliance authority is a pure signing role — it does not need to hold tokens, does not need a KYC identity, does not need sub-role bindings. A fresh bech32 address generated for the role is a common operational choice.

Repeatedly rotating

Idempotent. Each MsgUpdateParams replaces the full Params value; rotating A → B → A → B leaves the chain at B with three audit-trail entries for the proposals. No accumulated state; no quota on rotation frequency (beyond what governance timing imposes).

Hand-over protocol (off-chain, informed by the on-chain primitive)

The chain provides the primitive; the hand-over protocol around it is operational. A typical hand-over runs:

  1. New key generated and held in the intended production custody (multisig, HSM, etc.).
  2. Governance proposal drafted naming the new address, linked to off-chain documentation explaining the change (new personnel, custody migration, incident response).
  3. Voting period; stakeholders verify the off-chain documentation.
  4. Proposal passes; rotation commits.
  5. Old key is formally retired and archived; custody of the old key is documented for any post-incident audit.

None of steps 2, 4, or 5 is a chain primitive. The chain gates only the rotation event itself; everything else is the governance and operations layer's responsibility.

On-chain state summary

State objectOn rotation
x/compliance.Params.Authorityreplaced with new address
x/compliance.Params.Rulesunchanged (unless the proposal also updates rules)
Module constructor governance authorityunchanged
x/kyc.Params.kyc_provider_addressesunchanged (separate allowlist)
x/gov.Proposal[n]rotation proposal recorded with the body visible
x/kyc.Identity rows, SanctionsSet, AuditLogunchanged
Reviewer / officer sub-role bindingsunchanged