CKB RPC treats tx_status as meaningful even when transaction is null. CCC currently drops those responses in JsonRpcTransformers.transactionResponseTo, so Client.getTransaction can fall back to a cached sent response after the node has already reported a terminal rejected status.
This makes confirmation waiters miss terminal failures such as Resolve failed Dead(OutPoint(...)) or RBFRejected(...) and eventually time out instead.
CKB RPC behavior
CKB documents and implements this shape intentionally:
verbosity=1: transaction must be null.
verbosity=2: transaction is returned only when tx_status.status is pending, proposed, or committed; otherwise transaction is null.
- A rejected transaction is represented by
tx_status.status = "rejected" plus tx_status.reason, with transaction = null.
Source evidence:
CCC behavior
CCC currently treats transaction == null as if the RPC response has no usable status:
So this sequence can happen:
sendTransaction(tx) succeeds and caches tx as sent.
- CKB later reports
get_transaction(txHash) as tx_status.status = "rejected", tx_status.reason = ..., transaction = null.
- CCC transforms that RPC response to
undefined.
Client.getTransaction(txHash) falls back to the cached sent response.
- A caller polling for confirmation continues to see a pending-like status instead of the terminal rejection.
Public testnet examples
These public testnet hashes currently show the CKB RPC shape:
{"hash":"0x97ef4b066dcd82a94e2a77b9c0d57027393b40ad6ef81892ad651c51de2f9a78","status":"rejected","reason":"{\"type\":\"Resolve\",\"description\":\"Resolve failed Dead(OutPoint(0xb9d8f14129f32c65029ec84b6f9e315d1e5fccf430b2adc0016a2031fc5f439d00000000))\"}","transaction":null}
{"hash":"0x5dfa0e353214b083f7fe54b1fb774e65f21f60a1fdf548efc0e3664e8a859f9c","status":"rejected","reason":"{\"type\":\"RBFRejected\",\"description\":\"RBF rejected: replaced by tx Byte32(0x1872347f07644f061a48de77559b5abf60049414b5f18083368cd2a29c47e2a2)\"}","transaction":null}
The same RPC with verbosity = "0x2" returns transaction content for a committed tx, but not for rejected txs:
{"hash":"0x5d7c726635abff46908b119ef9b82af8e04418827fbfbb8fe49fded705636352","status":"committed","transaction":"present"}
Expected behavior
CCC should preserve terminal tx_status and reason from CKB RPC even when the transaction body is absent.
At minimum, callers should have a CCC API that can distinguish:
- RPC returned no useful record /
unknown.
- RPC returned a status-only terminal record, such as
rejected with a reason.
Possible fixes
I am not sure which API shape best fits CCC's public types, but these seem plausible:
- Change
ClientTransactionResponse or its transformer to support status-only responses.
- Add a status-only method, for example
getTransactionStatus, backed by get_transaction verbosity 1.
- Make
getTransaction avoid cache fallback when the fresh RPC response contains a status-only rejection.
The key invariant is that transaction: null should not cause CCC to discard tx_status.
Impact
Applications that call sendTransaction and then poll getTransaction can miss terminal chain or pool rejections. In our live iCKB supervisor runs, this surfaced as confirmation timeouts even though public CKB RPC already reported rejected with either Resolve failed Dead(OutPoint(...)) or RBFRejected(...).
Keep up the Great Work,
Phroi %37
CKB RPC treats
tx_statusas meaningful even whentransactionisnull. CCC currently drops those responses inJsonRpcTransformers.transactionResponseTo, soClient.getTransactioncan fall back to a cachedsentresponse after the node has already reported a terminalrejectedstatus.This makes confirmation waiters miss terminal failures such as
Resolve failed Dead(OutPoint(...))orRBFRejected(...)and eventually time out instead.CKB RPC behavior
CKB documents and implements this shape intentionally:
verbosity=1:transactionmust benull.verbosity=2:transactionis returned only whentx_status.statusispending,proposed, orcommitted; otherwisetransactionisnull.tx_status.status = "rejected"plustx_status.reason, withtransaction = null.Source evidence:
get_transactiondocs in code: https://github.com/nervosnetwork/ckb/blob/8f6cacf976899b324b2d23a24e5018b54f3502ec/rpc/src/module/chain.rs#L553-L556TransactionWithStatus::with_rejectedsetstransaction: None: https://github.com/nervosnetwork/ckb/blob/8f6cacf976899b324b2d23a24e5018b54f3502ec/util/types/src/core/tx_pool.rs#L234-L243with_rejected(record)fromrecent_reject_db: https://github.com/nervosnetwork/ckb/blob/8f6cacf976899b324b2d23a24e5018b54f3502ec/tx-pool/src/service.rs#L931-L934transaction.is_none()andStatus::Rejectedeven with verbosity 2: https://github.com/nervosnetwork/ckb/blob/8f6cacf976899b324b2d23a24e5018b54f3502ec/test/src/specs/tx_pool/different_txs_with_same_input.rs#L74-L85CCC behavior
CCC currently treats
transaction == nullas if the RPC response has no usable status:getTransactionNoCachecallsget_transactionand passes throughJsonRpcTransformers.transactionResponseTo: https://github.com/ckb-devrel/ccc/blob/94a873e28d47685a6a875e40e0200a329a4f8a5e/packages/core/src/client/jsonRpc/client.ts#L271-L275transactionResponseToreturnsundefinedwhentransaction == null: https://github.com/ckb-devrel/ccc/blob/94a873e28d47685a6a875e40e0200a329a4f8a5e/packages/core/src/client/jsonRpc/transformers.ts#L181-L198Client.getTransactionfalls back to the local cache when no-cache returnsundefined: https://github.com/ckb-devrel/ccc/blob/94a873e28d47685a6a875e40e0200a329a4f8a5e/packages/core/src/client/client.ts#L613-L623sendTransactionmarks a sent transaction in cache before returning: https://github.com/ckb-devrel/ccc/blob/94a873e28d47685a6a875e40e0200a329a4f8a5e/packages/core/src/client/client.ts#L594-L610sent: https://github.com/ckb-devrel/ccc/blob/94a873e28d47685a6a875e40e0200a329a4f8a5e/packages/core/src/client/cache/cache.ts#L38-L47So this sequence can happen:
sendTransaction(tx)succeeds and cachestxassent.get_transaction(txHash)astx_status.status = "rejected",tx_status.reason = ...,transaction = null.undefined.Client.getTransaction(txHash)falls back to the cachedsentresponse.Public testnet examples
These public testnet hashes currently show the CKB RPC shape:
{"hash":"0x97ef4b066dcd82a94e2a77b9c0d57027393b40ad6ef81892ad651c51de2f9a78","status":"rejected","reason":"{\"type\":\"Resolve\",\"description\":\"Resolve failed Dead(OutPoint(0xb9d8f14129f32c65029ec84b6f9e315d1e5fccf430b2adc0016a2031fc5f439d00000000))\"}","transaction":null} {"hash":"0x5dfa0e353214b083f7fe54b1fb774e65f21f60a1fdf548efc0e3664e8a859f9c","status":"rejected","reason":"{\"type\":\"RBFRejected\",\"description\":\"RBF rejected: replaced by tx Byte32(0x1872347f07644f061a48de77559b5abf60049414b5f18083368cd2a29c47e2a2)\"}","transaction":null}The same RPC with
verbosity = "0x2"returns transaction content for a committed tx, but not for rejected txs:{"hash":"0x5d7c726635abff46908b119ef9b82af8e04418827fbfbb8fe49fded705636352","status":"committed","transaction":"present"}Expected behavior
CCC should preserve terminal
tx_statusandreasonfrom CKB RPC even when the transaction body is absent.At minimum, callers should have a CCC API that can distinguish:
unknown.rejectedwith a reason.Possible fixes
I am not sure which API shape best fits CCC's public types, but these seem plausible:
ClientTransactionResponseor its transformer to support status-only responses.getTransactionStatus, backed byget_transactionverbosity 1.getTransactionavoid cache fallback when the fresh RPC response contains a status-only rejection.The key invariant is that
transaction: nullshould not cause CCC to discardtx_status.Impact
Applications that call
sendTransactionand then pollgetTransactioncan miss terminal chain or pool rejections. In our live iCKB supervisor runs, this surfaced as confirmation timeouts even though public CKB RPC already reportedrejectedwith eitherResolve failed Dead(OutPoint(...))orRBFRejected(...).Keep up the Great Work,
Phroi %37