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 transfers | rejected | rejected |
| Inbound transfers | rejected | allowed |
Redemption (MsgBurn) | rejected | rejected |
| Holder can ever transact again | yes, if delisted via governance | yes, on MsgUnfreezeAccount |
| Enforcement layer | KycGateDecorator (AnteHandler) | x/stablecoin handler + AnteHandler freeze check |
| Typical trigger | sanctions list, court order, flag-resolution BLOCKED | compliance 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:
authoritymust equal the compliance authority resolved fromx/compliance.Params. Any other signer is rejected. - For each address in
blocked_addresses: callx/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.BlockedVaspsis 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 byx/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_OFFICERsub-key. The officer's parent must be anLEVEL_LICENSED_ISSUER(L5) under MVP scope. - Status check: the flag must be in
PENDING_OFFICER(reviewer has already escalated). Flags inEMITTEDcan be blocked by a reviewer directly;CloseFlagis 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+EventLevelChangedare 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:
EventSanctionsUpdatedorEventFlagResolved— the compliance-layer action, carrying the reason and the evidence pointer.EventLevelChanged { ..., new_level: LEVEL_BLACKLISTED, reason, evidence_ref }— thex/kyclevel transition, one per newly-blacklisted address. Idempotent re-pushes produce no event.AuditEntry { tx_type: "sanctions_update" }(list path) orAuditEntry { tx_type: "flag_close" }(officer path) inx/compliance.AuditLog, with the authority address and summary metadata.- (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 object | Sanctions-list path | Flag-close path |
|---|---|---|
x/kyc.Identity[addr].level | LEVEL_BLACKLISTED (per address in list) | LEVEL_BLACKLISTED (for the flag's subject) |
x/compliance.SanctionsSet.BlockedVasps | replaced wholesale | unchanged |
x/compliance.SanctionsSet.BlockedAddresses | unchanged — address state lives in x/kyc | unchanged |
x/compliance.Flag[id].status | unchanged | RESOLVED_BLOCKED |
x/compliance.AuditLog | sanctions_update entry appended | flag_close entry appended |
EventSanctionsUpdated | emitted once per push | not emitted |
EventFlagResolved | not emitted | emitted for the flag |
EventLevelChanged | emitted per newly-blacklisted address | emitted for the flag's subject |