Summary
A THORChain XRP swap builds a routing memo, the signing adapter silently discards it, and the device returns a valid but semantically wrong transaction: a plain XRP payment to the THORChain inbound vault address with no routing memo and destinationTag: '0'.
THORChain cannot route an inbound deposit with no memo. The user asks for a swap and gets an unroutable transfer to a vault address.
The chain, verified end to end in this tree
-
src/bun/swap.ts:882 — XRP is explicitly in the "All other chains (Cosmos, XRP, Solana, Tron, TON): send to vault with memo" branch.
-
src/bun/txbuilder/index.ts:165-172 — case 'xrp' passes memo: params.memo to buildXrpTx.
-
src/bun/txbuilder/xrp.ts:43-52 — the isSwapMemo test /^[=+\-~]?:/ matches THORChain memos and routes them to memoData, explicitly never a destination tag:
// THORChain/swap memos (e.g. "=:ETH.ETH:0x...") are always memo data, never a destination tag.
-
src/bun/txbuilder/xrp.ts:83 — tx.value.memo = memoData.
-
src/bun/txbuilder/index.ts:621 — return wallet.rippleSignTx(unsignedTx).
-
@keepkey/hdwallet-keepkey/dist/ripple.js never reads .memo. It references msg.tx.value exactly twice — .fee at :56 and .msg at :69 — and builds:
signTx.setAddressNList / setFee / setSequence / setLastLedgerSequence
payment.setAmount / setDestination / setDestinationTag
No memo field is set, and the KeepKey RippleSignTx protobuf has no memo field to set — RippleSignTx carries fields 1-6 and RipplePayment carries amount/destination/destinationTag.
-
hdwallet returns the device's serializedTx, which likewise contains no memo.
src/bun/schemas.ts:156 records the assumption that made this invisible: "tx (StdTx wrapper) + lastLedgerSequence are read by hdwallet's rippleSignTx." The wrapper is read. The memo inside it is not.
Reachability
Armed and reachable in production code, not behind a feature flag:
src/shared/chains.ts:297-303 registers ripple as a first-class chain with signMethod: 'xrpSignTx'.
src/bun/index.ts:2933-2947 exposes xrpSignTx over RPC, dispatching to engine.wallet.rippleSignTx(params).
src/bun/rest-api.ts:2811 exposes the same over REST.
swap-parsing.ts:143 and swap-tracker.ts:82 both map ripple.
I have not confirmed whether XRP is currently offered as a swap source in the UI, or whether THORChain XRP has live liquidity. That determines whether this is actively losing funds today or waiting for a config change to activate. It should be fixed either way — the signing path cannot carry the memo, so any future UI or provider change activates it without anyone touching signing code.
The firmware is not at fault
Worth stating, because the first hypothesis was wrong. I initially suspected the firmware silently dropped an unknown protobuf field (nanopb skips unknown fields rather than rejecting them). It does not apply here: the memo never reaches the device. hdwallet discards it host-side, so firmware cannot disclose or refuse what it is never sent. No firmware change fixes this.
Suggested fix: fail closed in the builder
Do not send the memo-less payment. In buildXrpTx, refuse when memoData is set:
if (memoData) {
throw new Error(
'XRP memo cannot be signed: the KeepKey RippleSignTx protocol has no memo field, ' +
'so a routing memo would be silently dropped and the deposit would be unroutable.'
)
}
A refused swap is a recoverable inconvenience. A broadcast, valid, unroutable vault deposit is not.
Longer term this needs a memo field in RippleSignTx, firmware serialization of the XRPL Memos array, and on-device disclosure of the memo — that is a feature, and it should not gate failing closed now.
Related
Found while triaging a firmware integration test (test_sign_with_thorchain_memo) that asserts an XRPL Memos array in serialized_tx. That test encodes the intended end state and currently fails because neither the protocol nor the host can deliver it — it is measuring the same gap from the other side.
Summary
A THORChain XRP swap builds a routing memo, the signing adapter silently discards it, and the device returns a valid but semantically wrong transaction: a plain XRP payment to the THORChain inbound vault address with no routing memo and
destinationTag: '0'.THORChain cannot route an inbound deposit with no memo. The user asks for a swap and gets an unroutable transfer to a vault address.
The chain, verified end to end in this tree
src/bun/swap.ts:882— XRP is explicitly in the "All other chains (Cosmos, XRP, Solana, Tron, TON): send to vault with memo" branch.src/bun/txbuilder/index.ts:165-172—case 'xrp'passesmemo: params.memotobuildXrpTx.src/bun/txbuilder/xrp.ts:43-52— theisSwapMemotest/^[=+\-~]?:/matches THORChain memos and routes them tomemoData, explicitly never a destination tag:// THORChain/swap memos (e.g. "=:ETH.ETH:0x...") are always memo data, never a destination tag.src/bun/txbuilder/xrp.ts:83—tx.value.memo = memoData.src/bun/txbuilder/index.ts:621—return wallet.rippleSignTx(unsignedTx).@keepkey/hdwallet-keepkey/dist/ripple.jsnever reads.memo. It referencesmsg.tx.valueexactly twice —.feeat :56 and.msgat :69 — and builds:No memo field is set, and the KeepKey
RippleSignTxprotobuf has no memo field to set —RippleSignTxcarries fields 1-6 andRipplePaymentcarries amount/destination/destinationTag.hdwallet returns the device's
serializedTx, which likewise contains no memo.src/bun/schemas.ts:156records the assumption that made this invisible: "tx (StdTx wrapper) + lastLedgerSequence are read by hdwallet's rippleSignTx." The wrapper is read. Thememoinside it is not.Reachability
Armed and reachable in production code, not behind a feature flag:
src/shared/chains.ts:297-303registersrippleas a first-class chain withsignMethod: 'xrpSignTx'.src/bun/index.ts:2933-2947exposesxrpSignTxover RPC, dispatching toengine.wallet.rippleSignTx(params).src/bun/rest-api.ts:2811exposes the same over REST.swap-parsing.ts:143andswap-tracker.ts:82both mapripple.I have not confirmed whether XRP is currently offered as a swap source in the UI, or whether THORChain XRP has live liquidity. That determines whether this is actively losing funds today or waiting for a config change to activate. It should be fixed either way — the signing path cannot carry the memo, so any future UI or provider change activates it without anyone touching signing code.
The firmware is not at fault
Worth stating, because the first hypothesis was wrong. I initially suspected the firmware silently dropped an unknown protobuf field (nanopb skips unknown fields rather than rejecting them). It does not apply here: the memo never reaches the device. hdwallet discards it host-side, so firmware cannot disclose or refuse what it is never sent. No firmware change fixes this.
Suggested fix: fail closed in the builder
Do not send the memo-less payment. In
buildXrpTx, refuse whenmemoDatais set:A refused swap is a recoverable inconvenience. A broadcast, valid, unroutable vault deposit is not.
Longer term this needs a
memofield inRippleSignTx, firmware serialization of the XRPL Memos array, and on-device disclosure of the memo — that is a feature, and it should not gate failing closed now.Related
Found while triaging a firmware integration test (
test_sign_with_thorchain_memo) that asserts an XRPL Memos array inserialized_tx. That test encodes the intended end state and currently fails because neither the protocol nor the host can deliver it — it is measuring the same gap from the other side.