Hkchain

Worked examples

Four end-to-end examples against a running network: reading state, sending and confirming a transaction, inspecting what that transaction did, and doing the same over the EVM interface.

Everything here is a copy-paste command against a live node, and the sequence runs top to bottom in one shell: each block assigns what the later blocks read, so nothing is hard-coded to one network. Nothing on this page is a sketch — the repository ships scripts/docs-examples.sh, which runs the same sequence against a network you point it at and checks each response, so a command that stops being true fails a script rather than quietly misleading a reader.

The pages before this one explain why each step is shaped the way it is — Transaction lifecycle, Building and signing, Broadcasting and confirmation, Events and indexing. This page is the executable counterpart, and does not repeat their reasoning.

Before you start

Three endpoints, and nothing else. Take the URLs from whoever runs the network; the values below are what a node you started yourself listens on.

REST=http://localhost:1317      # Cosmos REST
RPC=http://localhost:26657      # CometBFT RPC
EVM=http://localhost:8545       # Ethereum JSON-RPC

Every example also needs two accounts and a stablecoin denom. Discover them rather than hard-coding them — §1 is exactly that discovery.

No credentials appear on this page, and none should appear in your copy of it. Where an example needs a signing key, it reads one out of a local keyring at the moment of use.

1. Read chain and module state over REST

Start by confirming which chain answered, because every later step commits to it. Keep it in a variable — the signing commands need it, and it differs between networks:

CHAIN_ID=$(curl -s "$REST/cosmos/base/tendermint/v1beta1/node_info" \
  | jq -r '.default_node_info.network')
echo "$CHAIN_ID"
# hkchain_262144-1

Which stablecoins exist, and who issues them. This is the discovery call — it gives you both the denom and the issuer address that later queries need:

curl -s "$REST/hkchain/stablecoin/v1/denoms" | jq -c '.denoms[]'
# {"denom":"hkd.hsbc","display":"HKD-HSBC","issuer":"hkchain1…","decimals":2}
DENOM=$(curl -s "$REST/hkchain/stablecoin/v1/denoms" | jq -r '.denoms[0].denom')
ISSUER=$(curl -s "$REST/hkchain/stablecoin/v1/denoms" | jq -r '.denoms[0].issuer')

The other two values the rest of this page needs are the accounts you will transact between. Any two enrolled addresses will not do: the pair has to clear every gate that can reject, and the queries in this section are how you establish that it does. What the later sections need is

  • both parties enrolled in the KYC registry, and neither one blacklisted;
  • neither party's registered VASP on the sanctions blocklist;
  • the sender registered at a Travel Rule tier that can originate a transfer — minimal or full, not none — and high enough for the amount being sent;
  • the identity fields that tier requires present on both parties;
  • both addresses confirmed primary keys, not delegated sub-keys — and confirmed means the lookup answered, not that it came back blank;
  • a funded sender — this page moves 1,000 fen of the stablecoin (two 500-fen transfers, one per path), sends 0.001 HKC in the native example, and pays gas on three transactions.

On a node you started yourself the addresses come out of its keyring:

SENDER=$(hkchaind keys show -a <sender-key> --keyring-backend test)
RECIPIENT=$(hkchaind keys show -a <recipient-key> --keyring-backend test)

What an account holds. Standard Cosmos bank query; both the native coin and any stablecoin come back in the same list, as integer strings in the base unit:

curl -s "$REST/cosmos/bank/v1beta1/balances/$SENDER" | jq -c '.balances'
# [{"denom":"ahkc","amount":"1000000000000000000"},{"denom":"hkd.hsbc","amount":"100000"}]

Whether an account may transact in stablecoin. The identity record is what the KYC, sanctions and Travel Rule gates read — the advisory flag decorators and the delegated-key gates key on other state, so this is not "what every gate reads". The two fields you will need when building a Travel Rule payload are vasp_identifier and ivms101_off_chain_ref:

SENDER_ID=$(curl -s "$REST/hkchain/kyc/v1/identity/$SENDER" | jq -c '.identity')
RECIPIENT_ID=$(curl -s "$REST/hkchain/kyc/v1/identity/$RECIPIENT" | jq -c '.identity')
echo "$SENDER_ID"
# {"address":"hkchain1…","level":"LEVEL_RETAIL_BASIC","vasp_identifier":"anchorpoint",
#  "ivms101_off_chain_ref":"sha256:…","enrolled_at":"…","last_level_change":"…"}

SENDER_VASP=$(jq   -r '.vasp_identifier'       <<< "$SENDER_ID")
SENDER_REF=$(jq    -r '.ivms101_off_chain_ref' <<< "$SENDER_ID")
RECIPIENT_VASP=$(jq -r '.vasp_identifier'       <<< "$RECIPIENT_ID")
RECIPIENT_REF=$(jq  -r '.ivms101_off_chain_ref' <<< "$RECIPIENT_ID")

An empty .identity here means no identity record exists, which is the most common reason a well-formed stablecoin transfer is rejected. Check both parties before building anything.

Which Travel Rule tier the sender is registered at. Do not assume it. The level-to-tier mapping is a governance parameter, the payload has to claim the sender's registered tier exactly, and a full-tier sender must additionally carry both VASP identifiers. Derive it:

SENDER_LEVEL=$(jq -r '.level' <<< "$SENDER_ID")
SENDER_TIER=$(curl -s "$REST/hkchain/kyc/v1/params" \
  | jq -r --arg l "$SENDER_LEVEL" '.params.level_definitions[] | select(.level==$l) | .travel_rule_tier')
echo "$SENDER_LEVEL -> $SENDER_TIER"
# LEVEL_RETAIL_BASIC -> minimal

On today's default parameters LEVEL_RETAIL_BASIC is minimal and every level above it is full; none means the account cannot originate a stablecoin transfer at all, so stop here and pick a different sender.

The rules currently in force. The Travel Rule threshold is a governance parameter, not a constant — read it rather than hard-coding HKD 8,000:

curl -s "$REST/hkchain/compliance/v1/params" | jq -c '.params.rules'
# {"travel_rule_full_threshold":"800000","velocity_window_seconds":"60","velocity_tx_limit":"10"}

THRESHOLD=$(curl -s "$REST/hkchain/compliance/v1/params" \
  | jq -r '.params.rules.travel_rule_full_threshold')

The threshold is in the stablecoin base unit: 800000 fen is HKD 8,000. It matters to the pair check below, because a sender registered at minimal tier cannot originate an amount at or above it, whatever the payload claims.

The remaining two inputs the pair check needs. The sanctions blocklist — read the VASP identifiers from it, and do not read its address list. Nothing on the runtime path fills that list: MsgUpdateSanctionsList pushes its address entries into the KYC registry instead and rewrites this set with the VASPs alone, so the first sanctions push on a network empties it. A genesis file is the one thing that can seed it, and no gate consults it either way — the address-level check the chain actually performs is on the identity record's level, which the pair check below reads:

BLOCKED_VASPS=$(curl -s "$REST/hkchain/compliance/v1/sanctions" \
  | jq -c '.set.blocked_vasps // []')
echo "$BLOCKED_VASPS"
# []

And whether either address is a delegated sub-key rather than a primary key. This page is written for primary keys: a bound sub-key is evaluated against its parent's identity, so every field read above would describe the wrong record, and an agent sub-key answers to a role allowlist and a spending envelope on top. This query answers 404 for a primary key — and that 404 is the only answer that proves it, so read the status, not just the body:

sub_role_status() {   # `primary` | `bound <parent>` | `unknown: <why>`
  local out http body parent
  out=$(curl -s -w $'\n%{http_code}' "$REST/hkchain/compliance/v1/sub_role_parent/$1")
  http=${out##*$'\n'}; body=${out%$'\n'*}
  case "$http" in
    200) parent=$(jq -r '.parent // empty' <<< "$body" 2>/dev/null)
         if [[ -n $parent ]]; then echo "bound $parent"
         else echo "unknown: 200 carrying no parent field"; fi ;;
    404) if jq -e --arg a "$1" '.code == 5 and .message == "no sub-role binding for \($a)"' <<< "$body" > /dev/null 2>&1
         then echo primary
         else echo "unknown: a 404 from something other than this query"; fi ;;
    *)   echo "unknown: HTTP ${http:-000}" ;;
  esac
}

SENDER_STATUS=$(sub_role_status "$SENDER")
RECIPIENT_STATUS=$(sub_role_status "$RECIPIENT")
echo "$SENDER_STATUS / $RECIPIENT_STATUS"
# primary / primary   → both are primary keys

An empty .parent is not that proof. A connection failure, a proxy or gateway 404, any other error body and malformed JSON all produce one, so a client that reads only the body cannot tell "unbound" from "never answered". Anything but primary blocks below, including unknown.

Nor is "a 404 whose body mentions the address". The handler's own answer is this exact message:

{"code":5,"message":"no sub-role binding for hkchain1…","details":[]}

Code 5 is what gRPC uses for any NotFound, and a proxy or load balancer in front of the node routinely echoes the requested path in its own 404 — a path that ends in the address you just queried:

{"code":5,"message":"Not Found: /hkchain/compliance/v1/sub_role_parent/hkchain1…"}

Both carry code 5 and both mention the address, so matching on either alone turns "nothing answered this query" into "this address is a primary key" and signs on it. Match the whole message.

Whether the pair clears every gate that can reject. Five of the seven gates a transfer passes through can refuse it, and every one of them can refuse a pair whose records look complete. A blacklisted address keeps its VASP and IVMS101 ref — blacklisting changes only the level — so populated fields are not evidence of anything. Enrollment stores whatever the provider supplied, and the governance path that whitelists an issuer creates a level-5 record — full tier — with both fields empty if the address had no identity before. Check the pair against all five rather than assuming:

jq -n --arg tier "$SENDER_TIER" --argjson s "$SENDER_ID" --argjson r "$RECIPIENT_ID" \
      --argjson blocked "$BLOCKED_VASPS" --arg threshold "$THRESHOLD" --arg amount 500 \
      --arg sstatus "$SENDER_STATUS" --arg rstatus "$RECIPIENT_STATUS" '
  def vasp: .vasp_identifier // "";
  def ref:  .ivms101_off_chain_ref // "";
  def sanctioned($v): $v != "" and ($blocked | any(. == $v));
  [ # KYC gate — a blacklisted party is rejected whatever else it carries.
    if ($s.level // "") == "LEVEL_BLACKLISTED" then "sender is blacklisted" else empty end,
    if ($r.level // "") == "LEVEL_BLACKLISTED" then "recipient is blacklisted" else empty end,
    # Sanctions gate — the registered VASP of either party, exact match.
    if sanctioned($s|vasp) then "sender VASP \($s|vasp) is on the blocklist" else empty end,
    if sanctioned($r|vasp) then "recipient VASP \($r|vasp) is on the blocklist" else empty end,
    # Travel Rule, structure — refs always, both VASPs at `full`.
    if ($s|ref) == "" then "sender has no ivms101_off_chain_ref" else empty end,
    if ($r|ref) == "" then "recipient has no ivms101_off_chain_ref" else empty end,
    if $tier == "full" and ($s|vasp) == "" then "full-tier sender has no vasp_identifier" else empty end,
    if $tier == "full" and ($r|vasp) == "" then "full-tier recipient has no vasp_identifier" else empty end,
    # Travel Rule, capability — the amount may demand a tier the sender lacks.
    if $tier != "full" and ($amount|tonumber) >= ($threshold|tonumber)
      then "\($tier)-tier sender cannot move \($amount) fen at or above the full-tier threshold \($threshold)" else empty end,
    # Role gate and agent policy — out of scope for this page, not a defect.
    # A lookup that did not answer blocks here too: it established nothing.
    if $sstatus != "primary" then "sender is not a confirmed primary key (\($sstatus))" else empty end,
    if $rstatus != "primary" then "recipient is not a confirmed primary key (\($rstatus))" else empty end ]'
# []      → the pair clears every blocking gate

Anything in that list is a rejection waiting to happen — from a gate, not from the registry, which is why none of it shows up as a malformed identity record. Together with the balance query above — the sender needs at least 1,000 fen of $DENOM and enough ahkc for gas — that is the complete prerequisite for everything below. The two advisory gates are deliberately absent from it: the large-transfer flag and the velocity check record a finding and let the transfer through, so neither can turn a usable pair into an unusable one.

What backs the circulating supply. Reserve attestations are keyed by issuer address, not by denom:

curl -s "$REST/hkchain/reserve/v1/attestations/$ISSUER/latest" | jq -c '.attestation | {id, state, circulation, reserve_market_value, random_day}'
# {"id":"1","state":"ATTESTATION_STATE_ATTESTED","circulation":{"denom":"hkd.hsbc","amount":"200000000"},
#  "reserve_market_value":{"denom":"hkd","amount":"210000000"},"random_day":"2025-12-15T00:00:00Z"}

What one holder's balance is backed by — the public lookup any holder can run for themselves, joining a balance to the attestation covering it:

curl -s "$REST/hkchain/reserve/v1/reserve_for_address/$SENDER" | jq -c '.bindings[] | {denom, balance, state: .attestation.state}'

The complete inventory of query paths is in the API Reference; each module page lists its own.

2. Send a transaction, and confirm it

Two flavours, and the difference matters: a native transfer carries no compliance obligation and needs nothing beyond a standard Cosmos transaction, while a stablecoin transfer sent as a MsgSend — the supported shape — must carry a Travel Rule payload as a transaction extension option or the gate chain rejects it.

"Sent as a MsgSend" is doing real work in that sentence. The gates project a compliance fact from exactly three message shapes, and a message that moves an hkd.* balance in some other shape produces no fact for them to act on; Transaction lifecycle sets out the recognised set and what follows from it. Everything on this page uses the supported shapes.

2a. A native transfer, with the bundled CLI

hkchaind is a client as well as a node, so the whole build-sign-broadcast cycle is one command. Gas is paid in ahkc at a flat 1 gwei floor:

NATIVE_BROADCAST=$(hkchaind tx bank send "$SENDER" "$RECIPIENT" 1000000000000000ahkc \
  --chain-id "$CHAIN_ID" --node "$RPC" \
  --gas 200000 --gas-prices 1000000000ahkc \
  --yes --output json)
jq -c '{code, codespace, txhash, raw_log}' <<< "$NATIVE_BROADCAST"
# {"code":0,"codespace":"","txhash":"8A48…64F","raw_log":""}

Keep the whole response rather than reaching straight for .txhash. A synchronous broadcast returns a hash whether or not the node accepted the transaction — the hash is derived from the bytes you submitted, so a rejected transaction has one too, and that one will never be indexed. The code field is the admission verdict, and code: 0 means only that the node accepted the transaction: it has not been executed yet. Broadcasting and confirmation sets out both verdicts.

So check admission before polling — polling a hash that was never queued is a loop that can only time out:

NATIVE_TXHASH=$(jq -r '.txhash' <<< "$NATIVE_BROADCAST")
if [ "$(jq -r '.code' <<< "$NATIVE_BROADCAST")" != 0 ]; then
  echo "not queued: $(jq -r '.raw_log' <<< "$NATIVE_BROADCAST")"
else
  for _ in $(seq 20); do
    NATIVE_RESP=$(curl -s "$REST/cosmos/tx/v1beta1/txs/$NATIVE_TXHASH")
    jq -e '.tx_response.code != null' <<< "$NATIVE_RESP" > /dev/null && break
    sleep 1
  done
  jq -c '{code: .tx_response.code, height: .tx_response.height}' <<< "$NATIVE_RESP"
fi
# {"code":0,"height":"14"}

Expect the first iteration or two to come back without a tx_response: the transaction is not queryable until the block containing it is committed and indexed. That is normal, and it is why Broadcasting and confirmation treats "accepted" and "executed" as two separate verdicts rather than one.

Waiting here is not only for your own benefit. The account sequence advances when the transaction is committed, so a second transaction submitted from the same account before the first lands is signed against a sequence the chain has already moved past, and is rejected for sequence mismatch. Either wait, or track the sequence yourself.

2b. A stablecoin transfer, with a Travel Rule payload

The payload is not a message field — it is a transaction extension option, so the generic CLI cannot attach one and neither can a generic Cosmos client without extension-option support. What it must contain comes straight from the two identity records you read in §1:

Payload fieldWhere its value comes from
originator_refthe sender's ivms101_off_chain_ref
beneficiary_refthe recipient's ivms101_off_chain_ref
originator_vaspthe sender's vasp_identifier (required at full tier)
beneficiary_vaspthe recipient's vasp_identifier (required at full tier)
tierthe sender's registered tier — a claim the chain verifies, not a choice

The signing key is read from a keyring at the moment of use and never stored in a file that ships anywhere. Against a local test node:

SENDER_PRIVKEY=$(yes | hkchaind keys export <sender-key> \
  --unarmored-hex --unsafe --keyring-backend test 2>/dev/null)

The repository ships a reference implementation of the whole cycle — scripts/agent-send, which builds the message, attaches the payload, signs with an eth_secp256k1 key and broadcasts. Note --parent-tier: it takes the tier derived in §1, not a fixed string.

HKD_TXHASH=$(go run ./scripts/agent-send \
  --chain-url "$REST" --chain-id "$CHAIN_ID" \
  --from "$SENDER" --from-priv "0x$SENDER_PRIVKEY" --to "$RECIPIENT" \
  --amount-fen 500 --denom "$DENOM" \
  --parent-vasp "$SENDER_VASP"   --parent-ref "$SENDER_REF"   --parent-tier "$SENDER_TIER" \
  --payee-vasp  "$RECIPIENT_VASP" --payee-ref  "$RECIPIENT_REF" \
  --wait-for-tx 15s | awk '/^OK /{print $2}')
echo "$HKD_TXHASH"
# BF74…114

Both VASP identifiers are passed unconditionally. At minimal tier the chain only checks them when they are present; at full tier it requires them, so sending them always is the shape that works for any sender.

Read the command as a worked example of the envelope described in Building and signing rather than as the only way to do this — any client that can attach an extension option and sign with the chain's key type will do.

That transaction and the native one in §2a leave two different hashes, and §3 inspects the stablecoin one. Keep them apart: only $HKD_TXHASH has a Travel Rule payload, an audit entry, and a compliance fact behind its trace.

If the transfer is rejected, the failure names the gate and the reason — see Errors and rejections, and use the pre-flight in Getting started to find out before broadcasting.

3. Inspect what a transaction did

Its events. A stablecoin transfer emits several transfer events — the fee payment is one of them — so filter by denom rather than taking the first:

curl -s "$REST/cosmos/tx/v1beta1/txs/$HKD_TXHASH" \
  | jq -c '[.tx_response.events[] | select(.type=="transfer")] | length'

Find it again by event. Attributes are indexed, so a transaction is searchable by the parties involved without your own index:

curl -s -G "$REST/cosmos/tx/v1beta1/txs" \
  --data-urlencode "query=transfer.recipient='$RECIPIENT'" \
  --data-urlencode "limit=5" \
  --data-urlencode "order_by=ORDER_BY_DESC" | jq -c '{total, hashes: [.tx_responses[].txhash]}'
# {"total":"7","hashes":["BF74…114", …]}

order_by is worth setting explicitly. The default order is oldest first, so on an account with any history the first page is the beginning of time, not the transaction you just sent — a paging bug that only shows up once real traffic exists.

Block-level events belong to no transaction and are not reachable this way; Events and indexing covers where to recover those instead.

Which gates ran, and what they decided:

curl -s "$REST/hkchain/compliance/v1/tx_trace/$HKD_TXHASH" \
  | jq -c '{accepted: .trace.accepted, gates: [.trace.results[] | {name, status}]}'
# {"accepted":true,"gates":[{"name":"kyc_gate","status":"DECORATOR_RESULT_PASS"}, … 7 rows]}

Run the same query against $NATIVE_TXHASH and all seven gates report PASS there too — but there they pass because there was nothing to inspect. A gate acts on a compliance fact, and a fact is only projected from an hkd.* amount in one of the three recognised message shapes; a native ahkc transfer produces none. Read a seven-row PASS trace as "nothing blocked this", not as "seven checks examined this" — the same caution applies to any message outside the recognised set.

The trace lives in a per-node ring buffer with a bounded size and lifetime, so treat it as diagnostics. The durable records are the next two.

The audit log entry — append-only, and the compliance-facing record of the transfer. Filter by tx_type rather than assuming one row per hash:

curl -s -G "$REST/hkchain/compliance/v1/audit_log" \
  --data-urlencode "tx_hash=$HKD_TXHASH" --data-urlencode "tx_type=transfer" \
  | jq -c '.entries[]'
# {"sequence":"1","block_height":"11","tx_type":"transfer","subject":"hkchain1…",
#  "counterparty":"hkchain1…","amount":{"denom":"hkd.hsbc","amount":"500"},"tx_hash":"bf74…114"}

One transfer writes one transfer row, but the advisory gates append their own rows under the same hash when they fire: a threshold_flag row once the amount reaches the full-tier threshold, and a velocity_flag row once the sender's transfer count inside the velocity window reaches the limit (both parameters are in /hkchain/compliance/v1/params). Drop the tx_type filter to see them — each carries the flag_id that joins it to /hkchain/compliance/v1/flags.

The anchored Travel Rule payload, keyed by transaction hash:

curl -s "$REST/hkchain/compliance/v1/travel_rule/$(echo "$HKD_TXHASH" | tr 'A-Z' 'a-z')" | jq -c '.payload'
# {"tx_hash":"bf74…114","originator_ref":"sha256:…","beneficiary_ref":"sha256:…",
#  "originator_vasp":"anchorpoint","beneficiary_vasp":"anchorpoint","tier":"minimal"}

That tr 'A-Z' 'a-z' is load-bearing, and it is the one hash-handling trap on this chain. Cosmos returns a transaction hash in upper-case hex; the chain stores compliance records under the lower-case form. The trace, audit-log and flag queries normalise the hash for you, so either case works. The Travel Rule registry query does not: pass it an upper-case hash and it returns 404 NotFound for a transaction whose payload is anchored perfectly well. Normalise transaction hashes to one case at the edge of your integration and the question never arises again.

4. Read and transfer HKD over the EVM interface

The same balances and the same transfer, reached over Ethereum JSON-RPC. The reads, the pre-flights and the transfer itself go to the EVM endpoint; two supporting calls deliberately do not — the token address comes from the Cosmos REST token-pair registry, and the parity check at the end queries both sides on purpose. hkchaind appears only for offline address conversion and to read the signing key out of the keyring.

Confirm the chain, and find the token address. Never hard-code the address: read it from the network's own token-pair registry, keyed by denom.

curl -s -X POST "$EVM" -H 'Content-Type: application/json' \
  -d '{"jsonrpc":"2.0","id":1,"method":"eth_chainId","params":[]}' | jq -r '.result'
# 0x40000

TOKEN=$(curl -s "$REST/cosmos/evm/erc20/v1/token_pairs" \
  | jq -r --arg d "$DENOM" '.token_pairs[] | select(.denom==$d) | .erc20_address')
# 0x0000000000000000000000000000000000484b44

Set up a caller and an encoder. An account has one key and two address encodings; the EVM side wants the 0x form, and hkchaind converts offline:

SENDER_HEX=$(hkchaind debug addr "$SENDER"    | awk '/Address hex/{print $3}')
RECIPIENT_HEX=$(hkchaind debug addr "$RECIPIENT" | awk '/Address hex/{print $3}')

ethcall() {  # $1 = calldata
  curl -s -X POST "$EVM" -H 'Content-Type: application/json' \
    -d "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"eth_call\",\"params\":[{\"from\":\"$SENDER_HEX\",\"to\":\"$TOKEN\",\"data\":\"$1\"},\"latest\"]}"
}
pad32()  { printf '%064s' "${1#0x}" | tr ' ' '0'; }                                  # one ABI word
padr32() { local h="${1#0x}"; printf '%-*s' "$(( (${#h} + 63) / 64 * 64 ))" "$h" | tr ' ' '0'; }

Read it as an ordinary ERC-20. Standard selectors — decimals() is 0x313ce567, balanceOf(address) is 0x70a08231:

ethcall 0x313ce567 | jq -r '.result'
# 0x0000000000000000000000000000000000000000000000000000000000000002   → 2 decimals

ethcall "0x70a08231$(pad32 "$SENDER_HEX")" | jq -r '.result'

Read at the same moment, that number equals what the bank query returns — so compare them rather than comparing against a figure printed earlier on this page, which §2b's transfer has since moved:

printf '%d\n' "$(ethcall "0x70a08231$(pad32 "$SENDER_HEX")" | jq -r '.result')"
curl -s "$REST/cosmos/bank/v1beta1/balances/$SENDER" \
  | jq -r --arg d "$DENOM" '.balances[] | select(.denom==$d) | .amount'
# 99500
# 99500

The two paths are one ledger rather than two views kept in sync: a Cosmos transfer moves the number an ERC-20 balanceOf reports, with nothing bridging between them.

The vanilla transfer is deliberately unavailable. Calling it is the quickest way to see the parity rule enforced rather than described:

ethcall "0xa9059cbb$(pad32 "$RECIPIENT_HEX")$(pad32 1f4)" | jq -r '.error.message'
# execution reverted: HKCHAIN: vanilla transfer disabled — use transferWithTravelRule

The supported method carries the payload. transferWithTravelRule(address,uint256,bytes) has selector 0x23ec66fc; its third argument is the protobuf-serialised TravelRulePayload — the same message as the Cosmos extension option, not JSON. The repository's scripts/tr-payload prints the encoded bytes for a given set of fields:

PAYLOAD=$(go run ./scripts/tr-payload \
  --originator-ref "$SENDER_REF"   --originator-vasp "$SENDER_VASP" \
  --beneficiary-ref "$RECIPIENT_REF" --beneficiary-vasp "$RECIPIENT_VASP" \
  --tier "$SENDER_TIER")
# 0x1217736861323536…

Same rule as the Cosmos path: the tier is the sender's registered one from §1, and both VASP identifiers ride along so a full-tier sender is not rejected for an incomplete payload.

The argument is a dynamic bytes, so the calldata is the selector, then one word each for to, amount and the offset to the tail (0x60), then the payload's byte length and the payload itself right-padded to a whole word:

calldata() {  # $1 = payload hex
  local pl="${1#0x}"
  printf '0x23ec66fc%s%s%s%s%s' \
    "$(pad32 "$RECIPIENT_HEX")" "$(pad32 1f4)" "$(pad32 60)" \
    "$(pad32 "$(printf '%x' $(( ${#pl} / 2 )))")" "$(padr32 "$pl")"
}

Because eth_call executes the method without committing anything, it is also the EVM pre-flight: it runs the full gate chain and hands back either the return value or the gate's reason.

ethcall "$(calldata "$PAYLOAD")" | jq -r '.result // .error.message'
# 0x000…001    → the transfer would succeed

Claim a tier the sender is not registered at, and the same call reports exactly why — the gate's own message, before you have spent anything:

WRONG_TIER=$([ "$SENDER_TIER" = "minimal" ] && echo full || echo minimal)
BAD=$(go run ./scripts/tr-payload \
  --originator-ref "$SENDER_REF"   --originator-vasp "$SENDER_VASP" \
  --beneficiary-ref "$RECIPIENT_REF" --beneficiary-vasp "$RECIPIENT_VASP" \
  --tier "$WRONG_TIER")
ethcall "$(calldata "$BAD")" | jq -r '.error.message'
# execution reverted: payload tier="full" does not match sender's
# KYC-registered tier="minimal" (level=LEVEL_RETAIL_BASIC): travel rule
# requirements not met

Now actually move the funds. The same calldata, signed and broadcast as an Ethereum transaction. scripts/evm-send does the signing — it takes pre-encoded calldata, so what goes on the wire is exactly what the eth_call above pre-flighted:

EVM_TXHASH=$(go run ./scripts/evm-send --evm-url "$EVM" \
  --priv "0x$SENDER_PRIVKEY" --to "$TOKEN" --data "$(calldata "$PAYLOAD")" \
  --wait 20s | awk '{print $2}')
echo "$EVM_TXHASH"
# 0xcf15…5385

The private key is the one you exported in §2b. An account has one key and two address encodings, so the same secret signs on both paths — this is the clearest demonstration on the page that there is one account, not two.

Confirm with the receipt, which is where an EVM transaction's verdict lives. status: 0x1 succeeded; 0x0 means the transaction was mined and reverted, and you still paid for the gas it burned:

curl -s -X POST "$EVM" -H 'Content-Type: application/json' \
  -d "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"eth_getTransactionReceipt\",\"params\":[\"$EVM_TXHASH\"]}" \
  | jq -c '.result | {status, blockNumber, logs: (.logs | length)}'
# {"status":"0x1","blockNumber":"0x1f","logs":1}

And prove it moved. The balance is 500 lower than it was before the send:

printf '%d\n' "$(ethcall "0x70a08231$(pad32 "$SENDER_HEX")" | jq -r '.result')"
# 99000

A failed receipt carries no reason — recovering that means replaying the call as an eth_call, as Broadcasting and confirmation describes, which is the same pre-flight used above.

The full ABI, including the argument layout for the calldata above, is in Extended ERC-20 interface.

Running these against your own network

./scripts/docs-examples.sh

It defaults to a local node, takes REST / RPC / EVM overrides for any other network, and reports a per-example PASS/FAIL plus anything it had to skip. It discovers its own accounts and derives the sender's Travel Rule tier rather than assuming one, so a full-tier sender is handled as well as a minimal one — but it checks the same prerequisite §1 sets out, on both parties, and skips the signing examples when an overridden pair does not meet it rather than reporting a failure the network is not responsible for. It broadcasts the same three transactions this page does, two Cosmos and one EVM, so point it at a network where that is fine. Read its header comment for what it needs on the network it runs against.

The one check that cannot be exercised against a healthy network is the §1 sub-role lookup misreading a 404 that never came from the handler, so it has an offline companion that needs neither a node nor keys:

./scripts/docs-examples-selftest.sh

It extracts the sub_role_status function from this page and from the harness — the published text, not a copy of it — and runs both against canned proxy, gateway and transport failures to confirm that none of them can be read as primary.