Skip to content

XRP THORChain swap memo is silently dropped by rippleSignTx; deposit becomes unroutable #422

Description

@BitHighlander

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

  1. src/bun/swap.ts:882 — XRP is explicitly in the "All other chains (Cosmos, XRP, Solana, Tron, TON): send to vault with memo" branch.

  2. src/bun/txbuilder/index.ts:165-172case 'xrp' passes memo: params.memo to buildXrpTx.

  3. 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.
  4. src/bun/txbuilder/xrp.ts:83tx.value.memo = memoData.

  5. src/bun/txbuilder/index.ts:621return wallet.rippleSignTx(unsignedTx).

  6. @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 setRippleSignTx carries fields 1-6 and RipplePayment carries amount/destination/destinationTag.

  7. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions