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 —
minimalorfull, notnone— 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 field | Where its value comes from |
|---|---|
originator_ref | the sender's ivms101_off_chain_ref |
beneficiary_ref | the recipient's ivms101_off_chain_ref |
originator_vasp | the sender's vasp_identifier (required at full tier) |
beneficiary_vasp | the recipient's vasp_identifier (required at full tier) |
tier | the 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.