diff --git a/AGENTS.md b/AGENTS.md index 0a2bf7e..e45919b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,7 +9,7 @@ Scope: canonical blockchain classes, aliases, and address validation. - `src/core/registry.ts` is the constructor registry - `src/core/resolve.ts` owns the aliases and `getChain`; canonical keys and display names are matched against the registry, so the alias table holds only real aliases, never a key as its own entry - `src/core/identify.ts` partitions the registry by an address: matching validators and unchecked chains -- `src/core/base58.ts` decodes base58 for chains that check the bytes behind an address +- `src/core/base58.ts` decodes base58 for chains that check the bytes behind an address. The alphabet is an argument, Bitcoin's by default and the XRP Ledger's for `xrpl` - `src/chains/*.ts` is one concrete blockchain class per file - `src/chains/index.ts` holds `builtins`, the ordered list the registry is seeded from. A chain file that is not in it is not in the registry - `src/index.ts` is the public API diff --git a/README.md b/README.md index 3565cd0..7247fed 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,7 @@ Chain (abstract) ├── Bitcoin ├── Solana ├── Stellar +├── Xrpl ├── Ton ├── Tron └── Octra @@ -89,7 +90,7 @@ Keys name the chain rather than its ticker, so it is `ethereum`, `berachain` and The base58 validators decode, because a shape is not enough. Solana requires exactly 32 decoded bytes: character length cannot separate an account from a Bitcoin or TRON address, since those are 34 characters and 25 bytes, while the System Program is 32 characters and 32 bytes. Bitcoin's legacy branch requires the 25 Base58Check bytes under a `0x00` or `0x05` version: the same System Program fit a character-length window, and decoding is what keeps that false match out. The checksum stays unchecked, this is a format check. Bitcoin's bech32 branch uses the BIP-173 charset, which has no `1`, `b`, `i` or `o`, and treats all-lowercase and all-uppercase as valid while rejecting mixed case - uppercase is what QR encoders emit, so rejecting it would fail addresses that spend fine. Stellar accepts SEP-23 Strkeys for classic accounts, muxed accounts and contracts, including the canonical base32 form and its CRC16-XModem checksum. -TRON is the same 25 Base58Check bytes under version `0x41`, so decoding is also what keeps it and Bitcoin's legacy form apart. TON takes the TEP-2 friendly form in either base64 alphabet: 36 decoded bytes, a bounceable or non-bounceable tag and one of the two workchains that exist, with the testnet-only flag rejected the way Bitcoin's testnet versions are. Octra is the one base58 chain here where decoding would be a mistake: `oct` and a fixed 44 characters is the whole format, and a contract address is cut out of a base58 string rather than encoded from a payload, so its value runs past 32 bytes. The node takes that width and nothing else, so an address one character short is not a near miss, it is a different string. Aptos and Sui want all 32 bytes of hex written out, or the one-digit short form AIP-40 defines for the special addresses, which is how the framework address `0x1` is actually written - anything in between stays rejected, because accepting dropped leading zeros would make every EVM address a valid move address too. With those in place every registered chain validates, so `identify` gets an answer out of the whole registry. +TRON is the same 25 Base58Check bytes under version `0x41`, so decoding is also what keeps it and Bitcoin's legacy form apart. The XRP Ledger writes base58 over its own ordering of those same 58 characters, so an address there has to be read under the ledger's digits or the bytes come back wrong instead of rejected. A classic account is 25 Base58Check bytes under version `0x00`, an X-address is 35 bytes under the mainnet prefix `0x05 0x44`, and the testnet prefix is turned away along with the reserved tag bytes XLS-5 requires to be zero. TON takes the TEP-2 friendly form in either base64 alphabet: 36 decoded bytes, a bounceable or non-bounceable tag and one of the two workchains that exist, with the testnet-only flag rejected the way Bitcoin's testnet versions are. Octra is the one base58 chain here where decoding would be a mistake: `oct` and a fixed 44 characters is the whole format, and a contract address is cut out of a base58 string rather than encoded from a payload, so its value runs past 32 bytes. The node takes that width and nothing else, so an address one character short is not a near miss, it is a different string. Aptos and Sui want all 32 bytes of hex written out, or the one-digit short form AIP-40 defines for the special addresses, which is how the framework address `0x1` is actually written - anything in between stays rejected, because accepting dropped leading zeros would make every EVM address a valid move address too. With those in place every registered chain validates, so `identify` gets an answer out of the whole registry. ### Errors @@ -170,4 +171,4 @@ Optional fields stay empty when the chain has no registered value. Octra has no ## Supported chains -`ethereum`, `base`, `arbitrum`, `optimism`, `polygon`, `bsc`, `avalanche`, `fantom`, `gnosis`, `linea`, `zksync`, `scroll`, `berachain`, `bitcoin`, `litecoin`, `pepecoin`, `ecash`, `cardano`, `solana`, `stellar`, `aptos`, `sui`, `ton`, `tron`, and `octra`. +`ethereum`, `base`, `arbitrum`, `optimism`, `polygon`, `bsc`, `avalanche`, `fantom`, `gnosis`, `linea`, `zksync`, `scroll`, `berachain`, `bitcoin`, `litecoin`, `pepecoin`, `ecash`, `cardano`, `solana`, `stellar`, `xrpl`, `aptos`, `sui`, `ton`, `tron`, and `octra`. diff --git a/packages/omp/extensions/chains.ts b/packages/omp/extensions/chains.ts index 56bc14f..9ecdb00 100644 --- a/packages/omp/extensions/chains.ts +++ b/packages/omp/extensions/chains.ts @@ -124,7 +124,7 @@ export default function chainsExtension(pi: ExtensionAPI): void { promptSnippet: "Use chains_list to see which blockchains are available, instead of guessing a chain name.", promptGuidelines: [ - "Families are evm, utxo, solana, stellar, move, ton, tron and octra.", + "Families are evm, utxo, solana, stellar, xrpl, move, ton, tron and octra.", "Every key and name it prints resolves in chains_lookup.", ], parameters: Type.Object({ diff --git a/packages/pi/extensions/chains.ts b/packages/pi/extensions/chains.ts index 97d7e3e..1c0ac14 100644 --- a/packages/pi/extensions/chains.ts +++ b/packages/pi/extensions/chains.ts @@ -121,7 +121,7 @@ export default function chainsExtension(pi: ExtensionAPI): void { promptSnippet: "Use chains_list to see which blockchains are available, instead of guessing a chain name.", promptGuidelines: [ - "Families are evm, utxo, solana, stellar, move, ton, tron and octra.", + "Families are evm, utxo, solana, stellar, xrpl, move, ton, tron and octra.", "Every key and name it prints resolves in chains_lookup.", ], parameters: Type.Object({ diff --git a/src/chains/index.ts b/src/chains/index.ts index 728f7f7..f44184a 100644 --- a/src/chains/index.ts +++ b/src/chains/index.ts @@ -23,6 +23,7 @@ import { Stellar } from "./stellar.js"; import { Sui } from "./sui.js"; import { Ton } from "./ton.js"; import { Tron } from "./tron.js"; +import { Xrpl } from "./xrpl.js"; import { ZkSync } from "./zksync.js"; /** Every chain the package ships. Not in this list, not in the registry. */ @@ -47,6 +48,7 @@ export const builtins: readonly ChainConstructor[] = [ Cardano, Solana, Stellar, + Xrpl, Aptos, Sui, Ton, diff --git a/src/chains/xrpl.ts b/src/chains/xrpl.ts new file mode 100644 index 0000000..6c2f446 --- /dev/null +++ b/src/chains/xrpl.ts @@ -0,0 +1,43 @@ +import { decodeBase58 } from "../core/base58.js"; +import { Chain } from "../core/chain.js"; +import { InvalidAddressError } from "../core/errors.js"; + +/** The ledger's base58 digits: Bitcoin's 58 characters reordered, so `r` is zero. */ +export const XRP_ALPHABET = "rpshnaf39wBUDNEGHJKLM4PQRST7VWXYZ2bcdeCg65jkm8oFqi1tuvAxyz"; + +/** + * An X-address folds a destination tag in: prefix, 20-byte account, flag byte, eight tag + * bytes, checksum. XLS-5 requires the bytes the flag does not use to be zero, so flag 0 + * leaves all eight and flag 1 the top four. TAG_64 is reserved and testnet is 0x04 0x93. + */ +function isXAddress(decoded: Uint8Array): boolean { + if (decoded.length !== 35 || decoded[0] !== 0x05 || decoded[1] !== 0x44) return false; + const flag = decoded[22]; + if (flag !== 0 && flag !== 1) return false; + return decoded.subarray(flag === 0 ? 23 : 27, 31).every((byte) => byte === 0); +} + +export class Xrpl extends Chain { + static readonly key = "xrpl" as const; + readonly type = "xrpl" as const; + readonly name = "XRP Ledger"; + readonly symbol = "XRP"; + readonly explorer = "https://livenet.xrpl.org"; + readonly bip44 = 144; + readonly caip2 = "xrpl:0"; + + /** + * A classic address is Base58Check under version 0x00, 25 bytes, read under the + * ledger's alphabet, which is what keeps Bitcoin and TRON out. The checksum stays + * unchecked, and 48 characters is one past the longest address the format writes. + */ + override assertAddress(address: string): string { + const decoded = decodeBase58(address, 48, XRP_ALPHABET); + if (decoded === undefined) throw new InvalidAddressError(this.key, address); + const classic = decoded.length === 25 && decoded[0] === 0x00; + if (!classic && !isXAddress(decoded)) { + throw new InvalidAddressError(this.key, address); + } + return address; + } +} diff --git a/src/commands/list.ts b/src/commands/list.ts index 1437684..29660cd 100644 --- a/src/commands/list.ts +++ b/src/commands/list.ts @@ -11,7 +11,7 @@ export default defineCommand({ type: { type: "string", description: - "Only show chains of this family (evm, utxo, solana, stellar, move, ton, tron, octra)", + "Only show chains of this family (evm, utxo, solana, stellar, xrpl, move, ton, tron, octra)", }, json: { type: "boolean", diff --git a/src/core/base58.ts b/src/core/base58.ts index 70a03fd..1971c0f 100644 --- a/src/core/base58.ts +++ b/src/core/base58.ts @@ -1,4 +1,4 @@ -const ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"; +const BITCOIN_ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"; /** * Decodes a base58 string to its bytes, or undefined when the input is not @@ -9,17 +9,25 @@ const ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"; * Program, all zeroes) to 44. A chain that needs to know how many bytes an address * carries has to decode it. * + * The alphabet is a parameter because base58 is an ordering, not one encoding: read + * an XRP Ledger address off Bitcoin's ordering and the bytes come back wrong rather + * than rejected. The zero digit moves with the alphabet, so leading zeros follow it. + * * The bound is required because decoding is quadratic: every character grows the * BigInt the next multiply has to walk, and a 100k-character string ties the * process up for seconds. An address format knows its maximum length, so the * caller states it and oversized input is rejected before any work. */ -export function decodeBase58(input: string, maxLength: number): Uint8Array | undefined { +export function decodeBase58( + input: string, + maxLength: number, + alphabet: string = BITCOIN_ALPHABET, +): Uint8Array | undefined { if (input.length === 0 || input.length > maxLength) return undefined; let value = 0n; for (const character of input) { - const digit = ALPHABET.indexOf(character); + const digit = alphabet.indexOf(character); if (digit < 0) return undefined; value = value * 58n + BigInt(digit); } @@ -32,7 +40,7 @@ export function decodeBase58(input: string, maxLength: number): Uint8Array | und let leadingZeros = 0; for (const character of input) { - if (character !== "1") break; + if (character !== alphabet[0]) break; leadingZeros++; } diff --git a/src/core/resolve.ts b/src/core/resolve.ts index 227b554..c0f50e9 100644 --- a/src/core/resolve.ts +++ b/src/core/resolve.ts @@ -32,6 +32,8 @@ const aliases: Readonly> = { ada: "cardano", sol: "solana", xlm: "stellar", + xrp: "xrpl", + ripple: "xrpl", apt: "aptos", trx: "tron", oct: "octra", diff --git a/src/core/types.ts b/src/core/types.ts index a2344a9..a916b71 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -19,12 +19,22 @@ export type ChainKey = | "cardano" | "solana" | "stellar" + | "xrpl" | "aptos" | "sui" | "ton" | "tron" | "octra"; -export type ChainType = "evm" | "utxo" | "solana" | "stellar" | "move" | "ton" | "tron" | "octra"; +export type ChainType = + | "evm" + | "utxo" + | "solana" + | "stellar" + | "xrpl" + | "move" + | "ton" + | "tron" + | "octra"; export interface ChainInfo { readonly name: string; readonly symbol: string; diff --git a/src/index.ts b/src/index.ts index 9b67039..4ceb6d3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -32,6 +32,7 @@ export { Ecash } from "./chains/ecash.js"; export { Cardano } from "./chains/cardano.js"; export { Solana } from "./chains/solana.js"; export { Stellar } from "./chains/stellar.js"; +export { Xrpl } from "./chains/xrpl.js"; export { Aptos } from "./chains/aptos.js"; export { Sui } from "./chains/sui.js"; export { Ton } from "./chains/ton.js"; diff --git a/test/unit/chains.test.ts b/test/unit/chains.test.ts index c0f7368..841dd7d 100644 --- a/test/unit/chains.test.ts +++ b/test/unit/chains.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { readdirSync, readFileSync } from "node:fs"; import { decodeBase58 } from "../../src/core/base58.ts"; +import { XRP_ALPHABET } from "../../src/chains/xrpl.ts"; import { AddressValidationUnsupportedError, Arbitrum, @@ -18,6 +19,7 @@ import { Solana, Stellar, UnsupportedChainError, + Xrpl, chains, create, getChain, @@ -61,6 +63,7 @@ describe("chain registry", () => { "cardano", "solana", "stellar", + "xrpl", "aptos", "sui", "ton", @@ -96,6 +99,7 @@ describe("chain registry", () => { expect(create("cardano")).toBeInstanceOf(Cardano); expect(create("solana")).toBeInstanceOf(Solana); expect(create("stellar")).toBeInstanceOf(Stellar); + expect(create("xrpl")).toBeInstanceOf(Xrpl); expect(create("octra")).toBeInstanceOf(Octra); }); }); @@ -141,6 +145,18 @@ describe("chain metadata", () => { }); }); + it("carries XRP Ledger livenet metadata", () => { + expect(create("xrpl")).toMatchObject({ + key: "xrpl", + name: "XRP Ledger", + symbol: "XRP", + type: "xrpl", + bip44: 144, + caip2: "xrpl:0", + explorer: "https://livenet.xrpl.org", + }); + }); + it("does not invent unregistered Octra identifiers", () => { const octra = create("octra"); expect(octra).toMatchObject({ @@ -167,6 +183,8 @@ describe("chain resolution", () => { expect(getChain("xec")).toBeInstanceOf(Ecash); expect(getChain("ada")).toBeInstanceOf(Cardano); expect(getChain("xlm")).toBeInstanceOf(Stellar); + expect(getChain("xrp")).toBeInstanceOf(Xrpl); + expect(getChain("ripple")).toBeInstanceOf(Xrpl); expect(getChain("oct")).toBeInstanceOf(Octra); }); @@ -383,6 +401,76 @@ describe("Stellar address validation", () => { }); }); +describe("XRP Ledger address validation", () => { + const xrpl = create("xrpl"); + + /** + * Three accounts funded on mainnet, one of them a character short of the usual 34, + * then ACCOUNT_ZERO and ACCOUNT_ONE, whose leading zero bytes write the shortest + * addresses the format has. + */ + it("accepts classic addresses, down to the special accounts", () => { + for (const address of [ + "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh", + "rEb8TK3gBgk5auZkwc6sHnwrGVJH8DuaLh", + "rvYAfWj5gh67oV6fW32ZzP3Aw4Eubs59B", + "rrrrrrrrrrrrrrrrrrrrrhoLvTp", + "rrrrrrrrrrrrrrrrrrrrBZbvji", + ]) { + expect(xrpl.assertAddress(address), address).toBe(address); + } + }); + + /** The genesis account again, encoded with no tag, tag 42 and tag 2^32-1. */ + it("accepts X-addresses with and without a destination tag", () => { + for (const address of [ + "XVPcpSm47b1CZkf5AkKM9a84dQHe3m4sBhsrA4XtnBECTAc", + "XVPcpSm47b1CZkf5AkKM9a84dQHe3mTCLZc5ZAoh11sd5nY", + "XVPcpSm47b1CZkf5AkKM9a84dQHe3mX6ZcxNZjq2wMvKo8a", + ]) { + expect(xrpl.assertAddress(address), address).toBe(address); + } + }); + + /** + * Each one checksums over the genesis account and is a single field off a real + * X-address: testnet prefix, flag 2, flag 0 over a tagged payload, a 32-bit tag with + * a reserved byte set, prefix 0x06 0x44, prefix 0x05 0x45, then 34 payload bytes. + * ripple-address-codec takes three of them, and reads the fourth as a plain tag 42. + */ + it("rejects X-address payloads the format does not define", () => { + for (const address of [ + "TVK3SYvMLZR6rEtLDZh3saYHaqFSeMf6Hj2w1dpb7SnJgqn", + "XVPcpSm47b1CZkf5AkKM9a84dQHe3mX5CatGoVBtxjSUpBU", + "XVPcpSm47b1CZkf5AkKM9a84dQHe3m4wqRZUR1Lkjcu3iKc", + "XVPcpSm47b1CZkf5AkKM9a84dQHe3mTCLZc5ZAohtiHB4Ms", + "dHs1rzwkm7bFd92d7dLjs4DpfY26KPtNfcwddrqLvgxnPsz", + "XW6eoXeSRsSueWqkhDEWhYCu3jocZWakT3NFyZ8DtTori2z", + "fuekHBFCQpfsPFqUKJh6F9ZVrYZKP95jc2kv3srhA3LaXb", + ]) { + expect(() => xrpl.assertAddress(address), address).toThrow(InvalidAddressError); + } + }); + + /** + * Bitcoin, TRON and Solana, then an address holding a `0` the ledger has no digit + * for, the genesis address two characters short at 24 bytes, and a tagged + * X-address one digit long at 36. + */ + it("rejects the other base58 chains and near misses", () => { + for (const address of [ + "1BvBMSEYstWetqTFn5Au4m4GFg7xJaNVN2", + "TR7NHqjeKQxGTCi8q8ZY4pL8otSzgjLj6t", + "9WzDXwBbmkg8ZTbNMqUxvQRAyrZzDsGYdLVL9zYtAWWM", + "rHb9CJAWyB4rj91VRWn96DkukG4bwdty0h", + "rHb9CJAWyB4rj91VRWn96DkukG4bwdty", + "XVPcpSm47b1CZkf5AkKM9a84dQHe3mTCLZc5ZAoh11sd5nYr", + ]) { + expect(() => xrpl.assertAddress(address), address).toThrow(InvalidAddressError); + } + }); +}); + describe("Bitcoin address validation", () => { const bitcoin = create("bitcoin"); @@ -809,7 +897,7 @@ describe("Octra address validation", () => { }); it("keeps a live Octra address out of the other base58 chains", () => { - for (const key of ["bitcoin", "litecoin", "solana", "tron", "cardano"] as const) { + for (const key of ["bitcoin", "litecoin", "solana", "tron", "cardano", "xrpl"] as const) { expect(() => create(key).assertAddress(live), key).toThrow(InvalidAddressError); } }); @@ -825,6 +913,22 @@ describe("base58 decoding", () => { expect(decodeBase58("z".repeat(36), 35)).toBeUndefined(); expect(decodeBase58("1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa", 35)).toHaveLength(25); }); + + /** + * The alphabet decides what the bytes are, not whether there are any: the genesis + * address opens on 0x00 under the ledger's digits and on 0x7a under Bitcoin's, and + * ACCOUNT_ZERO's 21 leading `r` are zero bytes only where `r` is the zero digit. + */ + it("reads a string against the alphabet it was given", () => { + const genesis = "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh"; + + expect(decodeBase58(genesis, 48, XRP_ALPHABET)?.[0]).toBe(0x00); + expect(decodeBase58(genesis, 48)?.[0]).toBe(0x7a); + expect(decodeBase58("rrrrrrrrrrrrrrrrrrrrrhoLvTp", 48, XRP_ALPHABET)?.slice(0, 21)).toEqual( + new Uint8Array(21), + ); + expect(decodeBase58("rrrrrrrrrrrrrrrrrrrrrhoLvTp", 48)?.[0]).not.toBe(0x00); + }); }); describe("address identification", () => { @@ -893,6 +997,20 @@ describe("address identification", () => { } }); + it("attributes classic and X-addresses to the XRP Ledger alone", () => { + for (const address of [ + "rHb9CJAWyB4rj91VRWn96DkukG4bwdtyTh", + "XVPcpSm47b1CZkf5AkKM9a84dQHe3mTCLZc5ZAoh11sd5nY", + ]) { + const { matches } = identify(address); + + expect( + matches.map((chain) => chain.key), + address, + ).toEqual(["xrpl"]); + } + }); + it("attributes a Shelley address to Cardano alone", () => { const { matches } = identify("addr1vx2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzers66hrl8"); diff --git a/test/unit/mcp.test.ts b/test/unit/mcp.test.ts index 0216468..8204c8b 100644 --- a/test/unit/mcp.test.ts +++ b/test/unit/mcp.test.ts @@ -146,7 +146,7 @@ describe("chains MCP server", () => { expect(response.isError).not.toBe(true); const [part] = response.content as Array<{ text: string }>; - expect(part?.text).toContain("matches 13 of 25 checked chains"); + expect(part?.text).toContain("matches 13 of 26 checked chains"); expect(part?.text).toContain("evm (13): ethereum, base, arbitrum"); expect(part?.text).toContain("does not prove the address is used"); expect(part?.text).not.toContain("Not checked"); @@ -165,7 +165,7 @@ describe("chains MCP server", () => { }); const [part] = response.content as Array<{ text: string }>; - expect(part?.text).toContain("matches 1 of 25 checked chains"); + expect(part?.text).toContain("matches 1 of 26 checked chains"); expect(part?.text).toContain("solana (1): solana"); expect(part?.text).not.toContain("utxo"); }); @@ -180,7 +180,7 @@ describe("chains MCP server", () => { expect(response.isError).not.toBe(true); const [part] = response.content as Array<{ text: string }>; - expect(part?.text).toContain('"nope" matches none of the 25 checked chains.'); + expect(part?.text).toContain('"nope" matches none of the 26 checked chains.'); expect(part?.text).not.toContain("does not prove"); expect(part?.text).not.toContain("Not checked"); }); @@ -202,7 +202,7 @@ describe("chains MCP server", () => { const [narrowed] = identified.content as Array<{ text: string }>; for (const character of invisible) expect(narrowed?.text).not.toContain(character); expect(narrowed?.text.split("\n")).toHaveLength(1); - expect(narrowed?.text).toContain("matches none of the 25 checked chains."); + expect(narrowed?.text).toContain("matches none of the 26 checked chains."); const validated = await client.callTool({ name: "chains_validate_address", @@ -237,7 +237,7 @@ describe("chains MCP server", () => { const all = await client.callTool({ name: "chains_list", arguments: {} }); expect(all.isError).not.toBe(true); const [listing] = all.content as Array<{ text: string }>; - expect(listing?.text).toContain("25 chains registered."); + expect(listing?.text).toContain("26 chains registered."); expect(listing?.text).toContain("litecoin LTC utxo Litecoin"); expect(listing?.text).toContain("cardano ADA utxo Cardano"); expect(listing?.text).toContain("pepecoin PEP utxo Pepecoin"); @@ -245,7 +245,7 @@ describe("chains MCP server", () => { expect(listing?.text).toContain("bitcoin BTC utxo Bitcoin"); expect(listing?.text).toContain("stellar XLM stellar Stellar"); expect(listing?.text).toContain( - "Families: evm, utxo, solana, stellar, move, ton, tron, octra.", + "Families: evm, utxo, solana, stellar, xrpl, move, ton, tron, octra.", ); const filtered = await client.callTool({