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.
| Method | GET path | Parameters | Returns |
|---|---|---|---|
Denoms | /hkchain/stablecoin/v1/denoms | — | denoms[] — every registered denom |
Denom | /hkchain/stablecoin/v1/denoms/{denom} | denom path segment | One denom record; error if unregistered |
IssuerTreasury | /hkchain/stablecoin/v1/issuer/{issuer}/treasury | issuer bech32 | treasuries[] — one row per denom the issuer has minted |
RedemptionRequest | /hkchain/stablecoin/v1/redemption/{request_id} | request_id uint64 | One request record |
OpenRedemptions | /hkchain/stablecoin/v1/redemptions/open | issuer query param, optional | requests[] — 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.
| Message | Signer field | Authorised when |
|---|---|---|
MsgMint | issuer | Signer is registered at LEVEL_LICENSED_ISSUER in x/kyc and owns the denom |
MsgDistribute | issuer | Same as mint, plus sufficient minted_available |
MsgBurn | holder | Any holder, unless the address is frozen |
MsgAckRedemption | issuer | Signer matches the issuer recorded on the request |
MsgPause / MsgUnpause | signer | Scope GLOBAL: signer is the governance authority. Scope ISSUER: signer is the named issuer and is LEVEL_LICENSED_ISSUER |
MsgFreezeAccount / MsgUnfreezeAccount | authority | Signer is the compliance authority — not governance |
MsgRegisterDenom | authority | Signer is the governance authority |
MsgUpdateParams | authority | Signer 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
| Event | Emitted by | Carries |
|---|---|---|
EventMinted | MsgMint | issuer, amount, attestation_ref |
EventDistributed | MsgDistribute | issuer, recipient, amount |
EventBurned | MsgBurn | holder, amount, request_id |
EventRedemptionAcked | MsgAckRedemption | issuer, request_id, holder, amount, fiat_ref |
EventPauseChanged | MsgPause and MsgUnpause | scope, issuer, paused, reason |
EventAccountFrozen | MsgFreezeAccount and MsgUnfreezeAccount | address, frozen, reason, evidence_hash |
EventDenomRegistered | MsgRegisterDenom | denom |
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
| Parameter | Type | Default | Effect |
|---|---|---|---|
suspicious_amount | integer string, base unit | 800000 (HKD 8,000.00) | None — nothing reads it. See below |
max_attestation_staleness | duration string | 168h0m0s (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).