Broadcasting and confirmation
Hkchain adds no broadcast endpoint of its own. You submit through the standard Cosmos transaction service, and the thing worth getting right is not the call — it is the difference between the two answers you get about the same transaction.
Submitting
curl -s -X POST "$REST/cosmos/tx/v1beta1/txs" \
-H 'Content-Type: application/json' \
-d '{"tx_bytes":"<base64 encoded signed tx>","mode":"BROADCAST_MODE_SYNC"}'
The response carries a txhash, a code, and a raw_log.
| Mode | Returns when | Use it when |
|---|---|---|
BROADCAST_MODE_SYNC | The admission checks have run | Almost always. You learn immediately whether the transaction was accepted into the mempool, and why not if it was not. |
BROADCAST_MODE_ASYNC | The node has the bytes | Fire-and-forget only. You get a hash and no verdict. |
Prefer SYNC. Every compliance rejection surfaces there, in the same round
trip, with a readable reason.
Two verdicts, one hash
This is the part that catches integrations.
code in the broadcast response is the admission verdict, and only for the
node you sent it to. 0 means that node accepted the transaction into its
mempool. Non-zero means that node rejected it, and it will not be gossiped
onward from there.
An admission verdict is not a promise of inclusion. A mempool entry can be evicted — the mempool fills, the entry ages out, the node restarts — and nothing notifies you when that happens. Admission is best read as "not obviously wrong", not as "scheduled".
code on the transaction query is the execution verdict. A transaction can
be admitted and then fail later — an insufficient balance, a paused issuer, a
message-level authority check, or a compliance gate seeing state that changed
since admission. It is in a block and it did not do what you asked.
So a successful broadcast is not a successful transaction. Confirm explicitly:
curl -s "$REST/cosmos/tx/v1beta1/txs/<TXHASH>"
404— not included yet, or never will be. Keep polling; "Polling" below covers the terminal case.- Present,
tx_response.code == 0— done. The state change happened. - Present,
tx_response.code != 0— included and failed.raw_logsays why.
Transaction hashes are returned as uppercase hex. Query with them in that form — the reference web client upper-cases before calling, and it is the form every other surface reports.
Where the compliance gates land
On the Cosmos path the gates run inside the admission phase. A transfer rejected by a gate therefore fails the broadcast in the common case — you see it in the first response, and the transaction never enters a block.
{
"tx_response": {
"txhash": "…",
"codespace": "compliance",
"code": 18,
"raw_log": "recipient=hkchain1…: address is blacklisted"
}
}
code is a module-registered error number, not an HTTP status, and it is only
meaningful together with codespace — code 18 means one thing in compliance
and something else entirely in another module. Match on the pair, or match on
neither and read raw_log. Errors and rejections maps them.
Because rejection happens before the account sequence advances, the sequence you signed at is still valid. Fix the payload, re-sign at the same sequence, and send again — do not re-read the account and skip a number.
The gates also run a second time when the block is executed, against the state
as of that block. Same codespace and code, same raw_log, same untouched
sequence — but this one surfaces on the transaction query, because by then
the transaction is in a block. Handle a compliance codespace in both places
rather than only in the broadcast response.
On the EVM path the gates are somewhere else entirely. An EVM transaction
does not carry its transfer through the Cosmos admission phase; the gates run
while the call executes, so a rejection is a revert, not a broadcast
failure. eth_sendRawTransaction succeeds, the transaction is mined, and
eth_getTransactionReceipt reports status: 0x0. Confirm an EVM-side transfer
by reading the receipt status, never by reading the submission response.
The receipt tells you that it failed, not why. The gate's message is ABI-encoded into the call's revert data, and an Ethereum receipt has no field for revert data — so the receipt is the confirmation source and something else is the diagnosis. Read the reason by making the same call read-only:
curl -s -X POST "$EVM_RPC" -H 'Content-Type: application/json' -d '{
"jsonrpc":"2.0","id":1,"method":"eth_call",
"params":[{"from":"0x<sender>","to":"0x<token>","data":"0x<calldata>"},"latest"]}'
A reverting eth_call answers with a JSON-RPC error rather than a result:
code 3, a message of execution reverted: <the gate's message>, and data
carrying the same reason ABI-encoded.
Run it before broadcasting and it is a pre-flight check — the gates run
identically under eth_call, so a rejection costs neither gas nor a
transaction. Run it after the fact and it is a replay: useful, but it is
evaluated against the state you point it at, so an identity or sanctions change
since the transaction can change the answer.
Polling
Blocks are produced continuously and finality is instant, so a poll interval well under one block time is appropriate — half a second is a reasonable default, and a few seconds of total timeout covers normal inclusion.
Do not treat a timeout as a failure and do not treat it as a pending success. Your deadline expiring tells you nothing about the transaction: it may be waiting in a mempool, or it may have been dropped from one without a trace. Both look identical from outside — a hash that does not resolve.
Three rules for a production poller:
- Poll by hash, not by account state. Balance changes are not attributable to your transaction.
- Give up eventually, but reconcile. If you stop polling, record the hash and reconcile it later against a transaction query or the event stream. Nothing about giving up cancels the transaction.
- Have a terminal path for "dropped". After a reconciliation window of your choosing, treat an unresolved hash as not landed and re-sign — at the same sequence, since an unincluded transaction consumed nothing. Re-broadcasting the identical bytes is also safe: if the original is still in a mempool the duplicate is discarded, and if it already landed the second copy fails on the now-stale sequence rather than paying twice.
Watching instead of polling
For anything beyond a single request-response, subscribe rather than poll. The CometBFT WebSocket delivers transaction results as they are committed, filtered by an event query:
{"jsonrpc":"2.0","method":"subscribe","id":1,
"params":{"query":"tm.event='Tx' AND message.sender='hkchain1…'"}}
Events and indexing covers the query language, the event names, and the reconnection problem you inherit with any subscription.
Finding a transaction you did not submit
The transaction service also searches by event:
# Everything in one block
curl -s "$REST/cosmos/tx/v1beta1/txs?query=tx.height%3D1234&limit=100&order_by=ORDER_BY_ASC"
# Everything an account sent
curl -s "$REST/cosmos/tx/v1beta1/txs?query=message.sender%3D%27hkchain1...%27&limit=50&order_by=ORDER_BY_DESC"
Values in the query are single-quoted; heights are bare. There is no OR
operator — a view that needs several conditions runs several queries and
merges.
One subtlety about addresses in these queries. message.sender carries the
Bech32 form for Cosmos-native activity — and an EVM transaction emits it
twice, once with the hex form of the Ethereum sender and once with the
Bech32 form of the account the transfer debited. Searching message.sender on
the Bech32 form therefore finds a stablecoin transfer whichever path it entered
by. Searching on the hex form finds only the EVM ones:
curl -s "$REST/cosmos/tx/v1beta1/txs?query=message.sender%3D%270x...%27&limit=50"
There is no ethereum_tx.sender attribute. ethereum_tx.recipient exists but
is the contract that was called — for a stablecoin transfer, the token's
precompile address, not the party that received the money. Do not use it to
find payments to an account.
Where to go next
- Events and indexing — reading results and subscribing to them.
- Errors and rejections — every failure mode, and how to tell them apart.