From 38d21e6333ab53dba81a7fb8a61ebc0e2a82370b Mon Sep 17 00:00:00 2001 From: Ted Palmer Date: Sun, 9 Aug 2026 21:11:34 -0400 Subject: [PATCH 1/2] XRP support --- .changeset/xrp-vm-sdk-support.md | 5 ++ .changeset/xrp-vm-ui-support.md | 5 ++ _to_delete/index.lock.stale2 | 0 packages/sdk/src/actions/execute.test.ts | 36 +++++++++- packages/sdk/src/actions/getQuote.test.ts | 48 ++++++++++++- packages/sdk/src/constants/address.ts | 11 ++- .../common/TokenSelector/PaymentMethod.tsx | 2 +- .../common/TokenSelector/TokenSelector.tsx | 2 +- .../widget/OnrampWidgetRenderer.tsx | 2 +- .../widgets/OnrampWidget/widget/index.tsx | 2 +- .../components/widgets/SwapWidget/index.tsx | 6 +- .../components/widgets/SwapWidgetRenderer.tsx | 4 +- .../components/widgets/WidgetErrorWell.tsx | 3 +- packages/ui/src/constants/depositAddresses.ts | 3 +- packages/ui/src/utils/address.ts | 7 +- packages/ui/src/utils/xrp.ts | 72 +++++++++++++++++++ 16 files changed, 190 insertions(+), 18 deletions(-) create mode 100644 .changeset/xrp-vm-sdk-support.md create mode 100644 .changeset/xrp-vm-ui-support.md create mode 100644 _to_delete/index.lock.stale2 create mode 100644 packages/ui/src/utils/xrp.ts diff --git a/.changeset/xrp-vm-sdk-support.md b/.changeset/xrp-vm-sdk-support.md new file mode 100644 index 000000000..116589432 --- /dev/null +++ b/.changeset/xrp-vm-sdk-support.md @@ -0,0 +1,5 @@ +--- +'@relayprotocol/relay-sdk': patch +--- + +Add XRP dead address and include the Tron, Zero and XRP dead addresses in isDeadAddress diff --git a/.changeset/xrp-vm-ui-support.md b/.changeset/xrp-vm-ui-support.md new file mode 100644 index 000000000..a74a938ed --- /dev/null +++ b/.changeset/xrp-vm-ui-support.md @@ -0,0 +1,5 @@ +--- +'@relayprotocol/relay-kit-ui': patch +--- + +Add XRP vm support for destination transactions diff --git a/_to_delete/index.lock.stale2 b/_to_delete/index.lock.stale2 new file mode 100644 index 000000000..e69de29bb diff --git a/packages/sdk/src/actions/execute.test.ts b/packages/sdk/src/actions/execute.test.ts index 5eede6929..d01f67225 100644 --- a/packages/sdk/src/actions/execute.test.ts +++ b/packages/sdk/src/actions/execute.test.ts @@ -5,7 +5,11 @@ import { mainnet } from 'viem/chains' import { MAINNET_RELAY_API } from '../constants' import { executeBridge } from '../../tests/data/executeBridge' import type { AdaptedWallet, Execute } from '../types' -import { evmDeadAddress } from '../constants/address' +import { + evmDeadAddress, + tronDeadAddress, + xrpDeadAddress +} from '../constants/address' let client: RelayClient | undefined let wallet: AdaptedWallet = { @@ -174,4 +178,34 @@ describe('Should test the execute action.', () => { }) ).toThrow('Recipient should never be burn address') }) + + it('Should throw an error when recipient is the XRP dead address', () => { + client = createClient({ + baseApiUrl: MAINNET_RELAY_API + }) + if (quote.details?.sender) { + quote.details.recipient = xrpDeadAddress + } + expect(() => + client?.actions?.execute({ + wallet, + quote + }) + ).toThrow('Recipient should never be burn address') + }) + + it('Should throw an error when recipient is the Tron dead address', () => { + client = createClient({ + baseApiUrl: MAINNET_RELAY_API + }) + if (quote.details?.sender) { + quote.details.recipient = tronDeadAddress + } + expect(() => + client?.actions?.execute({ + wallet, + quote + }) + ).toThrow('Recipient should never be burn address') + }) }) diff --git a/packages/sdk/src/actions/getQuote.test.ts b/packages/sdk/src/actions/getQuote.test.ts index ee8016b6d..dbf1cfccc 100644 --- a/packages/sdk/src/actions/getQuote.test.ts +++ b/packages/sdk/src/actions/getQuote.test.ts @@ -11,10 +11,25 @@ import { arbitrumNova } from 'viem/chains' import { MAINNET_RELAY_API } from '../constants' +import { xrpDeadAddress } from '../constants/address' import { axios } from '../utils' +import type { RelayChain } from '../types' const viemChains = [mainnet, base, zora, optimism, arbitrum, arbitrumNova] -const relayChains = viemChains.map(convertViemChainToRelayChain) +const xrpChain: RelayChain = { + id: 537724, + name: 'xrp', + displayName: 'XRP Ledger', + vmType: 'xrpvm', + currency: { + id: 'xrp', + symbol: 'XRP', + name: 'XRP', + address: 'xrp', + decimals: 6 + } +} +const relayChains = [...viemChains.map(convertViemChainToRelayChain), xrpChain] let client: RelayClient | undefined let wallet = { @@ -142,4 +157,35 @@ describe('Should test the getQuote action.', () => { }) ) }) + + it('Should use the XRP dead address as the default recipient for XRP destinations', async () => { + client = createClient({ + baseApiUrl: MAINNET_RELAY_API, + chains: relayChains + }) + + await client?.actions?.getQuote( + { + toChainId: 537724, + chainId: 8453, + currency: '0x0000000000000000000000000000000000000000', + toCurrency: 'xrp', + tradeType: 'EXACT_INPUT', + amount: '1000000000000000' // 0.001 ETH + }, + true + ) + + expect(axiosRequestSpy).toHaveBeenCalledWith( + expect.objectContaining({ + url: expect.stringContaining('quote'), + data: expect.objectContaining({ + // Origin is still EVM, so the user stays the EVM dead address + user: '0x000000000000000000000000000000000000dead', + destinationChainId: 537724, + recipient: xrpDeadAddress + }) + }) + ) + }) }) diff --git a/packages/sdk/src/constants/address.ts b/packages/sdk/src/constants/address.ts index 3e53593fe..1872ac14b 100644 --- a/packages/sdk/src/constants/address.ts +++ b/packages/sdk/src/constants/address.ts @@ -11,6 +11,10 @@ export const tronDeadAddress = 'THa7BwoPfacfiELa63pbmm3g5PGKYmtJyt' export const zeroDeadAddress = '0x00000000000000000000000000000000000dead0' export const tonDeadAddress = 'EQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADerZ0z' as const +// XRPL ACCOUNT_ONE, a checksum-valid blackhole account with no known key. +// Deliberately not ACCOUNT_ZERO (rrrrrrrrrrrrrrrrrrrrrhoLvTp), which the +// settlement layer already uses as the native-XRP sentinel. +export const xrpDeadAddress = 'rrrrrrrrrrrrrrrrrrrrBZbvji' as const const eclipseId = 9286185 const zeroChainId = 543210 @@ -26,6 +30,8 @@ export const getDeadAddress = (vmType?: ChainVM, chainId?: number) => { return tronDeadAddress } else if (vmType === 'tonvm') { return tonDeadAddress + } else if (vmType === 'xrpvm') { + return xrpDeadAddress } else { return evmDeadAddress } @@ -41,7 +47,10 @@ export const isDeadAddress = (address?: string) => { address === solDeadAddress || address === bitcoinDeadAddress || address === evmDeadAddress || - address === tonDeadAddress + address === tonDeadAddress || + address === tronDeadAddress || + address === zeroDeadAddress || + address === xrpDeadAddress ) { return true } diff --git a/packages/ui/src/components/common/TokenSelector/PaymentMethod.tsx b/packages/ui/src/components/common/TokenSelector/PaymentMethod.tsx index 0f6a85236..8ecaff035 100644 --- a/packages/ui/src/components/common/TokenSelector/PaymentMethod.tsx +++ b/packages/ui/src/components/common/TokenSelector/PaymentMethod.tsx @@ -57,7 +57,7 @@ export type PaymentMethodProps = { isValidAddress?: boolean multiWalletSupportEnabled?: boolean fromChainWalletVMSupported?: boolean - supportedWalletVMs?: Omit[] + supportedWalletVMs?: Omit[] popularChainIds?: number[] linkedWallets?: any[] setToken: (token: Token) => void diff --git a/packages/ui/src/components/common/TokenSelector/TokenSelector.tsx b/packages/ui/src/components/common/TokenSelector/TokenSelector.tsx index 69fca1613..4dc404dd1 100644 --- a/packages/ui/src/components/common/TokenSelector/TokenSelector.tsx +++ b/packages/ui/src/components/common/TokenSelector/TokenSelector.tsx @@ -69,7 +69,7 @@ export type TokenSelectorProps = { isValidAddress?: boolean multiWalletSupportEnabled?: boolean fromChainWalletVMSupported?: boolean - supportedWalletVMs?: Omit[] + supportedWalletVMs?: Omit[] popularChainIds?: number[] setToken: (token: Token) => void onAnalyticEvent?: (eventName: string, data?: any) => void diff --git a/packages/ui/src/components/widgets/OnrampWidget/widget/OnrampWidgetRenderer.tsx b/packages/ui/src/components/widgets/OnrampWidget/widget/OnrampWidgetRenderer.tsx index 11b19e6b8..7ebdad78d 100644 --- a/packages/ui/src/components/widgets/OnrampWidget/widget/OnrampWidgetRenderer.tsx +++ b/packages/ui/src/components/widgets/OnrampWidget/widget/OnrampWidgetRenderer.tsx @@ -66,7 +66,7 @@ export type ChildrenProps = { type OnrampWidgetRendererProps = { defaultWalletAddress?: string - supportedWalletVMs: Omit[] + supportedWalletVMs: Omit[] linkedWallets?: LinkedWallet[] multiWalletSupportEnabled?: boolean moonPayApiKey: string diff --git a/packages/ui/src/components/widgets/OnrampWidget/widget/index.tsx b/packages/ui/src/components/widgets/OnrampWidget/widget/index.tsx index 23f57b69d..813434d3c 100644 --- a/packages/ui/src/components/widgets/OnrampWidget/widget/index.tsx +++ b/packages/ui/src/components/widgets/OnrampWidget/widget/index.tsx @@ -26,7 +26,7 @@ import { type BaseOnrampWidgetProps = { defaultWalletAddress?: string - supportedWalletVMs: Omit[] + supportedWalletVMs: Omit[] moonPayApiKey: string moonPayThemeId?: string moonPayThemeMode?: 'dark' | 'light' diff --git a/packages/ui/src/components/widgets/SwapWidget/index.tsx b/packages/ui/src/components/widgets/SwapWidget/index.tsx index af83f9e11..03b681268 100644 --- a/packages/ui/src/components/widgets/SwapWidget/index.tsx +++ b/packages/ui/src/components/widgets/SwapWidget/index.tsx @@ -41,7 +41,7 @@ import { findSupportedWallet, isChainVmTypeSupported } from '../../../utils/address.js' -import { isDeadAddress, tronDeadAddress } from '@relayprotocol/relay-sdk' +import { isDeadAddress } from '@relayprotocol/relay-sdk' import { ProviderOptionsContext, useHapticEvent @@ -70,7 +70,7 @@ type BaseSwapWidgetProps = { lockChainId?: number singleChainMode?: boolean wallet?: AdaptedWallet - supportedWalletVMs: Omit[] + supportedWalletVMs: Omit[] disableInputAutoFocus?: boolean popularChainIds?: number[] disablePasteWalletAddressOption?: boolean @@ -977,7 +977,6 @@ const SwapWidget: FC = ({ displaySymbol={false} isConnected={ !isDeadAddress(address) && - address !== tronDeadAddress && address !== undefined } pending={fromBalancePending} @@ -1389,7 +1388,6 @@ const SwapWidget: FC = ({ displaySymbol={false} isConnected={ !isDeadAddress(address) && - address !== tronDeadAddress && address !== undefined } pending={toBalancePending} diff --git a/packages/ui/src/components/widgets/SwapWidgetRenderer.tsx b/packages/ui/src/components/widgets/SwapWidgetRenderer.tsx index 65bfb140a..1360f5b3d 100644 --- a/packages/ui/src/components/widgets/SwapWidgetRenderer.tsx +++ b/packages/ui/src/components/widgets/SwapWidgetRenderer.tsx @@ -74,7 +74,7 @@ type SwapWidgetRendererProps = { wallet?: AdaptedWallet linkedWallets?: LinkedWallet[] multiWalletSupportEnabled?: boolean - supportedWalletVMs: Omit[] + supportedWalletVMs: Omit[] onConnectWallet?: () => void onAnalyticEvent?: (eventName: string, data?: any) => void onSwapError?: (error: string, data?: Execute) => void @@ -133,7 +133,7 @@ export type ChildrenProps = { isBvmSwap: boolean isValidFromAddress: boolean isValidToAddress: boolean - supportedWalletVMs: Omit[] + supportedWalletVMs: Omit[] fromChainWalletVMSupported: boolean toChainWalletVMSupported: boolean isRecipientLinked?: boolean diff --git a/packages/ui/src/components/widgets/WidgetErrorWell.tsx b/packages/ui/src/components/widgets/WidgetErrorWell.tsx index 0c2fe63b5..f220ec838 100644 --- a/packages/ui/src/components/widgets/WidgetErrorWell.tsx +++ b/packages/ui/src/components/widgets/WidgetErrorWell.tsx @@ -1,5 +1,5 @@ import { type Execute } from '@relayprotocol/relay-sdk' -import { isDeadAddress, tronDeadAddress } from '@relayprotocol/relay-sdk' +import { isDeadAddress } from '@relayprotocol/relay-sdk' import { type FC } from 'react' import { Box, Flex, Text } from '../primitives/index.js' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' @@ -98,7 +98,6 @@ export const WidgetErrorWell: FC = ({ !recipientWalletSupportsChain && recipient && !isDeadAddress(recipient) && - recipient !== tronDeadAddress && toChainWalletVMSupported && (!recipientLinkedWallet || recipientLinkedWallet.vmType === toChainVmType) ) { diff --git a/packages/ui/src/constants/depositAddresses.ts b/packages/ui/src/constants/depositAddresses.ts index 8e58a3073..e31979e80 100644 --- a/packages/ui/src/constants/depositAddresses.ts +++ b/packages/ui/src/constants/depositAddresses.ts @@ -7,5 +7,6 @@ export const UnsupportedDepositAddressChainIds = [ 1996, //sanko 1514, //story 660279, //xai - 224235520 //ton + 224235520, //ton + 537724 //xrp ] diff --git a/packages/ui/src/utils/address.ts b/packages/ui/src/utils/address.ts index d52fb330d..4136743dd 100644 --- a/packages/ui/src/utils/address.ts +++ b/packages/ui/src/utils/address.ts @@ -16,6 +16,7 @@ import type { RelayKitProviderProps } from '../providers/RelayKitProvider.js' import { isTronAddress } from './tron.js' import { isTonAddress } from './ton.js' import { isLighterAddress } from './lighter.js' +import { isXrpAddress } from './xrp.js' export const isWalletVmTypeCompatible = ( walletVmType?: ChainVM, @@ -35,7 +36,7 @@ export const isWalletVmTypeCompatible = ( export const isChainVmTypeSupported = ( chainVmType: ChainVM | undefined, - supportedWalletVMs: Omit[] + supportedWalletVMs: Omit[] ) => { if (!chainVmType) { return true @@ -46,7 +47,7 @@ export const isChainVmTypeSupported = ( } return supportedWalletVMs.includes( - chainVmType as Omit + chainVmType as Omit ) } @@ -92,6 +93,8 @@ export const isValidAddress = ( return isTonAddress(address) } else if (vmType === 'lvm') { return isLighterAddress(address) + } else if (vmType === 'xrpvm') { + return isXrpAddress(address) } } return false diff --git a/packages/ui/src/utils/xrp.ts b/packages/ui/src/utils/xrp.ts new file mode 100644 index 000000000..f5ce44336 --- /dev/null +++ b/packages/ui/src/utils/xrp.ts @@ -0,0 +1,72 @@ +import { sha256 } from '@noble/hashes/sha2.js' + +export const xrp = { + id: 537724 +} + +// XRPL uses its own base58 alphabet (the "Ripple" alphabet), which orders the +// characters differently to Bitcoin's. Classic addresses are the base58check +// encoding of a 0x00 type prefix followed by a 20 byte account id. +const XRP_BASE58_ALPHABET = + 'rpshnaf39wBUDNEGHJKLM4PQRST7VWXYZ2bcdeCg65jkm8oFqi1tuvAxyz' +const XRP_ACCOUNT_ID_PREFIX = 0x00 +const XRP_DECODED_LENGTH = 25 // 1 prefix + 20 account id + 4 checksum + +// Cheap structural check so obviously wrong input short circuits before we +// spend a hash on it. X-addresses (`X`/`T` prefixed, destination tag encoded +// into the address) are intentionally rejected -- they are out of scope for v1. +const xrpClassicAddressRegex = /^r[1-9A-HJ-NP-Za-km-z]{24,34}$/ + +function decodeXrpBase58(address: string): Uint8Array | undefined { + const bytes: number[] = [] + + for (const char of address) { + const value = XRP_BASE58_ALPHABET.indexOf(char) + if (value === -1) { + return undefined + } + + let carry = value + for (let i = 0; i < bytes.length; i++) { + carry += bytes[i] * 58 + bytes[i] = carry & 0xff + carry >>= 8 + } + while (carry > 0) { + bytes.push(carry & 0xff) + carry >>= 8 + } + } + + // Each leading alphabet-zero character represents one leading zero byte + for ( + let i = 0; + i < address.length && address[i] === XRP_BASE58_ALPHABET[0]; + i++ + ) { + bytes.push(0) + } + + return new Uint8Array(bytes.reverse()) +} + +export function isXrpAddress(address: string): boolean { + if (!xrpClassicAddressRegex.test(address)) { + return false + } + + const decoded = decodeXrpBase58(address) + if ( + !decoded || + decoded.length !== XRP_DECODED_LENGTH || + decoded[0] !== XRP_ACCOUNT_ID_PREFIX + ) { + return false + } + + const payload = decoded.subarray(0, 21) + const checksum = decoded.subarray(21) + const expectedChecksum = sha256(sha256(payload)).subarray(0, 4) + + return checksum.every((byte, index) => byte === expectedChecksum[index]) +} From 2fffba7dcce95232137b0c999a26ef040ae145d2 Mon Sep 17 00:00:00 2001 From: Ted Palmer Date: Mon, 10 Aug 2026 12:18:16 -0400 Subject: [PATCH 2/2] Updates --- _to_delete/index.lock.stale2 | 0 packages/sdk/src/constants/address.ts | 3 -- packages/ui/src/utils/xrp.ts | 53 +++++++++++++-------------- 3 files changed, 25 insertions(+), 31 deletions(-) delete mode 100644 _to_delete/index.lock.stale2 diff --git a/_to_delete/index.lock.stale2 b/_to_delete/index.lock.stale2 deleted file mode 100644 index e69de29bb..000000000 diff --git a/packages/sdk/src/constants/address.ts b/packages/sdk/src/constants/address.ts index 1872ac14b..8a01ba5dd 100644 --- a/packages/sdk/src/constants/address.ts +++ b/packages/sdk/src/constants/address.ts @@ -11,9 +11,6 @@ export const tronDeadAddress = 'THa7BwoPfacfiELa63pbmm3g5PGKYmtJyt' export const zeroDeadAddress = '0x00000000000000000000000000000000000dead0' export const tonDeadAddress = 'EQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADerZ0z' as const -// XRPL ACCOUNT_ONE, a checksum-valid blackhole account with no known key. -// Deliberately not ACCOUNT_ZERO (rrrrrrrrrrrrrrrrrrrrrhoLvTp), which the -// settlement layer already uses as the native-XRP sentinel. export const xrpDeadAddress = 'rrrrrrrrrrrrrrrrrrrrBZbvji' as const const eclipseId = 9286185 diff --git a/packages/ui/src/utils/xrp.ts b/packages/ui/src/utils/xrp.ts index f5ce44336..5a30a05fe 100644 --- a/packages/ui/src/utils/xrp.ts +++ b/packages/ui/src/utils/xrp.ts @@ -4,29 +4,20 @@ export const xrp = { id: 537724 } -// XRPL uses its own base58 alphabet (the "Ripple" alphabet), which orders the -// characters differently to Bitcoin's. Classic addresses are the base58check -// encoding of a 0x00 type prefix followed by a 20 byte account id. +// XRPL's own base58 ordering. Same 58 characters as Bitcoin's alphabet, so a +// Bitcoin ordering looks right but indexes to the wrong bytes. const XRP_BASE58_ALPHABET = 'rpshnaf39wBUDNEGHJKLM4PQRST7VWXYZ2bcdeCg65jkm8oFqi1tuvAxyz' -const XRP_ACCOUNT_ID_PREFIX = 0x00 -const XRP_DECODED_LENGTH = 25 // 1 prefix + 20 account id + 4 checksum - -// Cheap structural check so obviously wrong input short circuits before we -// spend a hash on it. X-addresses (`X`/`T` prefixed, destination tag encoded -// into the address) are intentionally rejected -- they are out of scope for v1. -const xrpClassicAddressRegex = /^r[1-9A-HJ-NP-Za-km-z]{24,34}$/ function decodeXrpBase58(address: string): Uint8Array | undefined { const bytes: number[] = [] for (const char of address) { - const value = XRP_BASE58_ALPHABET.indexOf(char) - if (value === -1) { + let carry = XRP_BASE58_ALPHABET.indexOf(char) + if (carry === -1) { return undefined } - let carry = value for (let i = 0; i < bytes.length; i++) { carry += bytes[i] * 58 bytes[i] = carry & 0xff @@ -39,34 +30,40 @@ function decodeXrpBase58(address: string): Uint8Array | undefined { } // Each leading alphabet-zero character represents one leading zero byte - for ( - let i = 0; - i < address.length && address[i] === XRP_BASE58_ALPHABET[0]; - i++ - ) { + for (const char of address) { + if (char !== XRP_BASE58_ALPHABET[0]) { + break + } bytes.push(0) } return new Uint8Array(bytes.reverse()) } +/** + * Classic `r...` addresses only. X-addresses have a non-zero version byte and + * are rejected, since the destination tag they encode can't be forwarded. + * + * Does not trim: callers store the string they validated as the recipient, so + * accepting padded input would forward the padding to the quote. + */ export function isXrpAddress(address: string): boolean { - if (!xrpClassicAddressRegex.test(address)) { + // Bounds the decode; every 25 byte base58check payload lands in this range + if (address.length < 25 || address.length > 35) { return false } const decoded = decodeXrpBase58(address) - if ( - !decoded || - decoded.length !== XRP_DECODED_LENGTH || - decoded[0] !== XRP_ACCOUNT_ID_PREFIX - ) { + // 1 version byte + 20 byte account id + 4 byte checksum. Version 0x00 can + // only come from a leading `r`, so this also pins the prefix. + if (!decoded || decoded.length !== 25 || decoded[0] !== 0x00) { return false } - const payload = decoded.subarray(0, 21) - const checksum = decoded.subarray(21) - const expectedChecksum = sha256(sha256(payload)).subarray(0, 4) + const expectedChecksum = sha256(sha256(decoded.subarray(0, 21))).subarray( + 0, + 4 + ) - return checksum.every((byte, index) => byte === expectedChecksum[index]) + return decoded.subarray(21).every((byte, i) => byte === expectedChecksum[i]) }