Hkchain

hkchain.stablecoin.v1

The stablecoin module owns HKD supply: which denominations exist, who issues them, how tokens enter and leave circulation, and the two switches — pause and freeze — that stop supply operations.

Schema: proto/hkchain/stablecoin/v1/{query,tx,events,stablecoin}.proto.

It does not reject transfers. Holder-to-holder movement is x/bank plus the compliance gates; this module is the supply side of the ledger.

Concepts

Two-step issuance. Minting does not put tokens into circulation. MsgMint creates supply into the module account and credits the issuer's treasury row; MsgDistribute releases from that row to a named recipient. Circulation is therefore total supply minus what is still sitting in treasuries, and the two messages are separately authorised and separately evented.

Two-step redemption. MsgBurn parks the holder's tokens in the module account and opens a RedemptionRequest; the coins are actually destroyed later, when the issuer confirms the off-chain payout with MsgAckRedemption. Between the two, the amount is visible as pending_redemption on the issuer's treasury row.

Denoms are namespaced. Every stablecoin denom is hkd.<issuer-slug>, and the registry records which issuer owns it. Most authorisation in this module is "is the signer the issuer of this denom", not a global role.

Queries

All paths are relative to the REST base; the gRPC service is hkchain.stablecoin.v1.Query.

MethodGET pathParametersReturns
Denoms/hkchain/stablecoin/v1/denoms—denoms[] — every registered denom
Denom/hkchain/stablecoin/v1/denoms/{denom}denom path segmentOne denom record; error if unregistered
IssuerTreasury/hkchain/stablecoin/v1/issuer/{issuer}/treasuryissuer bech32treasuries[] — one row per denom the issuer has minted
RedemptionRequest/hkchain/stablecoin/v1/redemption/{request_id}request_id uint64One request record
OpenRedemptions/hkchain/stablecoin/v1/redemptions/openissuer query param, optionalrequests[] — state OPEN only
PauseState/hkchain/stablecoin/v1/pause_state—state — global flag plus per-issuer flags
Params/hkchain/stablecoin/v1/params—params

Note the two redemption paths: singular /redemption/{request_id} for lookup by id, plural /redemptions/open for the collection.

Denoms and OpenRedemptions declare a pagination field that the node does not apply. The request schema carries it and the response schema has a pagination tail, but both handlers return the complete set and leave the tail empty. Treat these as unpaginated: do not send a page key expecting it to be honoured, and do not loop on next_key.

Response shapes

A denom record:

{
  "denom": "hkd.hsbc",
  "display": "HKD-HSBC",
  "issuer": "hkchain1...",
  "decimals": 2
}

decimals is the display exponent. Amounts everywhere else in the API are integer strings in the base unit, so "150000" of a 2-decimal denom is HKD 1,500.00.

A treasury row:

{
  "issuer": "hkchain1...",
  "denom": "hkd.hsbc",
  "minted_available": { "denom": "hkd.hsbc", "amount": "0" },
  "pending_redemption": { "denom": "hkd.hsbc", "amount": "25000" }
}

minted_available is minted but not yet distributed — supply that exists and is not in circulation. pending_redemption is burned but not yet acknowledged. Rows are created on first mint, so an issuer that has never minted a denom returns an empty treasuries list rather than a row of zeroes.

A redemption request:

{
  "id": "7",
  "holder": "hkchain1...",
  "issuer": "hkchain1...",
  "amount": { "denom": "hkd.hsbc", "amount": "25000" },
  "state": "REDEMPTION_STATE_OPEN",
  "opened_at": "2026-08-09T11:02:41Z",
  "acked_at": "0001-01-01T00:00:00Z",
  "fiat_ref": ""
}

acked_at is a non-nullable timestamp, so an open request carries the zero time, not null and not an absent field. Branch on state, never on the presence of acked_at.

Example

Which stablecoins exist, and how much of one is still undistributed:

curl -s "$REST/hkchain/stablecoin/v1/denoms"
curl -s "$REST/hkchain/stablecoin/v1/issuer/hkchain1.../treasury"

Whether supply operations are currently halted:

curl -s "$REST/hkchain/stablecoin/v1/pause_state"
{ "state": { "global": false, "issuers": [ { "issuer": "hkchain1...", "paused": false } ] } }

A per-issuer row stays in the list after the issuer is unpaused, with paused false. Read the flag, not the presence of the row.

Messages

The Signer field column names the message field the SDK derives the required signature from. Authorised when is what the handler enforces on top of that.

MessageSigner fieldAuthorised when
MsgMintissuerSigner is registered at LEVEL_LICENSED_ISSUER in x/kyc and owns the denom
MsgDistributeissuerSame as mint, plus sufficient minted_available
MsgBurnholderAny holder, unless the address is frozen
MsgAckRedemptionissuerSigner matches the issuer recorded on the request
MsgPause / MsgUnpausesignerScope GLOBAL: signer is the governance authority. Scope ISSUER: signer is the named issuer and is LEVEL_LICENSED_ISSUER
MsgFreezeAccount / MsgUnfreezeAccountauthoritySigner is the compliance authority — not governance
MsgRegisterDenomauthoritySigner is the governance authority
MsgUpdateParamsauthoritySigner is the governance authority

Three authority kinds appear here and they are different addresses: the governance module account (params, denom registration, global pause), the compliance authority (freeze), and the issuer's own key (mint, distribute, per-issuer pause). Sending the right message from the wrong one of these is the most common unauthorized authority in this module.

Supply operations

MsgMint — { issuer, amount, attestation_ref }.

Mint is refused unless the issuer has a recent reserve attestation on file: the handler reads the latest attested time for that issuer from x/reserve and rejects if it is older than max_attestation_staleness. attestation_ref is recorded on the event as the attestation this mint claims backing from.

Order of checks: amount validity → not paused → issuer level → denom ownership → attestation freshness. A paused issuer therefore gets stablecoin activity paused rather than a level or staleness error, whatever else is also wrong.

MsgDistribute — { issuer, recipient, amount }.

Releases from the issuer's treasury row into the recipient's spendable balance. An issuer may name itself as recipient; the tokens count as circulating from that moment either way.

MsgBurn — { holder, amount } → { request_id }.

The response carries the id of the opened request. Capture it: it is the only handle to the redemption, and MsgAckRedemption takes it as its argument.

MsgAckRedemption — { issuer, request_id, fiat_ref }.

Closes the loop. fiat_ref is the issuer's off-chain payment reference (an MT103 number, an FPS id) and is stored on the request and emitted on the event — it is what ties the on-chain burn to the bank transfer that settled it.

Ack authorises on the request, not on the role: the signer must equal the issuer recorded on the request when it was opened. A request already in state ACKED cannot be acked again.

Compliance gates on supply messages

MsgDistribute and MsgBurn move hkd.*, so they are visible to the compliance gates and are screened like any transfer. MsgMint and MsgAckRedemption are not transfers between parties and produce no compliance fact.

One asymmetry worth knowing: the delegated-role gate recognises MsgSend and the compliance administrative messages, and does not recognise MsgDistribute or MsgBurn. The screening gates see those two; the role gate does not. Accounts and keys covers what that means for delegated keys.

The two switches

Pause halts supply operations. A global pause supersedes every per-issuer flag. While paused, MsgMint, MsgDistribute, MsgBurn and MsgAckRedemption for the affected issuer all fail with stablecoin activity paused.

Freeze is narrower than its name suggests. MsgFreezeAccount sets a marker that the handler for MsgBurn alone consults: a frozen address cannot open a redemption. No gate in the transaction-admission chain reads the freeze marker, so a frozen address can still send hkd.* with an ordinary transfer. The primitive that stops an address transacting is blacklisting, in hkchain.kyc.v1.

MsgFreezeAccount carries reason and evidence_hash — a short code and a hash binding the action to an off-chain case file. Both are emitted on the event; unfreeze carries evidence_hash only.

Registering a denom

MsgRegisterDenom takes a whole Denom record and is governance-only. The denom string must begin with hkd. and must not already be registered.

Registering a denom here makes it mintable and queryable. It does not make it visible to the EVM — that mapping is fixed in the node software and is described in the EVM interface.

Typed events

EventEmitted byCarries
EventMintedMsgMintissuer, amount, attestation_ref
EventDistributedMsgDistributeissuer, recipient, amount
EventBurnedMsgBurnholder, amount, request_id
EventRedemptionAckedMsgAckRedemptionissuer, request_id, holder, amount, fiat_ref
EventPauseChangedMsgPause and MsgUnpausescope, issuer, paused, reason
EventAccountFrozenMsgFreezeAccount and MsgUnfreezeAccountaddress, frozen, reason, evidence_hash
EventDenomRegisteredMsgRegisterDenomdenom

The two shared events carry the resulting state in a boolean — paused, frozen — so an indexer reads the value rather than inferring direction from which message ran. MsgUpdateParams emits nothing.

Event types are the fully qualified proto names, e.g. hkchain.stablecoin.v1.EventMinted. See Events and indexing for the wire encoding and subscription filters.

Circulation is not evented. It is derived — total bank supply of a denom minus the issuer's minted_available — and the daily figure is published by hkchain.reserve.v1 at each day boundary.

Parameters

ParameterTypeDefaultEffect
suspicious_amountinteger string, base unit800000 (HKD 8,000.00)None — nothing reads it. See below
max_attestation_stalenessduration string168h0m0s (7 days)How old the issuer's latest attestation may be before MsgMint is refused

Both are governance-settable through MsgUpdateParams. Read the live values rather than assuming the defaults:

curl -s "$REST/hkchain/stablecoin/v1/params"

suspicious_amount is an unused duplicate. Its name and its proto comment describe the review threshold, but no gate, handler or query reads it — it is only defaulted and validated in this module. The threshold the chain actually applies is rules.travel_rule_full_threshold in compliance params, which is read by both the Travel Rule gate (which tier an amount demands) and the threshold gate (when to raise a flag). The two default to the same number, which is why the duplication is easy to miss.

Point any integration — and any governance change — at the compliance parameter:

curl -s "$REST/hkchain/compliance/v1/params"

Changing suspicious_amount alone moves no threshold and blocks or flags nothing differently. Its fate is a chain-side question: hkchain.compliance.v1 documents the live parameter.

Errors

Module errors use codespace stablecoin; the code table is in Errors and rejections. The ones specific to this module's flow are 7 (attestation stale or missing — mint without fresh reserve backing), 9 (treasury has less minted_available than the distribute asks for) and 13 (redemption request not in OPEN state).