xahau-address-codec
Version:
encodes/decodes base58 encoded XAH Ledger identifiers
260 lines (223 loc) • 7.57 kB
text/typescript
/**
* Codec class
*/
import { base58xrp, BytesCoder } from '@scure/base'
import { sha256 } from '@xrplf/isomorphic/sha256'
import { arrayEqual, concatArgs, ByteArray } from './utils'
class Codec {
private readonly _sha256: (bytes: ByteArray) => Uint8Array
private readonly _codec: BytesCoder
public constructor(options: { sha256: (bytes: ByteArray) => Uint8Array }) {
this._sha256 = options.sha256
this._codec = base58xrp
}
/**
* Encoder.
*
* @param bytes - Uint8Array of data to encode.
* @param opts - Options object including the version bytes and the expected length of the data to encode.
*/
public encode(
bytes: ByteArray,
opts: {
versions: number[]
expectedLength: number
},
): string {
const versions = opts.versions
return this._encodeVersioned(bytes, versions, opts.expectedLength)
}
/**
* Decoder.
*
* @param base58string - Base58Check-encoded string to decode.
* @param opts - Options object including the version byte(s) and the expected length of the data after decoding.
*/
/* eslint-disable max-lines-per-function --
* TODO refactor */
public decode(
base58string: string,
opts: {
versions: Array<number | number[]>
expectedLength?: number
versionTypes?: ['ed25519', 'secp256k1']
},
): {
version: number[]
bytes: Uint8Array
type: 'ed25519' | 'secp256k1' | null
} {
const versions = opts.versions
const types = opts.versionTypes
const withoutSum = this.decodeChecked(base58string)
if (versions.length > 1 && !opts.expectedLength) {
throw new Error(
'expectedLength is required because there are >= 2 possible versions',
)
}
const versionLengthGuess =
typeof versions[0] === 'number' ? 1 : versions[0].length
const payloadLength =
opts.expectedLength ?? withoutSum.length - versionLengthGuess
const versionBytes = withoutSum.slice(0, -payloadLength)
const payload = withoutSum.slice(-payloadLength)
for (let i = 0; i < versions.length; i++) {
/* eslint-disable @typescript-eslint/consistent-type-assertions --
* TODO refactor */
const version: number[] = Array.isArray(versions[i])
? (versions[i] as number[])
: [versions[i] as number]
if (arrayEqual(versionBytes, version)) {
return {
version,
bytes: payload,
type: types ? types[i] : null,
}
}
/* eslint-enable @typescript-eslint/consistent-type-assertions */
}
throw new Error(
'version_invalid: version bytes do not match any of the provided version(s)',
)
}
public encodeChecked(bytes: ByteArray): string {
const check = this._sha256(this._sha256(bytes)).slice(0, 4)
return this._encodeRaw(Uint8Array.from(concatArgs(bytes, check)))
}
public decodeChecked(base58string: string): Uint8Array {
const intArray = this._decodeRaw(base58string)
if (intArray.byteLength < 5) {
throw new Error('invalid_input_size: decoded data must have length >= 5')
}
if (!this._verifyCheckSum(intArray)) {
throw new Error('checksum_invalid')
}
return intArray.slice(0, -4)
}
private _encodeVersioned(
bytes: ByteArray,
versions: number[],
expectedLength: number,
): string {
if (!checkByteLength(bytes, expectedLength)) {
throw new Error(
'unexpected_payload_length: bytes.length does not match expectedLength.' +
' Ensure that the bytes are a Uint8Array.',
)
}
return this.encodeChecked(concatArgs(versions, bytes))
}
private _encodeRaw(bytes: ByteArray): string {
return this._codec.encode(Uint8Array.from(bytes))
}
/* eslint-enable max-lines-per-function */
private _decodeRaw(base58string: string): Uint8Array {
return this._codec.decode(base58string)
}
private _verifyCheckSum(bytes: ByteArray): boolean {
const computed = this._sha256(this._sha256(bytes.slice(0, -4))).slice(0, 4)
const checksum = bytes.slice(-4)
return arrayEqual(computed, checksum)
}
}
/**
* XAH codec
*/
// base58 encodings: https://xrpl.org/base58-encodings.html
// Account address (20 bytes)
const ACCOUNT_ID = 0
// Account public key (33 bytes)
const ACCOUNT_PUBLIC_KEY = 0x23
// 33; Seed value (for secret keys) (16 bytes)
const FAMILY_SEED = 0x21
// 28; Validation public key (33 bytes)
const NODE_PUBLIC = 0x1c
// [1, 225, 75]
const ED25519_SEED = [0x01, 0xe1, 0x4b]
const codecOptions = {
sha256,
}
const codecWithXrpAlphabet = new Codec(codecOptions)
export const codec = codecWithXrpAlphabet
// entropy is a Uint8Array of size 16
// type is 'ed25519' or 'secp256k1'
export function encodeSeed(
entropy: ByteArray,
type: 'ed25519' | 'secp256k1',
): string {
if (!checkByteLength(entropy, 16)) {
throw new Error('entropy must have length 16')
}
const opts = {
expectedLength: 16,
// for secp256k1, use `FAMILY_SEED`
versions: type === 'ed25519' ? ED25519_SEED : [FAMILY_SEED],
}
// prefixes entropy with version bytes
return codecWithXrpAlphabet.encode(entropy, opts)
}
export function decodeSeed(
seed: string,
opts: {
versionTypes: ['ed25519', 'secp256k1']
versions: Array<number | number[]>
expectedLength: number
} = {
versionTypes: ['ed25519', 'secp256k1'],
versions: [ED25519_SEED, FAMILY_SEED],
expectedLength: 16,
},
): {
version: number[]
bytes: Uint8Array
type: 'ed25519' | 'secp256k1' | null
} {
return codecWithXrpAlphabet.decode(seed, opts)
}
export function encodeAccountID(bytes: ByteArray): string {
const opts = { versions: [ACCOUNT_ID], expectedLength: 20 }
return codecWithXrpAlphabet.encode(bytes, opts)
}
/* eslint-disable import/no-unused-modules ---
* unclear why this is aliased but we should keep it in case someone else is
* importing it with the aliased name */
export const encodeAddress = encodeAccountID
/* eslint-enable import/no-unused-modules */
export function decodeAccountID(accountId: string): Uint8Array {
const opts = { versions: [ACCOUNT_ID], expectedLength: 20 }
return codecWithXrpAlphabet.decode(accountId, opts).bytes
}
/* eslint-disable import/no-unused-modules ---
* unclear why this is aliased but we should keep it in case someone else is
* importing it with the aliased name */
export const decodeAddress = decodeAccountID
/* eslint-enable import/no-unused-modules */
export function decodeNodePublic(base58string: string): Uint8Array {
const opts = { versions: [NODE_PUBLIC], expectedLength: 33 }
return codecWithXrpAlphabet.decode(base58string, opts).bytes
}
export function encodeNodePublic(bytes: ByteArray): string {
const opts = { versions: [NODE_PUBLIC], expectedLength: 33 }
return codecWithXrpAlphabet.encode(bytes, opts)
}
export function encodeAccountPublic(bytes: ByteArray): string {
const opts = { versions: [ACCOUNT_PUBLIC_KEY], expectedLength: 33 }
return codecWithXrpAlphabet.encode(bytes, opts)
}
export function decodeAccountPublic(base58string: string): Uint8Array {
const opts = { versions: [ACCOUNT_PUBLIC_KEY], expectedLength: 33 }
return codecWithXrpAlphabet.decode(base58string, opts).bytes
}
export function isValidClassicAddress(address: string): boolean {
try {
decodeAccountID(address)
} catch (_error) {
return false
}
return true
}
function checkByteLength(bytes: ByteArray, expectedLength: number): boolean {
return 'byteLength' in bytes
? bytes.byteLength === expectedLength
: bytes.length === expectedLength
}