Hkchain

Blacklist an address via sanctions or flag review

Question. A sanctions update or an internal investigation determines that a specific address must stop interacting with HKD entirely — no outbound, no inbound, not as principal and not as a delegate. How does the chain arrive at that state, who signs, and what happens to tokens the address already holds?

Short answer. Two signing paths converge on one primitive. The compliance authority pushes an entire list with MsgUpdateSanctionsList, or an issuer's compliance officer resolves a flagged case with MsgCloseFlag { decision: BLOCKED }. Both call into x/kyc.Keeper.Blacklist, which sets the address to LEVEL_BLACKLISTED. From the next block, the AnteHandler's KycGateDecorator rejects any transfer where the signer, the signer's delegating parent (for agent sub-keys), or the recipient is blacklisted. The address's existing HKD balance remains on chain — inert, queryable, unmovable.

Blacklist vs freeze — two different primitives

Blacklist is stricter than the address freeze described in Freeze an address on compliance action.

Blacklist (LEVEL_BLACKLISTED)Freeze (FrozenAccounts)
Outbound transfersrejectedrejected
Inbound transfersrejectedallowed
Redemption (MsgBurn)rejectedrejected
Holder can ever transact againyes, if delisted via governanceyes, on MsgUnfreezeAccount
Enforcement layerKycGateDecorator (AnteHandler)x/stablecoin handler + AnteHandler freeze check
Typical triggersanctions list, court order, flag-resolution BLOCKEDcompliance incident, investigation hold

The two are orthogonal tools. Sanctions-list entries warrant the stronger primitive; investigation holds that may lift once facts emerge warrant the softer one.

The flow, step by step

0. Prerequisite: identities exist or get created

x/kyc.Identity is keyed by bech32 address. The blacklist primitive upserts the identity row — it does not require the address to have been previously enrolled. An address never seen by the chain can be blacklisted preemptively: the identity is created at LEVEL_BLACKLISTED with enrolled-at set to the current block time. This matches the real-world need to honour sanctions against cold addresses before any hypothetical future movement.

1a. Path A — compliance authority pushes a sanctions list

The compliance authority is a single operational principal (held by the issuer's compliance team, typically behind a multisig in production) authorised via x/compliance.Params.Authority. Governance rotates the address through MsgUpdateParams on the compliance module; day-to-day sanctions pushes do not need a governance vote per push.

MsgUpdateSanctionsList {
  authority: compliance_auth_addr,
  blocked_addresses: [alice_addr, bob_addr, ...],
  blocked_vasps: ["sanctioned-vasp-1", ...],
  reason: "ofac-sdn-list-20260501",
  evidence_ref: "sha256:3f8a..."
}

On chain, per call:

  • Authority check: authority must equal the compliance authority resolved from x/compliance.Params. Any other signer is rejected.
  • For each address in blocked_addresses: call x/kyc.Keeper.Blacklist(addr, reason, evidence_ref). The call is idempotent — re-pushing an already-blacklisted address is a no-op.
  • For VASP identifiers: SanctionsSet.BlockedVasps is replaced wholesale with the new list. Identifiers in the previous set but not in the new list are dropped; identifiers in both are kept; new identifiers are added. Replace semantics match how sanctions lists are versioned by their issuing body.
  • EventSanctionsUpdated { addresses_added, vasps_added, vasps_removed, reason, evidence_ref } is emitted, scoped to the diff of this push.
  • For each address actually newly-blacklisted, EventLevelChanged { old_level: LEVEL_UNVERIFIED or previous, new_level: LEVEL_BLACKLISTED, reason, evidence_ref } is emitted by x/kyc.

The reason and evidence_ref strings are shared across every entry in the push — a single sanctions-list version covers every address and VASP added under it. This is the design choice that ties every chain-side blacklist action to exactly one off-chain source document.

MVP: the compliance authority pushes lists manually through a tx signed by the held key. A live sanctions-feed integration (automated push from OFAC, HKMA's own list, a chain analytics provider) is an external service that would sit on top of the authority's signing key in a future integration — out of MVP scope.

1b. Path B — an officer closes a flag as BLOCKED

The flag workflow handles case-by-case decisions: a decorator (threshold, velocity, sanctions) emits a Flag during AnteHandler processing; a reviewer triages it; an officer makes the terminal decision on escalated flags.

MsgCloseFlag {
  officer: officer_addr,
  flag_id: 42,
  decision: FLAG_DECISION_BLOCKED,
  officer_evidence_hash: "sha256:9c2b..."
}

On chain:

  • Authority check: the officer must be a bound, non-revoked SUB_ROLE_OFFICER sub-key. The officer's parent must be an LEVEL_LICENSED_ISSUER (L5) under MVP scope.
  • Status check: the flag must be in PENDING_OFFICER (reviewer has already escalated). Flags in EMITTED can be blocked by a reviewer directly; CloseFlag is for the escalated subset only.
  • The flag transitions to RESOLVED_BLOCKED.
  • kycKeeper.Blacklist(flag.Subject, "flag_blocked", "flag/<id>") is called. The flag id becomes the evidence chain, linking the on-chain blacklist action back to the specific case file.
  • EventFlagResolved + EventLevelChanged are emitted.

MVP: the chain permits any bound officer to close any flag in PENDING_OFFICER status — flag-to-VASP routing is not enforced. finalProduct would scope officers to flags on their own VASP's activity (e.g., L4 VASPs reviewing their own customers), which requires VASP-scoped flag routing, per-VASP KYC-provider authority, and first-class per-user VASP-ownership semantics in x/kyc. These are significant design extensions out of MVP scope.

2. What the AnteHandler rejects

From the next block after the blacklist action, the KycGateDecorator inspects every transfer-like message in every tx. It rejects the tx when any of the following applies:

Signer is blacklisted. The signer's identity level is LEVEL_BLACKLISTED. The tx is rejected before signature verification completes — the blacklisted holder cannot produce a valid tx regardless of whether their key is compromised, available, or still in their control.

Signer is an agent sub-key whose parent is blacklisted. Agent sub-keys are not independent identities for KYC purposes — the decorator resolves sub_addr → parent via the reverse index in x/compliance and checks the parent's level. Blacklisting a primary key immediately disables every agent delegation under it, without having to enumerate and revoke sub-keys one by one. This is the dual of must-show #9's revocation story: the revocation primitive works from the user side; blacklist propagation works from the compliance side.

Recipient is blacklisted. Transfers to a blacklisted address are also rejected. This is the stricter-than-freeze semantics: a blacklisted address is inert in both directions. The narrative matches sanctions-list semantics, where receiving funds by a sanctioned party is as prohibited as sending from them.

The rejection happens inside the AnteHandler, before the message reaches the value-moving module (x/bank, x/stablecoin). State is not touched. Error strings carry the blacklisted principal (signer=... or recipient=...) so operational tools can surface the cause.

3. What the blacklist does not prevent

Reading. Balances, identity records, recent history, and any Flag or AuditEntry rows involving the address remain queryable. Blacklist acts on movement, not visibility.

Ownership of tokens. Tokens held by the blacklisted address stay at that address. They count toward total supply; they appear in balance queries. They cannot move until the address is delisted (via governance) or the tokens are separately handled off-chain by the issuer under whatever policy applies.

In-flight redemption requests, if any. A redemption opened before blacklist remains in the request store. Whether the issuer chooses to ack it is an off-chain decision — on-chain ack is still rejected while the holder remains blacklisted, because the handler path consults the same KYC-level check. In practice the issuer either waits out a policy window, seeks delisting, or does not settle; none of those branches is a chain-level primitive.

The audit trail, end to end

Any blacklist event leaves a structured, queryable record:

  1. EventSanctionsUpdated or EventFlagResolved — the compliance-layer action, carrying the reason and the evidence pointer.
  2. EventLevelChanged { ..., new_level: LEVEL_BLACKLISTED, reason, evidence_ref } — the x/kyc level transition, one per newly-blacklisted address. Idempotent re-pushes produce no event.
  3. AuditEntry { tx_type: "sanctions_update" } (list path) or AuditEntry { tx_type: "flag_close" } (officer path) in x/compliance.AuditLog, with the authority address and summary metadata.
  4. (During the blacklist) Any rejected transfer attempt involving the address is observable through the chain's tx rejection stream — those rejected txs never mutate state, but they appear in the mempool / rejection history with a decorator-specific error code.

A reviewer after the fact can reconstruct: which path applied the blacklist, which authority signed, when, on what evidence, what transfers were attempted during the blacklist, and what level the address held before. The two evidence handles (sanctions-push evidence_ref, flag-close flag/<id>) bind every on-chain blacklist to a specific off-chain document or case.

Edge cases and what happens

Blacklist from a non-authorised signer

MsgUpdateSanctionsList signed by any address other than the compliance authority is rejected at the compliance module's msg server. MsgCloseFlag by a non-officer — or by an officer whose binding is revoked — is rejected with a distinct sub-role error. Governance does not sign either message directly; governance's role is rotating the compliance authority, not acting as one.

Blacklist an address with no tokens

Supported. Blacklist upserts the identity at LEVEL_BLACKLISTED regardless of prior enrolment. Useful for preemptive sanctions-list propagation before any hypothetical token movement.

Re-pushing an already-blacklisted list

Idempotent. Re-applying the same blocked_addresses is a no-op per address (no duplicate event, no state churn). Sanctions lists are typically republished versioned — the chain tolerates the repetition without spamming the audit log.

VASP identifier on the list without a corresponding address blacklist

Supported and distinct. SanctionsSet.BlockedVasps carries VASP identifiers (LEI-style); KycGateDecorator looks up the Identity.vasp_identifier of transfer parties against this list separately. An address whose VASP is sanctioned is rejected independently of whether the address itself is at LEVEL_BLACKLISTED. This is the two-axis nature of sanctions screening — parties and intermediaries both.

Delisting (removing a blacklist)

Not a compliance-authority primitive on MVP. Removing an address from the sanctions list does not automatically restore its KYC level — the compliance-authority push updates the SanctionsSet for VASPs (replace semantics) but does not downgrade addresses already written to LEVEL_BLACKLISTED. Restoring an address requires a governance-signed path. Rationale: blacklist is a supervised, irreversible-except-by-governance boundary; once the chain records an address at LEVEL_BLACKLISTED with evidence, the lift cannot happen through the same operational key that set it.

MVP: governance-signed delisting is scoped out; blacklist is one-way during the demo window. finalProduct includes a governance-gated delist path with its own evidence handle.

Blacklisting the compliance authority itself

Mechanically possible — nothing prevents the compliance authority from including its own address in a list push — but operationally catastrophic. Governance should rotate the compliance authority before any action could land on the current address; once blacklisted, the authority can no longer sign even the rotation proposal. This is an operational foot-gun, not a chain invariant.

On-chain state summary

State objectSanctions-list pathFlag-close path
x/kyc.Identity[addr].levelLEVEL_BLACKLISTED (per address in list)LEVEL_BLACKLISTED (for the flag's subject)
x/compliance.SanctionsSet.BlockedVaspsreplaced wholesaleunchanged
x/compliance.SanctionsSet.BlockedAddressesunchanged — address state lives in x/kycunchanged
x/compliance.Flag[id].statusunchangedRESOLVED_BLOCKED
x/compliance.AuditLogsanctions_update entry appendedflag_close entry appended
EventSanctionsUpdatedemitted once per pushnot emitted
EventFlagResolvednot emittedemitted for the flag
EventLevelChangedemitted per newly-blacklisted addressemitted for the flag's subject