Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ Chain (abstract)
├── Bitcoin
├── Solana
├── Stellar
├── Xrpl
├── Ton
├── Tron
└── Octra
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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`.
2 changes: 1 addition & 1 deletion packages/omp/extensions/chains.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
2 changes: 1 addition & 1 deletion packages/pi/extensions/chains.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
2 changes: 2 additions & 0 deletions src/chains/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand All @@ -47,6 +48,7 @@ export const builtins: readonly ChainConstructor[] = [
Cardano,
Solana,
Stellar,
Xrpl,
Aptos,
Sui,
Ton,
Expand Down
43 changes: 43 additions & 0 deletions src/chains/xrpl.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
2 changes: 1 addition & 1 deletion src/commands/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
16 changes: 12 additions & 4 deletions src/core/base58.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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);
}
Expand All @@ -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++;
}

Expand Down
2 changes: 2 additions & 0 deletions src/core/resolve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ const aliases: Readonly<Record<string, ChainKey>> = {
ada: "cardano",
sol: "solana",
xlm: "stellar",
xrp: "xrpl",
ripple: "xrpl",
apt: "aptos",
trx: "tron",
oct: "octra",
Expand Down
12 changes: 11 additions & 1 deletion src/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
120 changes: 119 additions & 1 deletion test/unit/chains.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -18,6 +19,7 @@ import {
Solana,
Stellar,
UnsupportedChainError,
Xrpl,
chains,
create,
getChain,
Expand Down Expand Up @@ -61,6 +63,7 @@ describe("chain registry", () => {
"cardano",
"solana",
"stellar",
"xrpl",
"aptos",
"sui",
"ton",
Expand Down Expand Up @@ -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);
});
});
Expand Down Expand Up @@ -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({
Expand All @@ -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);
});

Expand Down Expand Up @@ -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");

Expand Down Expand Up @@ -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);
}
});
Expand All @@ -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", () => {
Expand Down Expand Up @@ -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");

Expand Down
Loading
Loading