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 whose address is a key of a known byte length
- `src/core/base58.ts` decodes base58 for chains that check the bytes behind an address
- `src/chains/*.ts` is one concrete blockchain class per file
- `src/index.ts` is the public API and the registration entrypoint
- `src/cli.ts` plus `src/commands/*.ts` is the citty CLI: `info`, `resolve`, `validate`, `identify`, `list`, `mcp`
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,4 +165,4 @@ Optional fields stay empty when the chain has no registered value. Octra has no

## Supported chains

`eth`, `base`, `arbitrum`, `optimism`, `polygon`, `bsc`, `avalanche`, `fantom`, `gnosis`, `linea`, `zksync`, `scroll`, `bera`, `bitcoin`, `litecoin`, `solana`, `aptos`, `sui`, `ton`, `tron`, and `oct`.
`eth`, `base`, `arbitrum`, `optimism`, `polygon`, `bsc`, `avalanche`, `fantom`, `gnosis`, `linea`, `zksync`, `scroll`, `bera`, `bitcoin`, `litecoin`, `cardano`, `solana`, `aptos`, `sui`, `ton`, `tron`, and `oct`.
59 changes: 59 additions & 0 deletions src/chains/cardano.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { decodeBase58 } from "../core/base58.js";
import { Chain } from "../core/chain.js";
import { InvalidAddressError } from "../core/errors.js";
import { register } from "../core/registry.js";

/**
* CIP-19 Shelley addresses under the mainnet prefix. Cardano waives BIP-173's
* 90-character cap, so the bounds come from the 29 to 57 byte payloads.
*/
const SHELLEY_ADDRESS =
/^(addr1[qpzry9x8gf2tvdw0s3jn54khce6mua7l]{53,98}|ADDR1[QPZRY9X8GF2TVDW0S3JN54KHCE6MUA7L]{53,98})$/;
Comment thread
oritwoen marked this conversation as resolved.

/** A stake address is always one 29-byte payload, so one data-part length. */
const STAKE_ADDRESS =
/^(stake1[qpzry9x8gf2tvdw0s3jn54khce6mua7l]{53}|STAKE1[QPZRY9X8GF2TVDW0S3JN54KHCE6MUA7L]{53})$/;

/**
* The Byron envelope: array(2), tag(24), bytes opening as the three-item
* array with its 28-byte root, then a CRC head matching the bytes it
* claims. Attributes, type and the CRC value stay unparsed on purpose.
*/
function isByronEnvelope(decoded: Uint8Array): boolean {
if (decoded[0] !== 0x82 || decoded[1] !== 0xd8 || decoded[2] !== 0x18 || decoded[3] !== 0x58) {
return false;
}
const payloadLength = decoded[4] ?? 0;
if (payloadLength < 33) return false;
if (decoded[5] !== 0x83 || decoded[6] !== 0x58 || decoded[7] !== 0x1c) return false;
const head = decoded[5 + payloadLength];
if (head === undefined) return false;
const crcBytes = head <= 0x17 ? 1 : head === 0x18 ? 2 : head === 0x19 ? 3 : head === 0x1a ? 5 : 0;
return crcBytes > 0 && decoded.length === 5 + payloadLength + crcBytes;
}

export class Cardano extends Chain {
static readonly key = "cardano" as const;
readonly type = "utxo" as const;
readonly name = "Cardano";
readonly symbol = "ADA";
readonly explorer = "https://cardanoscan.io";
readonly bip44 = 1815;
readonly caip2 = "cip34:1-764824073";

/**
* A format check: the bech32 checksum and the Byron CRC stay unverified,
* and a Byron testnet address passes because its network hides in a CBOR
* attribute this check does not open.
*/
override assertAddress(address: string): string {
const decoded = decodeBase58(address, 128);
const byron = decoded !== undefined && isByronEnvelope(decoded);
if (!byron && !SHELLEY_ADDRESS.test(address) && !STAKE_ADDRESS.test(address)) {
throw new InvalidAddressError(this.key, address);
}
return address;
}
}

register(Cardano);
1 change: 1 addition & 0 deletions src/chains/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import "./scroll.js";
import "./berachain.js";
import "./bitcoin.js";
import "./litecoin.js";
import "./cardano.js";
import "./solana.js";
import "./aptos.js";
import "./sui.js";
Expand Down
1 change: 1 addition & 0 deletions src/core/resolve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ const aliases: Readonly<Record<string, ChainKey>> = {
berachain: "bera",
btc: "bitcoin",
ltc: "litecoin",
ada: "cardano",
sol: "solana",
apt: "aptos",
trx: "tron",
Expand Down
1 change: 1 addition & 0 deletions src/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export type ChainKey =
| "bera"
| "bitcoin"
| "litecoin"
| "cardano"
| "solana"
| "aptos"
| "sui"
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export { Scroll } from "./chains/scroll.js";
export { Berachain } from "./chains/berachain.js";
export { Bitcoin } from "./chains/bitcoin.js";
export { Litecoin } from "./chains/litecoin.js";
export { Cardano } from "./chains/cardano.js";
export { Solana } from "./chains/solana.js";
export { Aptos } from "./chains/aptos.js";
export { Sui } from "./chains/sui.js";
Expand Down
98 changes: 98 additions & 0 deletions test/unit/chains.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
AddressValidationUnsupportedError,
Arbitrum,
Bitcoin,
Cardano,
Chain,
ChainsError,
Ethereum,
Expand Down Expand Up @@ -52,6 +53,7 @@ describe("chain registry", () => {
"bera",
"bitcoin",
"litecoin",
"cardano",
"solana",
"aptos",
"sui",
Expand All @@ -75,6 +77,7 @@ describe("chain registry", () => {
expect(create("arbitrum")).toBeInstanceOf(Arbitrum);
expect(create("bitcoin")).toBeInstanceOf(Bitcoin);
expect(create("litecoin")).toBeInstanceOf(Litecoin);
expect(create("cardano")).toBeInstanceOf(Cardano);
expect(create("solana")).toBeInstanceOf(Solana);
expect(create("oct")).toBeInstanceOf(Octra);
});
Expand Down Expand Up @@ -130,6 +133,7 @@ describe("chain resolution", () => {
expect(getChain("matic").key).toBe("polygon");
expect(getChain("BTC")).toBeInstanceOf(Bitcoin);
expect(getChain("ltc")).toBeInstanceOf(Litecoin);
expect(getChain("ada")).toBeInstanceOf(Cardano);
expect(getChain("octra")).toBeInstanceOf(Octra);
});

Expand Down Expand Up @@ -397,6 +401,94 @@ describe("Litecoin address validation", () => {
});
});

describe("Cardano address validation", () => {
const cardano = create("cardano");
/** The 114-character Byron bootstrap example from CIP-19. */
const byron =
"37btjrVyb4KDXBNC4haBVPCrro8AQPHwvCMp3RFhhSVWwfFmZ6wwzSK6JK1hY6wHNmtrpTf1kdbva8TCneM2YsiXT7mrzT21EacHnPpz5YyUdj64na";

/** Payment types 0 through 7, then both stake credential kinds. */
it("accepts the CIP-19 mainnet vectors", () => {
for (const address of [
"addr1qx2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzer3n0d3vllmyqwsx5wktcd8cc3sq835lu7drv2xwl2wywfgse35a3x",
"addr1z8phkx6acpnf78fuvxn0mkew3l0fd058hzquvz7w36x4gten0d3vllmyqwsx5wktcd8cc3sq835lu7drv2xwl2wywfgs9yc0hh",
"addr1yx2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzerkr0vd4msrxnuwnccdxlhdjar77j6lg0wypcc9uar5d2shs2z78ve",
"addr1x8phkx6acpnf78fuvxn0mkew3l0fd058hzquvz7w36x4gt7r0vd4msrxnuwnccdxlhdjar77j6lg0wypcc9uar5d2shskhj42g",
"addr1gx2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzer5pnz75xxcrzqf96k",
"addr128phkx6acpnf78fuvxn0mkew3l0fd058hzquvz7w36x4gtupnz75xxcrtw79hu",
"addr1vx2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzers66hrl8",
"addr1w8phkx6acpnf78fuvxn0mkew3l0fd058hzquvz7w36x4gtcyjy7wx",
"stake1uyehkck0lajq8gr28t9uxnuvgcqrc6070x3k9r8048z8y5gh6ffgw",
"stake178phkx6acpnf78fuvxn0mkew3l0fd058hzquvz7w36x4gtcccycj5",
]) {
expect(cardano.assertAddress(address)).toBe(address);
}
});

it("accepts uppercase, which QR encoders emit", () => {
expect(
cardano.assertAddress("ADDR1VX2FXV2UMYHTTKXYXP8X0DLPDT3K6CWNG5PXJ3JHSYDZERS66HRL8"),
).toBeTruthy();
expect(
cardano.assertAddress("STAKE1UYEHKCK0LAJQ8GR28T9UXNUVGCQRC6070X3K9R8048Z8Y5GH6FFGW"),
).toBeTruthy();
});

it("rejects mixed case, which BIP-173 makes invalid", () => {
expect(() =>
cardano.assertAddress("addr1VX2FXV2UMYHTTKXYXP8X0DLPDT3K6CWNG5PXJ3JHSYDZERS66HRL8"),
).toThrow(InvalidAddressError);
});

it("rejects the testnet prefixes", () => {
expect(() =>
cardano.assertAddress("addr_test1vz2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzerspjrlsz"),
).toThrow(InvalidAddressError);
expect(() =>
cardano.assertAddress("stake_test1uqehkck0lajq8gr28t9uxnuvgcqrc6070x3k9r8048z8y5gssrtvn"),
).toThrow(InvalidAddressError);
});

/**
* The CIP-19 example, then a synthetic minimal envelope, whose base58
* lands on the familiar Ae2 prefix purely by construction.
*/
it("accepts a Byron bootstrap address", () => {
expect(cardano.assertAddress(byron)).toBe(byron);
expect(
cardano.assertAddress("Ae2tdPwUPEYwWS5R2H6DTA2XJnBULNKZrrxpHiiEnkDzcdDg2rmtjdAXs6T"),
).toBeTruthy();
});

/**
* The first three fail the prefix; the crafted trio then dies one gate at
* a time: payload below the 33-byte minimum, wrong array opener, and a
* CRC head claiming more bytes than remain.
*/
it("rejects base58 that is not a Byron CBOR envelope", () => {
expect(() => cardano.assertAddress("11111111111111111111111111111111")).toThrow(
InvalidAddressError,
);
expect(() => cardano.assertAddress("LYhttvnKawAv6RcHQ4eBkNtifuiEA99PFe")).toThrow(
InvalidAddressError,
);
expect(() => cardano.assertAddress(byron.slice(0, -1))).toThrow(InvalidAddressError);
expect(() => cardano.assertAddress("5xb5UCMiej")).toThrow(InvalidAddressError);
expect(() =>
cardano.assertAddress("Ae2tdPwUXpBWfnybBCEByAo5PB5GWTopJ4cehzSQENMZ4yKAWcVB4phhGEP"),
).toThrow(InvalidAddressError);
expect(() =>
cardano.assertAddress("VhLXUZmS1gXF9DUMPMU6SdiQxAmT6brEid4taqdutAgEG3ewdw55Zh29"),
).toThrow(InvalidAddressError);
});

it("keeps Cardano addresses out of the other base58 chains", () => {
for (const key of ["bitcoin", "litecoin", "solana"] as const) {
expect(() => create(key).assertAddress(byron)).toThrow(InvalidAddressError);
}
});
});

describe("TRON address validation", () => {
const tron = create("tron");

Expand Down Expand Up @@ -540,6 +632,12 @@ describe("address identification", () => {
expect(matches.map((chain) => chain.key)).toEqual(["aptos", "sui"]);
});

it("attributes a Shelley address to Cardano alone", () => {
const { matches } = identify("addr1vx2fxv2umyhttkxyxp8x0dlpdt3k6cwng5pxj3jhsydzers66hrl8");

expect(matches.map((chain) => chain.key)).toEqual(["cardano"]);
});

/**
* The System Program sat inside Bitcoin's old character-length window, so
* identify used to report a false bitcoin match here. Decoding settles it.
Expand Down
9 changes: 5 additions & 4 deletions test/unit/mcp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,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 21 checked chains");
expect(part?.text).toContain("matches 13 of 22 checked chains");
expect(part?.text).toContain("evm (13): eth, base, arbitrum");
expect(part?.text).toContain("does not prove the address is used");
expect(part?.text).not.toContain("Not checked");
Expand All @@ -151,7 +151,7 @@ describe("chains MCP server", () => {
});

const [part] = response.content as Array<{ text: string }>;
expect(part?.text).toContain("matches 1 of 21 checked chains");
expect(part?.text).toContain("matches 1 of 22 checked chains");
expect(part?.text).toContain("solana (1): solana");
expect(part?.text).not.toContain("utxo");
});
Expand All @@ -166,7 +166,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 21 checked chains.");
expect(part?.text).toContain("nope matches none of the 22 checked chains.");
expect(part?.text).not.toContain("does not prove");
expect(part?.text).not.toContain("Not checked");
});
Expand All @@ -177,8 +177,9 @@ 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("21 chains registered.");
expect(listing?.text).toContain("22 chains registered.");
expect(listing?.text).toContain("litecoin LTC utxo Litecoin");
expect(listing?.text).toContain("cardano ADA utxo Cardano");
expect(listing?.text).toContain("bitcoin BTC utxo Bitcoin");
expect(listing?.text).toContain("Families: evm, utxo, solana, move, ton, tron, octra.");

Expand Down
Loading