Building and signing a transaction
An Hkchain transaction is a standard Cosmos transaction. Nothing about the envelope, the sign modes, or the signature algorithm is special. What is specific to this chain is one extension option that a stablecoin transfer must carry, and the rules that decide what goes in it.
The envelope
| Part | Value |
|---|---|
| Messages | Standard Cosmos messages, including cosmos.bank.v1beta1.MsgSend, plus the Hkchain module messages |
| Sign mode | SIGN_MODE_DIRECT |
| Signature | eth_secp256k1 over the canonical sign bytes |
| Fee | Paid in ahkc, never in a stablecoin |
| Extension options | The Travel Rule payload, for a stablecoin transfer |
Extension options are fail-closed
The chain accepts exactly two extension options: the Travel Rule payload
(/hkchain.compliance.v1.TravelRulePayload) and the Cosmos EVM dynamic-fee
option. Any other extension option is rejected before the transaction is
examined at all.
That is a deliberate posture, and it has one trap: if the first extension option is the Ethereum-transaction option, the transaction is routed down the EVM path instead of the Cosmos path. Do not mix the two.
The Travel Rule payload
Every stablecoin transfer carries originator and beneficiary information. The payload is a protobuf message attached as an extension option — not a memo, not a message field:
| Field | Contents |
|---|---|
originator_ref | Hash of the originator's identity record, held off chain |
beneficiary_ref | Hash of the beneficiary's identity record |
originator_vasp | The originating VASP identifier |
beneficiary_vasp | The beneficiary VASP identifier |
tier | "minimal" or "full" |
tx_hash | Leave empty. The chain derives it when the payload arrives as an extension option; the field exists for the stored form. |
No personal data goes in it. The refs are hashes of records that stay with the institutions that hold them.
Choosing the tier
This is where most first attempts fail, because two independent rules apply and they check different things.
Rule 1 — the claim must be true. tier must equal the tier registered for
the sender's identity level: minimal for retail-basic, full for
retail-enhanced, institutional and issuer accounts. You do not choose the tier
based on the transfer; you state the one your identity level already has. An
account cannot claim a tier it was not granted.
Rule 2 — the tier must be sufficient for the amount. At or above the
enhanced-data threshold, full is required. A retail-basic account therefore
cannot make a large transfer at all — it cannot lower the requirement, and it
cannot raise its own tier by asserting one. Read the threshold from
$REST/hkchain/compliance/v1/params rather than hard-coding it; the default
is the HKD 8,000 line, expressed like every other amount in the base unit —
800000 fen.
Rule 3 — the fields must be complete and match the registry. Both refs are always required. Beyond that, the checks key on the sender's registered tier, not on what the payload claimed:
- Registered
full: both VASP identifiers must be present. - Registered
minimal: both refs must equal the off-chain references recorded in the identity registry for the two parties. At full tier the refs are per-transfer and VASP-authored, so the chain does not pin them. - Either tier: a VASP identifier that is populated must match the one the corresponding identity record carries.
For a transfer signed by a delegated key, all three rules are evaluated against the parent. The payload describes the account that delegated, not the key that signed.
Assembling it
The sequence, in whichever Cosmos client library you use:
- Build the message.
- Set memo, gas limit and fee amount.
- Encode the Travel Rule payload into an
Anyand set it as an extension option. The transaction builder has to be asked for its extension-options interface; this is the one step a plain builder does not expose. - Set a placeholder signature — public key, sign mode, sequence, empty signature bytes.
- Compute the canonical sign bytes for
SIGN_MODE_DIRECTwith the signer data (address, chain ID, account number, sequence, public key). - Sign those bytes and set the real signature.
- Encode the transaction.
Step 4 is not optional and not a formality. The sign bytes are computed over the transaction as it will be broadcast, so the signature slot has to already exist with the right shape when they are computed. Skipping it produces a transaction that is well-formed and fails verification.
In Go, using the Cosmos SDK directly:
builder := txConfig.NewTxBuilder()
builder.SetMsgs(banktypes.NewMsgSend(from, to, sdk.NewCoins(coin)))
builder.SetGasLimit(gasLimit)
builder.SetFeeAmount(sdk.NewCoins(sdk.NewCoin("ahkc", feeAmount)))
// Travel Rule payload as an extension option.
extAny, err := codectypes.NewAnyWithValue(&payload)
builder.(authtx.ExtensionOptionsTxBuilder).SetExtensionOptions(extAny)
// Pass 1 — placeholder, so the sign bytes cover the final wire shape.
sig := signing.SignatureV2{
PubKey: priv.PubKey(),
Data: &signing.SingleSignatureData{SignMode: signing.SignMode_SIGN_MODE_DIRECT},
Sequence: sequence,
}
builder.SetSignatures(sig)
// Pass 2 — real signature.
signBytes, err := authsigning.GetSignBytesAdapter(
ctx, txConfig.SignModeHandler(),
signing.SignMode_SIGN_MODE_DIRECT, signerData, builder.GetTx(),
)
signature, err := priv.Sign(signBytes)
sig.Data = &signing.SingleSignatureData{
SignMode: signing.SignMode_SIGN_MODE_DIRECT,
Signature: signature,
}
builder.SetSignatures(sig)
txBytes, err := txConfig.TxEncoder()(builder.GetTx())
Your encoding configuration must have the Hkchain message types registered, or
their type URLs will not resolve on the wire. Each module exposes the usual
RegisterInterfaces for that.
Fees
The chain runs a flat fee model — no base-fee auction, no priority ladder. A fee is acceptable if it clears the network's minimum gas price. Read the current figure and multiply:
curl -s "$REST/cosmos/evm/feemarket/v1/params" # → params.base_fee, in ahkc per gas
A working rule is fee = 2 × base_fee × gas_limit, which absorbs any movement
between reading the parameter and the transaction landing. The local default
floor is 1 gwei of ahkc per gas.
Gas limits are ordinary; a stablecoin transfer with a payload runs comfortably within a few hundred thousand.
Signing on the EVM path
If you are sending from Ethereum tooling instead, you are not building a Cosmos transaction at all — you are calling a contract method. The payload becomes the third argument:
transferWithTravelRule(address to, uint256 amount, bytes travelRulePayload)
travelRulePayload is the same protobuf message, serialised. The chain
unmarshals it and applies the identical three rules. Note that to is the hex
address form and amount is in the stablecoin's base unit, exactly as on the
Cosmos side.
The plain transfer and transferFrom revert. That is the enforcement, not an
omission — see the API Reference.
Where to go next
- Broadcasting and confirmation — submitting what you just built.
- Attach a Travel Rule payload — the same payload from the protocol side, with worked values.
- Errors and rejections — what each of the three rules looks like when it fails.