diff --git a/src/__fixtures__/caip-types.ts b/src/__fixtures__/caip-types.ts new file mode 100644 index 00000000..b0af50db --- /dev/null +++ b/src/__fixtures__/caip-types.ts @@ -0,0 +1,35 @@ +export const CAIP_CHAIN_ID_FIXTURES = [ + 'eip155:1', + 'bip122:000000000019d6689c085ae165831e93', + 'bip122:12a765e31ffd4059bada1e25190f6e98', + 'bip122:fdbe99b90c90bae7505796461471d89a', + 'cosmos:cosmoshub-2', + 'cosmos:cosmoshub-3', + 'cosmos:Binance-Chain-Tigris', + 'cosmos:iov-mainnet', + 'starknet:SN_GOERLI', + 'lip9:9ee11e9df416b18b', + 'chainstd:8c3444cf8970a9e41a706fab93e7a6c4', +] as const; + +export const CAIP_NAMESPACE_FIXTURES = Array.from( + new Set(CAIP_CHAIN_ID_FIXTURES.map((value) => value.split(':')[0])), +); + +export const CAIP_REFERENCE_FIXTURES = Array.from( + new Set(CAIP_CHAIN_ID_FIXTURES.map((value) => value.split(':')[1])), +); + +export const CAIP_ACCOUNT_ID_FIXTURES = [ + 'eip155:1:0xab16a96D359eC26a11e2C2b3d8f8B8942d5Bfcdb', + 'bip122:000000000019d6689c085ae165831e93:128Lkh3S7CkDTBZ8W7BbpsN3YYizJMp8p6', + 'cosmos:cosmoshub-3:cosmos1t2uflqwqe0fsj0shcfkrvpukewcw40yjj6hdc0', + 'polkadot:b0a8d493285c2df73290dfb7e61f870f:5hmuyxw9xdgbpptgypokw4thfyoe3ryenebr381z9iaegmfy', + 'starknet:SN_GOERLI:0x02dd1b492765c064eac4039e3841aa5f382773b598097a40073bd8b48170ab57', + 'chainstd:8c3444cf8970a9e41a706fab93e7a6c4:6d9b0b4b9994e8a6afbd3dc3ed983cd51c755afb27cd1dc7825ef59c134a39f7', + 'hedera:mainnet:0.0.1234567890-zbhlt', +] as const; + +export const CAIP_ACCOUNT_ADDRESS_FIXTURES = Array.from( + new Set(CAIP_ACCOUNT_ID_FIXTURES.map((value) => value.split(':')[2])), +); diff --git a/src/__fixtures__/index.ts b/src/__fixtures__/index.ts index 54588564..39d2d2b4 100644 --- a/src/__fixtures__/index.ts +++ b/src/__fixtures__/index.ts @@ -1,4 +1,5 @@ export * from './bytes'; +export * from './caip-types'; export * from './coercions'; export * from './json'; export * from './numbers'; diff --git a/src/caip-types.test-d.ts b/src/caip-types.test-d.ts new file mode 100644 index 00000000..5b94402a --- /dev/null +++ b/src/caip-types.test-d.ts @@ -0,0 +1,52 @@ +import { expectAssignable, expectNotAssignable } from 'tsd'; + +import type { + CaipAccountAddress, + CaipAccountId, + CaipChainId, + CaipNamespace, + CaipReference, +} from '.'; + +const embeddedString = 'test'; + +// Valid caip strings: + +expectAssignable('namespace:reference'); +expectAssignable('namespace:'); +expectAssignable(':reference'); +expectAssignable(`${embeddedString}:${embeddedString}`); + +expectAssignable('string'); +expectAssignable(`${embeddedString}`); + +expectAssignable('string'); +expectAssignable(`${embeddedString}`); + +expectAssignable('namespace:reference:accountAddress'); +expectAssignable('namespace:reference:'); +expectAssignable(':reference:accountAddress'); +expectAssignable( + `${embeddedString}:${embeddedString}:${embeddedString}`, +); + +expectAssignable('string'); +expectAssignable(`${embeddedString}`); + +// Not valid caip strings: + +expectAssignable('namespace:😀'); +expectAssignable('😀:reference'); +expectNotAssignable(0); +expectNotAssignable('🙃'); + +expectNotAssignable(0); + +expectNotAssignable(0); + +expectAssignable('namespace:reference:😀'); +expectAssignable('😀:reference:accountAddress'); +expectNotAssignable(0); +expectNotAssignable('🙃'); + +expectNotAssignable(0); diff --git a/src/caip-types.test.ts b/src/caip-types.test.ts new file mode 100644 index 00000000..3b5acc9d --- /dev/null +++ b/src/caip-types.test.ts @@ -0,0 +1,276 @@ +import { + CAIP_ACCOUNT_ADDRESS_FIXTURES, + CAIP_ACCOUNT_ID_FIXTURES, + CAIP_CHAIN_ID_FIXTURES, + CAIP_NAMESPACE_FIXTURES, + CAIP_REFERENCE_FIXTURES, +} from './__fixtures__'; +import { + isCaipAccountAddress, + isCaipAccountId, + isCaipChainId, + isCaipNamespace, + isCaipReference, + parseCaipAccountId, + parseCaipChainId, +} from './caip-types'; + +describe('isCaipChainId', () => { + it.each(CAIP_CHAIN_ID_FIXTURES)( + 'returns true for a valid chain id %s', + (id) => { + expect(isCaipChainId(id)).toBe(true); + }, + ); + + it.each([ + true, + false, + null, + undefined, + 1, + {}, + [], + '!@#$%^&*()', + 'a', + ':1', + '123:', + 'abC:1', + 'eip155', + 'eip155:', + 'eip155:1:2', + 'bip122', + 'bip122:', + 'bip122:000000000019d6689c085ae165831e93:2', + ])('returns false for an invalid chain id %s', (id) => { + expect(isCaipChainId(id)).toBe(false); + }); +}); + +describe('isCaipNamespace', () => { + it.each([...CAIP_NAMESPACE_FIXTURES])( + 'returns true for a valid namespace %s', + (id) => { + expect(isCaipNamespace(id)).toBe(true); + }, + ); + + it.each([true, false, null, undefined, 1, {}, [], 'abC', '12', '123456789'])( + 'returns false for an invalid namespace %s', + (id) => { + expect(isCaipNamespace(id)).toBe(false); + }, + ); +}); + +describe('isCaipReference', () => { + it.each([...CAIP_REFERENCE_FIXTURES])( + 'returns true for a valid reference %s', + (id) => { + expect(isCaipReference(id)).toBe(true); + }, + ); + + it.each([ + true, + false, + null, + undefined, + 1, + {}, + [], + '', + '!@#$%^&*()', + Array(33).fill('0').join(''), + ])('returns false for an invalid reference %s', (id) => { + expect(isCaipReference(id)).toBe(false); + }); +}); + +describe('isCaipAccountId', () => { + it.each([...CAIP_ACCOUNT_ID_FIXTURES])( + 'returns true for a valid account id %s', + (id) => { + expect(isCaipAccountId(id)).toBe(true); + }, + ); + + it.each([ + true, + false, + null, + undefined, + 1, + {}, + [], + '', + '!@#$%^&*()', + 'foo', + 'eip155', + 'eip155:', + 'eip155:1', + 'eip155:1:', + 'eip155:1:0x0000000000000000000000000000000000000000:2', + 'bip122', + 'bip122:', + 'bip122:000000000019d6689c085ae165831e93', + 'bip122:000000000019d6689c085ae165831e93:', + 'bip122:000000000019d6689c085ae165831e93:0x0000000000000000000000000000000000000000:2', + ])('returns false for an invalid account id %s', (id) => { + expect(isCaipAccountId(id)).toBe(false); + }); +}); + +describe('isCaipAccountAddress', () => { + it.each([...CAIP_ACCOUNT_ADDRESS_FIXTURES])( + 'returns true for a valid account address %s', + (id) => { + expect(isCaipAccountAddress(id)).toBe(true); + }, + ); + + it.each([ + true, + false, + null, + undefined, + 1, + {}, + [], + '', + '!@#$%^&*()', + Array(129).fill('0').join(''), + ])('returns false for an invalid account address %s', (id) => { + expect(isCaipAccountAddress(id)).toBe(false); + }); +}); + +describe('parseCaipChainId', () => { + it('parses valid chain ids', () => { + expect(parseCaipChainId('eip155:1')).toMatchInlineSnapshot(` + { + "namespace": "eip155", + "reference": "1", + } + `); + + expect(parseCaipChainId('bip122:000000000019d6689c085ae165831e93')) + .toMatchInlineSnapshot(` + { + "namespace": "bip122", + "reference": "000000000019d6689c085ae165831e93", + } + `); + + expect(parseCaipChainId('cosmos:cosmoshub-3')).toMatchInlineSnapshot(` + { + "namespace": "cosmos", + "reference": "cosmoshub-3", + } + `); + + expect(parseCaipChainId('polkadot:b0a8d493285c2df73290dfb7e61f870f')) + .toMatchInlineSnapshot(` + { + "namespace": "polkadot", + "reference": "b0a8d493285c2df73290dfb7e61f870f", + } + `); + }); + + it.each([ + true, + false, + null, + undefined, + 1, + 'foo', + 'foobarbazquz:1', + 'foo:', + 'foo:foobarbazquzfoobarbazquzfoobarbazquzfoobarbazquzfoobarbazquzfoobarbazquz', + ])('throws for invalid input %s', (input) => { + expect(() => parseCaipChainId(input as any)).toThrow( + 'Invalid CAIP chain ID.', + ); + }); +}); + +describe('parseCaipAccountId', () => { + it('parses valid account ids', () => { + expect( + parseCaipAccountId('eip155:1:0xab16a96d359ec26a11e2c2b3d8f8b8942d5bfcdb'), + ).toMatchInlineSnapshot(` + { + "address": "0xab16a96d359ec26a11e2c2b3d8f8b8942d5bfcdb", + "chain": { + "namespace": "eip155", + "reference": "1", + }, + "chainId": "eip155:1", + } + `); + + expect( + parseCaipAccountId( + 'bip122:000000000019d6689c085ae165831e93:128Lkh3S7CkDTBZ8W7BbpsN3YYizJMp8p6', + ), + ).toMatchInlineSnapshot(` + { + "address": "128Lkh3S7CkDTBZ8W7BbpsN3YYizJMp8p6", + "chain": { + "namespace": "bip122", + "reference": "000000000019d6689c085ae165831e93", + }, + "chainId": "bip122:000000000019d6689c085ae165831e93", + } + `); + + expect( + parseCaipAccountId( + 'cosmos:cosmoshub-3:cosmos1t2uflqwqe0fsj0shcfkrvpukewcw40yjj6hdc0', + ), + ).toMatchInlineSnapshot(` + { + "address": "cosmos1t2uflqwqe0fsj0shcfkrvpukewcw40yjj6hdc0", + "chain": { + "namespace": "cosmos", + "reference": "cosmoshub-3", + }, + "chainId": "cosmos:cosmoshub-3", + } + `); + + expect( + parseCaipAccountId( + 'polkadot:b0a8d493285c2df73290dfb7e61f870f:5hmuyxw9xdgbpptgypokw4thfyoe3ryenebr381z9iaegmfy', + ), + ).toMatchInlineSnapshot(` + { + "address": "5hmuyxw9xdgbpptgypokw4thfyoe3ryenebr381z9iaegmfy", + "chain": { + "namespace": "polkadot", + "reference": "b0a8d493285c2df73290dfb7e61f870f", + }, + "chainId": "polkadot:b0a8d493285c2df73290dfb7e61f870f", + } + `); + }); + + it.each([ + true, + false, + null, + undefined, + 1, + 'foo', + 'foobarbazquz:1', + 'foo:', + 'foo:foobarbazquzfoobarbazquzfoobarbazquzfoobarbazquzfoobarbazquzfoobarbazquz', + 'eip155:1', + 'eip155:1:', + ])('throws for invalid input %s', (input) => { + expect(() => parseCaipAccountId(input as any)).toThrow( + 'Invalid CAIP account ID.', + ); + }); +}); diff --git a/src/caip-types.ts b/src/caip-types.ts new file mode 100644 index 00000000..bbd1b4d4 --- /dev/null +++ b/src/caip-types.ts @@ -0,0 +1,148 @@ +import type { Infer } from 'superstruct'; +import { is, pattern, string } from 'superstruct'; + +export const CAIP_CHAIN_ID_REGEX = + /^(?[-a-z0-9]{3,8}):(?[-_a-zA-Z0-9]{1,32})$/u; + +export const CAIP_NAMESPACE_REGEX = /^[-a-z0-9]{3,8}$/u; + +export const CAIP_REFERENCE_REGEX = /^[-_a-zA-Z0-9]{1,32}$/u; + +export const CAIP_ACCOUNT_ID_REGEX = + /^(?(?[-a-z0-9]{3,8}):(?[-_a-zA-Z0-9]{1,32})):(?[-.%a-zA-Z0-9]{1,128})$/u; + +export const CAIP_ACCOUNT_ADDRESS_REGEX = /^[-.%a-zA-Z0-9]{1,128}$/u; + +/** + * A CAIP-2 chain ID, i.e., a human-readable namespace and reference. + */ +export const CaipChainIdStruct = pattern(string(), CAIP_CHAIN_ID_REGEX); +export type CaipChainId = `${string}:${string}`; + +/** + * A CAIP-2 namespace, i.e., the first part of a CAIP chain ID. + */ +export const CaipNamespaceStruct = pattern(string(), CAIP_NAMESPACE_REGEX); +export type CaipNamespace = Infer; + +/** + * A CAIP-2 reference, i.e., the second part of a CAIP chain ID. + */ +export const CaipReferenceStruct = pattern(string(), CAIP_REFERENCE_REGEX); +export type CaipReference = Infer; + +/** + * A CAIP-10 account ID, i.e., a human-readable namespace, reference, and account address. + */ +export const CaipAccountIdStruct = pattern(string(), CAIP_ACCOUNT_ID_REGEX); +export type CaipAccountId = `${string}:${string}:${string}`; + +/** + * A CAIP-10 account address, i.e., the third part of the CAIP account ID. + */ +export const CaipAccountAddressStruct = pattern( + string(), + CAIP_ACCOUNT_ADDRESS_REGEX, +); +export type CaipAccountAddress = Infer; + +/** + * Check if the given value is a {@link CaipChainId}. + * + * @param value - The value to check. + * @returns Whether the value is a {@link CaipChainId}. + */ +export function isCaipChainId(value: unknown): value is CaipChainId { + return is(value, CaipChainIdStruct); +} + +/** + * Check if the given value is a {@link CaipNamespace}. + * + * @param value - The value to check. + * @returns Whether the value is a {@link CaipNamespace}. + */ +export function isCaipNamespace(value: unknown): value is CaipNamespace { + return is(value, CaipNamespaceStruct); +} + +/** + * Check if the given value is a {@link CaipReference}. + * + * @param value - The value to check. + * @returns Whether the value is a {@link CaipReference}. + */ +export function isCaipReference(value: unknown): value is CaipReference { + return is(value, CaipReferenceStruct); +} + +/** + * Check if the given value is a {@link CaipAccountId}. + * + * @param value - The value to check. + * @returns Whether the value is a {@link CaipAccountId}. + */ +export function isCaipAccountId(value: unknown): value is CaipAccountId { + return is(value, CaipAccountIdStruct); +} + +/** + * Check if a value is a {@link CaipAccountAddress}. + * + * @param value - The value to validate. + * @returns True if the value is a valid {@link CaipAccountAddress}. + */ +export function isCaipAccountAddress( + value: unknown, +): value is CaipAccountAddress { + return is(value, CaipAccountAddressStruct); +} + +/** + * Parse a CAIP-2 chain ID to an object containing the namespace and reference. + * This validates the CAIP-2 chain ID before parsing it. + * + * @param caipChainId - The CAIP-2 chain ID to validate and parse. + * @returns The parsed CAIP-2 chain ID. + */ +export function parseCaipChainId(caipChainId: CaipChainId): { + namespace: CaipNamespace; + reference: CaipReference; +} { + const match = CAIP_CHAIN_ID_REGEX.exec(caipChainId); + if (!match?.groups) { + throw new Error('Invalid CAIP chain ID.'); + } + + return { + namespace: match.groups.namespace as CaipNamespace, + reference: match.groups.reference as CaipReference, + }; +} + +/** + * Parse an CAIP-10 account ID to an object containing the chain ID, parsed chain ID, and account address. + * This validates the CAIP-10 account ID before parsing it. + * + * @param caipAccountId - The CAIP-10 account ID to validate and parse. + * @returns The parsed CAIP-10 account ID. + */ +export function parseCaipAccountId(caipAccountId: CaipAccountId): { + address: CaipAccountAddress; + chainId: CaipChainId; + chain: { namespace: CaipNamespace; reference: CaipReference }; +} { + const match = CAIP_ACCOUNT_ID_REGEX.exec(caipAccountId); + if (!match?.groups) { + throw new Error('Invalid CAIP account ID.'); + } + + return { + address: match.groups.accountAddress as CaipAccountAddress, + chainId: match.groups.chainId as CaipChainId, + chain: { + namespace: match.groups.namespace as CaipNamespace, + reference: match.groups.reference as CaipReference, + }, + }; +} diff --git a/src/index.ts b/src/index.ts index 094c4669..7e44f26a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,7 @@ export * from './assert'; export * from './base64'; export * from './bytes'; +export * from './caip-types'; export * from './checksum'; export * from './coercers'; export * from './collections';