Attach a Travel Rule payload to a transfer
Question. A user wants to send HKD to another address. Hong Kong regulation requires Travel Rule information on every transfer — no threshold. What exactly rides on the tx, how does the chain verify it, and how does the requirement shift as the amount crosses the HKD 8,000 line?
Short answer. Every HKD transfer tx carries a
TravelRulePayload as a tx extension option, not as a separate
message. The TravelRuleDecorator reads the payload out of
tx.GetExtensionOptions() before the tx executes. Below HKD 8,000,
a minimal tier — originator_ref + beneficiary_ref (IVMS101
hashes) — satisfies the check. At or above HKD 8,000, the full tier
is required: additionally originator_vasp + beneficiary_vasp
identifiers. Missing, under-tiered, or incomplete payloads are
rejected at the AnteHandler; the tx never reaches x/bank. On
pass, the payload is anchored in the TravelRuleRegistry keyed by
the tx hash — the chain holds the hashes only; the full IVMS101
record stays off-chain at the originator VASP.
Why a tx extension, not a separate message
The IVMS101 payload is intrinsic to the transfer it describes — one transfer, one payload, one atomic unit. The alternatives and why they were rejected:
- Separate
MsgSubmitTravelRulePayloadahead of the transfer: creates a two-tx coupling where the second tx's compliance depends on the first's inclusion. Ordering, replay, and "orphan transfers with no payload" all become edge cases. - Separate payload submitted after the transfer: the transfer executes first, meaning compliance enforcement happens after value has moved. Unacceptable for zero-threshold Travel Rule.
- Tx extension option on the transfer tx: atomic. The decorator sees the payload before the tx produces any effect; rejection means no value moves; acceptance means the payload is anchored at the same block the value moves.
The extension-option design is the consequence of Hong Kong's zero-threshold rule. There is no "low-value fast path" the chain could skip, so the payload has to be present inline on every transfer.
The flow, step by step
1. The sender builds the tx with the payload attached
A transfer-capable client (wallet, service backend, bridge) builds
the transfer message and attaches TravelRulePayload to the tx's
extension options:
tx {
messages: [
MsgSend { from: alice, to: bob, amount: [100_00 hkd.hsbc] }
],
extension_options: [
Any {
type_url: "/hkchain.compliance.v1.TravelRulePayload",
value: TravelRulePayload {
originator_ref: "sha256:<hash of alice's IVMS101 record>",
beneficiary_ref: "sha256:<hash of bob's IVMS101 record>",
tier: "minimal"
}
}
],
...signatures
}
The originator_ref / beneficiary_ref are SHA-256 hashes of the
parties' IVMS101 records (name, address, identifier, account). The
actual records stay with the originator VASP and are shared with
the beneficiary VASP through an off-chain channel (TRP, TRUST,
Sygna Bridge, etc. depending on the corridor); the chain never
sees the PII. tier is "minimal" or "full" — selected by the
sender based on the transfer amount.
The tx_hash field on the payload is optional at submission time —
the chain derives it from the raw tx bytes at AnteHandler time and
uses that as the storage key.
2. The TravelRuleDecorator validates the payload
Before the message reaches its handler, the decorator:
- Iterates the tx's transfer-like messages. Non-transfer txs (governance, compliance admin, reviewer workflow) are ignored — Travel Rule applies to value-moving flows.
- Rejects a tx with more than one transfer message that claims a single TravelRulePayload — one payload cannot unambiguously describe multiple transfers.
- Looks up
TravelRulePayloadintx.GetExtensionOptions()by its type URL. A transfer tx without a payload is rejected: zero threshold, no exception. - Validates the payload against the transfer amount. See the next sections for the tier check.
- Cross-checks against
x/kyc: both VASP identifiers, when present, must equal the sender's / recipient'sIdentity.vasp_identifierregistered at KYC onboarding; and for tier="minimal", theoriginator_ref/beneficiary_refmust equal the parties'Identity.ivms101_off_chain_ref. Forged institution binding or an invented minimal-tier ref is rejected at the chain, not left to off-chain audit. - On pass, writes the (derived-tx-hash-keyed) record into
TravelRuleRegistry.
The decorator runs inside the AnteHandler; rejection means the tx never executes and no state changes. Accepted tx state changes and the registry write commit together.
3. Tier is bonded to the sender's KYC level
The tier on a payload is not a caller-chosen label. The chain reads
the sender's KYC level from x/kyc.LevelDefinition.TravelRuleTier,
treats that as the authoritative tier, and runs two pre-checks before
any structural validation:
- Claim gate —
payload.tiermust equal the KYC-registered tier. An L2 retail_basic sender attesting"full", or an L4 institutional sender attesting"minimal", is rejected with "payload tier=… does not match sender's KYC-registered tier=…". Over-supplying tier is not allowed — the attestation has to tell the truth. - Capability gate — the KYC tier (rank:
full > minimal > none) must be at least what the amount demands (fullat or aboveTravelRuleFullThreshold, elseminimal). An L2 retail_basic sender can never originate a transfer at or above the threshold no matter what they attest; they'd need to upgrade to L3+ first. Rejection message: "sender KYC level=… cannot originate transfer requiring tier=…".
Only after those two gates pass does the decorator run the structural
checks on payload refs + VASPs. The downstream logic keys off the
KYC-authoritative tier, not payload.tier — i.e. the input field is a
claim to verify, not a source of truth. The
compliance model sets out why the
registered tier, and never the submitted one, is what the chain acts on.
4. Structural requirements by tier
The tier applied (from KYC, per §3 above) dictates which fields must be populated:
| Field | Required at minimal tier | Required at full tier |
|---|---|---|
originator_ref | yes | yes |
beneficiary_ref | yes | yes |
originator_vasp | no | yes |
beneficiary_vasp | no | yes |
Plus the minimal-tier KYC cross-check: originator_ref must equal the
sender's Identity.ivms101_off_chain_ref, and beneficiary_ref must
equal the recipient's. Full-tier refs are per-transaction and
VASP-authored, so the chain accepts per-tx hashes that differ from the
per-person KYC refs — the VASP-id binding is the integrity anchor for
that tier.
TravelRuleFullThreshold lives in x/compliance.Params.Rules and is
governance-managed. The default is 800,000 fen (= HKD 8,000) —
matching the HKMA broader-information line exactly.
The payload's VASP identifiers are FATF-style identifiers for the originating and beneficiary institutions. They are separate from the parties' own addresses; a transfer between two customers of the same VASP still carries two identical VASP entries, so the compliance trail reflects the intermediation relationship.
4a. The EVM path — same validation, different carrier
An HKD transfer originated over JSON-RPC (MetaMask, ethers.js, any
ERC-20-aware tool calling through a dApp) goes through the
hkchain-extended ERC-20 precompile at the issuer denom's token-pair
address (one per hkd.<issuer> denom; read it from the network's
token-pair registry rather than hard-coding it).
The precompile exposes the full IERC20 ABI plus two hkchain methods:
function transferWithTravelRule(address to, uint256 amount, bytes payload) external returns (bool);
function transferFromWithTravelRule(address from, address to, uint256 amount, bytes payload) external returns (bool);
Vanilla transfer(to, amount) and transferFrom(from, to, amount)
are present in the ABI but unconditionally revert with
"use transferWithTravelRule". There is no EVM method on HKD that
moves funds without an attested Travel Rule payload.
When transferWithTravelRule is invoked, the precompile:
- Decodes
payloadas a protobuf-encodedTravelRulePayload. - Synthesizes a
banktypes.MsgSend(from = EVM caller, to = recipient, amount =amountof that issuer denom) and attaches the decoded payload as a tx extension option — the same shape a native Cosmos tx would carry. - Runs
app.ComplianceAnteDecorators(ComplianceKeeper, KycKeeper)— the identical slice used on the cosmos-branch AnteHandler. KycGate, Sanctions, TravelRule (with the KYC cross-check above), Threshold, Velocity, RoleGate all see the EVM transfer on the same terms as a native tx. - On rejection, the EVM call reverts with the decorator's error message; no state changes.
- On pass, delegates the actual balance movement to the upstream
precompile's transfer logic, which routes into
x/bank.
The TravelRuleRegistry anchoring (§5 below) happens identically:
the tx hash used as the registry key is the Cosmos tx hash of the
wrapping MsgEthereumTx, so the EVM call and its payload remain
bound across both the EVM and Cosmos views of block state.
This is dual-path parity — the chain-level enforcement principle made operational: one compliance slice, two entry points, same rules.
5. Registry anchoring on pass
On successful validation, the payload is written to
TravelRuleRegistry[tx_hash] where tx_hash is
hex(sha256(tx_bytes)). The registry is a single record per tx —
over-writing is not part of normal operation.
The registry lets compliance review after the fact reconstruct which IVMS101 record (by hash) was claimed against which transfer (by hash). A reviewer with access to both ends of the VASP channel (originator and beneficiary) can verify that the off-chain PII record hashes to exactly the anchored value — closing the loop between on-chain evidence and off-chain identity.
What the Travel Rule enforcement does not do
Verify the PII contents. The chain holds the hash, not the record. A hash anchored to the right structure is cryptographically verifiable only against the record the VASP holds. The integrity guarantee is "this hash matches this record when the record is produced" — not "this record is true." Identity attestation is a VASP responsibility under the licensing framework; the chain binds to it, it does not replace it.
Enforce cross-VASP channel existence. Whether the two VASPs involved actually have a functioning IVMS101 exchange channel is an off-chain reality. The chain's requirement is satisfied by a well-formed payload — if the beneficiary VASP cannot retrieve the off-chain record, that's a service-level failure between VASPs, not a chain-state question.
MVP: the chain accepts any well-formed payload regardless of the
real-world existence of the claimed IVMS101 channel. finalProduct
may layer a VASP-registry freshness check (is originator_vasp
currently licensed? is the channel currently operational?) as a
follow-up primitive — pending VASP-registry design, out of MVP
scope.
Check identity completeness beyond the referenced fields. The
decorator validates that the referenced fields are non-empty; it
does not enforce that the claimed IVMS101 record has all fields
HKMA expects. Record-level completeness is audited at the VASP
layer, checked by KYC onboarding (x/kyc.MsgEnroll), and
reconciled during regulatory inspection — not re-checked per tx.
Mutate the transferred tokens. Travel Rule is a pre-execution
gate, not a post-execution modifier. A transfer that passes runs
through x/bank unchanged; a transfer that fails produces no
effect.
The audit trail, end to end
A complete HKD transfer produces, in one block:
- The tx itself — inputs, outputs, signatures, fees.
TravelRuleRegistry[tx_hash]— the anchored IVMS101 hashes and tier claim.AuditEntry { tx_type: "transfer", subject: originator, counterparty: beneficiary, amount }— the compliance-log summary of the movement (stamped by theVelocityDecorator).MsgSend's standard transfer events fromx/bank.
For a rejected transfer (missing or mis-tiered payload), no state changes but the rejection is observable through the chain's rejection stream with a decorator-labelled error, sufficient for supervisory review of enforcement behaviour.
Edge cases and what happens
Payload with tx_hash already set
Accepted; the payload's own tx_hash is overwritten with the
derived value before the registry write. This matches the
documented flexibility: the field is optional at submission, and
the chain is the authoritative source of the hash.
Payload with unknown extra fields
Accepted. Proto unknown fields are preserved at the wire level but are not interpreted by the decorator. Clients may extend the payload format (within the proto's forward-compatibility rules) without breaking existing validation.
Multiple transfer messages in one tx
Rejected. A single payload cannot disambiguate which of several transfers it applies to. In practice this pattern is rare — most transfer tx have a single message — and the rejection preserves the one-payload-per-transfer invariant the registry relies on.
Non-transfer tx (governance, flag review, rule update, ...)
The decorator skips non-transfer messages entirely. These tx do not need to carry a payload; requiring one would be zero-threshold expansion into a layer the regulation does not cover.
Self-transfer (originator === beneficiary)
Accepted as long as the payload is well-formed. An address sending HKD to itself still produces a transfer event and still carries Travel Rule information — the information is trivially the same originator and beneficiary reference, which is both accurate and supervisable.
Transfers at exactly the threshold
The threshold check is inclusive: exactly HKD 8,000 requires full tier. A transfer of 7,999.99 HKD is minimal-tier-acceptable; 8,000.00 HKD and above require full.
Missing tier string
A payload with empty tier is rejected with a tier-value error.
The field is a validation witness — omitting it signals "sender
did not commit to a tier," which the chain does not resolve on
the sender's behalf.
Upgrading from minimal to full in a sequence of transfers
Each tx is independent; the payload rides on that tx only. Two consecutive transfers between the same parties can use different tiers if their amounts straddle the threshold. There is no aggregate-tier check at the chain layer — structured transfers designed to evade the threshold are a transaction-monitoring concern handled by the flagging decorators and the reviewer workflow, not by the Travel Rule decorator.
On-chain state summary
| State object | Touched on successful Travel Rule pass |
|---|---|
x/compliance.TravelRuleRegistry[tx_hash] | payload record written |
x/compliance.AuditLog | transfer entry appended (velocity-layer stamp) |
x/bank balances | transfer executes normally |
| Tx acceptance | the tx proceeds to its handler |
On rejection (missing, mis-tiered, or incomplete payload), no state is touched; the tx is rejected at AnteHandler with a Travel-Rule-labelled error.
MVP vs. finalProduct gap
MVP behaviour: the caller — wallet, dApp, VASP
backend — always supplies the full TravelRulePayload explicitly,
both on the native Cosmos path (via the tx extension option) and
on the EVM path (via transferWithTravelRule's bytes payload
argument). The chain cross-checks the supplied payload against
x/kyc: VASP identifiers must match the sender's / recipient's
KYC-registered VASP, and minimal-tier refs must equal the
KYC-registered ivms101_off_chain_ref.
finalProduct roadmap: the minimal-tier payload is entirely
derivable from on-chain state — both VASP identifiers and both
ivms101_off_chain_ref hashes already live in x/kyc.Identity.
A later iteration can drop the caller-provided payload for
tier="minimal" transfers entirely: the decorator (and/or the
precompile on the EVM path) synthesizes the minimal payload from
the KYC registry, anchors it into TravelRuleRegistry, and the
caller only needs to attach a payload for tier="full" (≥ HKD
8,000) — where per-transaction IVMS101 fields (purpose of
transfer, beneficiary account identifier, remittance info) are
authored by the VASP compliance stack and genuinely cannot be
derived from the registry.
Why not in MVP: auto-derivation changes the regulator-facing claim from "caller positively attests to a Travel Rule payload on every transfer" to "chain synthesizes a minimal payload from registry state on behalf of the caller." Both are defensible, but the former is a simpler story to bring to the first HKMA demo, and the cross-check already gives us forgery resistance. Collapse to auto-derive once the supervisory conversation is settled on explicit-attestation semantics.