diff --git a/README.md b/README.md index 47274c4..4994ce8 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ # bytecodec -Typed JavaScript and TypeScript byte utilities for base64, base64url, hex, Z85, UTF-8 strings, unsigned BigInt conversion, JSON, gzip, concatenation, comparison, and byte-source normalization. The package ships tree-shakeable ESM plus CommonJS entry points and keeps the same API across Node, Bun, Deno, browsers, and edge runtimes. +Typed JavaScript and TypeScript byte utilities for base58, base58btc, base64, base64url, hex, Z85, UTF-8 strings, unsigned BigInt conversion, JSON, gzip, concatenation, comparison, and byte-source normalization. The package ships tree-shakeable ESM plus CommonJS entry points and keeps the same API across Node, Bun, Deno, browsers, and edge runtimes. ## Compatibility @@ -17,7 +17,7 @@ Typed JavaScript and TypeScript byte utilities for base64, base64url, hex, Z85, ## Goals -- Developer-friendly API for base64, base64url, hex, Z85, UTF-8, unsigned BigInt conversion, JSON, gzip, concat, equality, and byte normalization. +- Developer-friendly API for base58, base58btc, base64, base64url, hex, Z85, UTF-8, unsigned BigInt conversion, JSON, gzip, concat, equality, and byte normalization. - No runtime dependencies or bundler shims. - Tree-shakeable ESM by default with CommonJS compatibility and no side effects. - Returns copies for safety when normalizing inputs. @@ -60,6 +60,28 @@ const encoded = toBase64String(bytes) // string of base64 chars const decoded = fromBase64String(encoded) // Uint8Array ``` +### Base58 + +```js +import { toBase58String, fromBase58String } from '@sovereignbase/bytecodec' + +const bytes = new Uint8Array([104, 101, 108, 108, 111]) +const encoded = toBase58String(bytes) // "Cn8eVZg" +const decoded = fromBase58String(encoded) // Uint8Array +``` + +### Base58btc + +```js +import { toBase58BtcString, fromBase58BtcString } from '@sovereignbase/bytecodec' + +const bytes = new Uint8Array([104, 101, 108, 108, 111]) +const encoded = toBase58BtcString(bytes) // "zCn8eVZg" +const decoded = fromBase58BtcString(encoded) // Uint8Array +``` + +`base58btc` uses the Bitcoin base58 alphabet and adds the multibase `z` prefix. + ### CommonJS ```js @@ -184,7 +206,7 @@ const joined = concat([new Uint8Array([1, 2]), new Uint8Array([3, 4]), [5, 6]]) ### Node -Uses `Buffer.from` for base64 helpers, `TextEncoder` and `TextDecoder` when available with `Buffer` fallback for UTF-8, and `node:zlib` for gzip. +Uses pure JavaScript for base58/base58btc, `Buffer.from` for base64 helpers, `TextEncoder` and `TextDecoder` when available with `Buffer` fallback for UTF-8, and `node:zlib` for gzip. ### Bun @@ -196,7 +218,7 @@ Uses `TextEncoder`, `TextDecoder`, `btoa`, and `atob`. Gzip uses `CompressionStr ### Validation & errors -Validation failures throw `BytecodecError` instances with a `code` string, for example `BASE64URL_INVALID_LENGTH`, `BIGINT_UNSIGNED_EXPECTED`, `HEX_INVALID_CHARACTER`, `Z85_INVALID_BLOCK`, `BASE64_DECODER_UNAVAILABLE`, `UTF8_DECODER_UNAVAILABLE`, and `GZIP_COMPRESSION_UNAVAILABLE`. Messages are prefixed with `{@sovereignbase/bytecodec}`. +Validation failures throw `BytecodecError` instances with a `code` string, for example `BASE58_INVALID_CHARACTER`, `BASE58BTC_INVALID_PREFIX`, `BASE64URL_INVALID_LENGTH`, `BIGINT_UNSIGNED_EXPECTED`, `HEX_INVALID_CHARACTER`, `Z85_INVALID_BLOCK`, `BASE64_DECODER_UNAVAILABLE`, `UTF8_DECODER_UNAVAILABLE`, and `GZIP_COMPRESSION_UNAVAILABLE`. Messages are prefixed with `{@sovereignbase/bytecodec}`. ### Safety / copying semantics @@ -206,44 +228,48 @@ Validation failures throw `BytecodecError` instances with a `code` string, for e `npm test` covers: -- 75 unit tests -- 7 integration tests -- Node E2E: 23/23 passed in ESM and 23/23 passed in CommonJS -- Bun E2E: 23/23 passed in ESM and 23/23 passed in CommonJS -- Deno E2E: 23/23 passed in ESM -- Cloudflare Workers E2E: 23/23 passed in ESM -- Edge Runtime E2E: 23/23 passed in ESM +- 85 unit tests +- 9 integration tests +- Node E2E: 27/27 passed in ESM and 27/27 passed in CommonJS +- Bun E2E: 27/27 passed in ESM and 27/27 passed in CommonJS +- Deno E2E: 27/27 passed in ESM +- Cloudflare Workers E2E: 27/27 passed in ESM +- Edge Runtime E2E: 27/27 passed in ESM - Browser E2E: 5/5 passed in Chromium, Firefox, WebKit, mobile-chrome, and mobile-safari - Coverage gate: 100% statements, branches, functions, and lines ## Benchmarks -Latest local `npm run bench` run on 2026-03-27 with Node `v22.14.0 (win32 x64)`: - -| Benchmark | Result | -| ---------------- | ------------------------- | -| base64 encode | 979,522 ops/s (51.0 ms) | -| base64 decode | 1,825,737 ops/s (27.4 ms) | -| base64url encode | 407,973 ops/s (122.6 ms) | -| base64url decode | 560,991 ops/s (89.1 ms) | -| hex encode | 781,944 ops/s (63.9 ms) | -| hex decode | 806,002 ops/s (62.0 ms) | -| z85 encode | 170,125 ops/s (293.9 ms) | -| z85 decode | 1,141,472 ops/s (43.8 ms) | -| utf8 encode | 1,241,977 ops/s (40.3 ms) | -| utf8 decode | 2,610,407 ops/s (19.2 ms) | -| bigint encode | 490,692 ops/s (101.9 ms) | -| bigint decode | 428,938 ops/s (116.6 ms) | -| json encode | 588,066 ops/s (34.0 ms) | -| json decode | 603,058 ops/s (33.2 ms) | -| concat 3 buffers | 560,639 ops/s (89.2 ms) | -| toUint8Array | 6,292,910 ops/s (31.8 ms) | -| toArrayBuffer | 677,822 ops/s (295.1 ms) | -| toBufferSource | 7,465,472 ops/s (26.8 ms) | -| equals same | 2,217,064 ops/s (90.2 ms) | -| equals diff | 2,302,002 ops/s (86.9 ms) | -| gzip compress | 3,473 ops/s (115.2 ms) | -| gzip decompress | 4,753 ops/s (84.2 ms) | +Latest local `npm run bench` run on 2026-04-17 with Node `v22.14.0 (win32 x64)`. Each benchmark uses the same `5,000` operations: + +| Benchmark | Ops | Ms | Ms/Op | Ops/Sec | +| ---------------- | ----- | -------- | -------- | --------- | +| base58 encode | 5,000 | 378.548 | 0.075710 | 13,208 | +| base58 decode | 5,000 | 64.313 | 0.012863 | 77,745 | +| base58btc encode | 5,000 | 318.044 | 0.063609 | 15,721 | +| base58btc decode | 5,000 | 56.138 | 0.011228 | 89,066 | +| base64 encode | 5,000 | 16.971 | 0.003394 | 294,629 | +| base64 decode | 5,000 | 13.244 | 0.002649 | 377,541 | +| base64url encode | 5,000 | 23.162 | 0.004632 | 215,867 | +| base64url decode | 5,000 | 22.993 | 0.004599 | 217,454 | +| hex encode | 5,000 | 18.494 | 0.003699 | 270,361 | +| hex decode | 5,000 | 10.099 | 0.002020 | 495,084 | +| z85 encode | 5,000 | 65.417 | 0.013083 | 76,433 | +| z85 decode | 5,000 | 11.928 | 0.002386 | 419,171 | +| utf8 encode | 5,000 | 9.949 | 0.001990 | 502,583 | +| utf8 decode | 5,000 | 4.835 | 0.000967 | 1,034,105 | +| bigint encode | 5,000 | 17.098 | 0.003420 | 292,435 | +| bigint decode | 5,000 | 21.104 | 0.004221 | 236,922 | +| json encode | 5,000 | 10.640 | 0.002128 | 469,912 | +| json decode | 5,000 | 11.192 | 0.002238 | 446,740 | +| concat 3 buffers | 5,000 | 28.862 | 0.005772 | 173,240 | +| toUint8Array | 5,000 | 4.866 | 0.000973 | 1,027,475 | +| toArrayBuffer | 5,000 | 13.325 | 0.002665 | 375,229 | +| toBufferSource | 5,000 | 3.412 | 0.000682 | 1,465,373 | +| equals same | 5,000 | 9.302 | 0.001860 | 537,536 | +| equals diff | 5,000 | 5.908 | 0.001182 | 846,267 | +| gzip compress | 5,000 | 1370.000 | 0.274000 | 3,650 | +| gzip decompress | 5,000 | 1493.242 | 0.298648 | 3,348 | Command: `npm run bench` diff --git a/benchmark/bench.js b/benchmark/bench.js index 295df21..5866228 100644 --- a/benchmark/bench.js +++ b/benchmark/bench.js @@ -3,6 +3,8 @@ import { randomBytes } from 'node:crypto' import { concat, equals, + fromBase58BtcString, + fromBase58String, fromBase64String, fromBase64UrlString, fromBigInt, @@ -12,6 +14,8 @@ import { fromString, fromZ85String, toArrayBuffer, + toBase58BtcString, + toBase58String, toBase64String, toBase64UrlString, toBigInt, @@ -24,27 +28,98 @@ import { toZ85String, } from '../dist/index.js' -function formatOps(iterations, durationMs) { - const opsPerSec = Math.round((iterations / durationMs) * 1000) - const ms = durationMs.toFixed(1) - return `${opsPerSec.toLocaleString()} ops/s (${ms} ms)` -} +const OPERATIONS = 5_000 +const NAME_HEADER = 'benchmark' +const COLUMN_SEPARATOR = ' | ' -function bench(name, iterations, fn) { +function measure(iterations, fn) { const start = performance.now() for (let i = 0; i < iterations; i++) fn() - const duration = performance.now() - start - console.log(`${name.padEnd(18)} ${formatOps(iterations, duration)}`) + const durationMs = performance.now() - start + return toStats(iterations, durationMs) } -async function benchAsync(name, iterations, fn) { +async function measureAsync(iterations, fn) { const start = performance.now() for (let i = 0; i < iterations; i++) await fn() - const duration = performance.now() - start - console.log(`${name.padEnd(18)} ${formatOps(iterations, duration)}`) + const durationMs = performance.now() - start + return toStats(iterations, durationMs) +} + +function toStats(iterations, durationMs) { + return { + ops: iterations, + ms: durationMs, + msPerOp: durationMs / iterations, + opsPerSec: (iterations / durationMs) * 1000, + } +} + +function formatInt(value) { + return Math.round(value).toLocaleString('en-US') +} + +function formatMs(value) { + return value.toFixed(3) +} + +function formatMsPerOp(value) { + return value.toFixed(6) +} + +function formatResults(results) { + const nameWidth = Math.max( + NAME_HEADER.length, + ...results.map((result) => result.name.length) + ) + const opsWidth = Math.max( + 'ops'.length, + ...results.map((result) => formatInt(result.ops).length) + ) + const msWidth = Math.max( + 'ms'.length, + ...results.map((result) => formatMs(result.ms).length) + ) + const msPerOpWidth = Math.max( + 'ms/op'.length, + ...results.map((result) => formatMsPerOp(result.msPerOp).length) + ) + const opsPerSecWidth = Math.max( + 'ops/sec'.length, + ...results.map((result) => formatInt(result.opsPerSec).length) + ) + + const header = [ + NAME_HEADER.padEnd(nameWidth), + 'ops'.padStart(opsWidth), + 'ms'.padStart(msWidth), + 'ms/op'.padStart(msPerOpWidth), + 'ops/sec'.padStart(opsPerSecWidth), + ].join(COLUMN_SEPARATOR) + + const divider = [ + '-'.repeat(nameWidth), + '-'.repeat(opsWidth), + '-'.repeat(msWidth), + '-'.repeat(msPerOpWidth), + '-'.repeat(opsPerSecWidth), + ].join(COLUMN_SEPARATOR) + + const rows = results.map((result) => + [ + result.name.padEnd(nameWidth), + formatInt(result.ops).padStart(opsWidth), + formatMs(result.ms).padStart(msWidth), + formatMsPerOp(result.msPerOp).padStart(msPerOpWidth), + formatInt(result.opsPerSec).padStart(opsPerSecWidth), + ].join(COLUMN_SEPARATOR) + ) + + return [header, divider, ...rows].join('\n') } console.log('Benchmarking @sovereignbase/bytecodec...') +console.log(`Operations per benchmark: ${formatInt(OPERATIONS)}`) const sampleBytes = randomBytes(64) const sampleBytesDiff = Uint8Array.from(sampleBytes, (value, idx) => @@ -57,40 +132,124 @@ const sampleBigInt = 0x1234567890abcdef1234567890abcdefn const sampleBigIntBytes = fromBigInt(sampleBigInt) const sampleJson = { ok: true, count: 42, note: '@sovereignbase/bytecodec' } const sampleJsonBytes = fromJSON(sampleJson) +const base58 = toBase58String(sampleBytes) +const base58Btc = toBase58BtcString(sampleBytes) const base64 = toBase64String(sampleBytes) const base64Url = toBase64UrlString(sampleBytes) const hex = toHex(sampleBytes) const z85 = toZ85String(sampleBytes) const compressed = await toCompressed(sampleBytes) +const results = [] -bench('base64 encode', 50000, () => toBase64String(sampleBytes)) -bench('base64 decode', 50000, () => fromBase64String(base64)) -bench('base64url encode', 50000, () => toBase64UrlString(sampleBytes)) -bench('base64url decode', 50000, () => fromBase64UrlString(base64Url)) -bench('hex encode', 50000, () => toHex(sampleBytes)) -bench('hex decode', 50000, () => fromHex(hex)) -bench('z85 encode', 50000, () => toZ85String(sampleBytes)) -bench('z85 decode', 50000, () => fromZ85String(z85)) -bench('utf8 encode', 50000, () => fromString(sampleText)) -bench('utf8 decode', 50000, () => toString(sampleTextBytes)) -bench('bigint encode', 50000, () => fromBigInt(sampleBigInt)) -bench('bigint decode', 50000, () => toBigInt(sampleBigIntBytes)) -bench('json encode', 20000, () => fromJSON(sampleJson)) -bench('json decode', 20000, () => toJSON(sampleJsonBytes)) -bench('concat 3 buffers', 50000, () => - concat([sampleBytes, sampleBytes, sampleBytes]) -) -bench('toUint8Array', 200000, () => toUint8Array(sampleView)) -bench('toArrayBuffer', 200000, () => toArrayBuffer(sampleView)) -bench('toBufferSource', 200000, () => toBufferSource(sampleView)) -bench('equals same', 200000, () => equals(sampleBytes, sampleBytes)) -bench('equals diff', 200000, () => equals(sampleBytes, sampleBytesDiff)) - -await benchAsync('gzip compress', 400, async () => { - await toCompressed(sampleBytes) +results.push({ + name: 'base58 encode', + ...measure(OPERATIONS, () => toBase58String(sampleBytes)), +}) +results.push({ + name: 'base58 decode', + ...measure(OPERATIONS, () => fromBase58String(base58)), +}) +results.push({ + name: 'base58btc encode', + ...measure(OPERATIONS, () => toBase58BtcString(sampleBytes)), +}) +results.push({ + name: 'base58btc decode', + ...measure(OPERATIONS, () => fromBase58BtcString(base58Btc)), +}) +results.push({ + name: 'base64 encode', + ...measure(OPERATIONS, () => toBase64String(sampleBytes)), }) -await benchAsync('gzip decompress', 400, async () => { - await fromCompressed(compressed) +results.push({ + name: 'base64 decode', + ...measure(OPERATIONS, () => fromBase64String(base64)), }) +results.push({ + name: 'base64url encode', + ...measure(OPERATIONS, () => toBase64UrlString(sampleBytes)), +}) +results.push({ + name: 'base64url decode', + ...measure(OPERATIONS, () => fromBase64UrlString(base64Url)), +}) +results.push({ + name: 'hex encode', + ...measure(OPERATIONS, () => toHex(sampleBytes)), +}) +results.push({ + name: 'hex decode', + ...measure(OPERATIONS, () => fromHex(hex)), +}) +results.push({ + name: 'z85 encode', + ...measure(OPERATIONS, () => toZ85String(sampleBytes)), +}) +results.push({ + name: 'z85 decode', + ...measure(OPERATIONS, () => fromZ85String(z85)), +}) +results.push({ + name: 'utf8 encode', + ...measure(OPERATIONS, () => fromString(sampleText)), +}) +results.push({ + name: 'utf8 decode', + ...measure(OPERATIONS, () => toString(sampleTextBytes)), +}) +results.push({ + name: 'bigint encode', + ...measure(OPERATIONS, () => fromBigInt(sampleBigInt)), +}) +results.push({ + name: 'bigint decode', + ...measure(OPERATIONS, () => toBigInt(sampleBigIntBytes)), +}) +results.push({ + name: 'json encode', + ...measure(OPERATIONS, () => fromJSON(sampleJson)), +}) +results.push({ + name: 'json decode', + ...measure(OPERATIONS, () => toJSON(sampleJsonBytes)), +}) +results.push({ + name: 'concat 3 buffers', + ...measure(OPERATIONS, () => concat([sampleBytes, sampleBytes, sampleBytes])), +}) +results.push({ + name: 'toUint8Array', + ...measure(OPERATIONS, () => toUint8Array(sampleView)), +}) +results.push({ + name: 'toArrayBuffer', + ...measure(OPERATIONS, () => toArrayBuffer(sampleView)), +}) +results.push({ + name: 'toBufferSource', + ...measure(OPERATIONS, () => toBufferSource(sampleView)), +}) +results.push({ + name: 'equals same', + ...measure(OPERATIONS, () => equals(sampleBytes, sampleBytes)), +}) +results.push({ + name: 'equals diff', + ...measure(OPERATIONS, () => equals(sampleBytes, sampleBytesDiff)), +}) +results.push({ + name: 'gzip compress', + ...(await measureAsync(OPERATIONS, async () => { + await toCompressed(sampleBytes) + })), +}) +results.push({ + name: 'gzip decompress', + ...(await measureAsync(OPERATIONS, async () => { + await fromCompressed(compressed) + })), +}) + +console.log(formatResults(results)) console.log('Benchmark complete.') diff --git a/package.json b/package.json index 96aefd5..35f975d 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,10 @@ { "name": "@sovereignbase/bytecodec", "version": "1.5.1", - "description": "JS/TS runtime-agnostic byte toolkit for UTF-8, base64, base64url, hex, Z85, unsigned BigInt conversion, JSON, normalization, compression, concatenation, and comparison.", + "description": "JS/TS runtime-agnostic byte toolkit for UTF-8, base58, base58btc, base64, base64url, hex, Z85, unsigned BigInt conversion, JSON, normalization, compression, concatenation, and comparison.", "keywords": [ + "base58", + "base58btc", "base64url", "base64", "Uint8Array", diff --git a/src/.errors/class.ts b/src/.errors/class.ts index 2654c55..cba9195 100644 --- a/src/.errors/class.ts +++ b/src/.errors/class.ts @@ -18,6 +18,10 @@ * All structured error codes thrown by the bytecodec. */ export type BytecodecErrorCode = + | 'BASE58BTC_INPUT_EXPECTED' + | 'BASE58BTC_INVALID_PREFIX' + | 'BASE58_INPUT_EXPECTED' + | 'BASE58_INVALID_CHARACTER' | 'BASE64_DECODER_UNAVAILABLE' | 'BASE64_ENCODER_UNAVAILABLE' | 'BASE64URL_INVALID_LENGTH' diff --git a/src/.helpers/index.ts b/src/.helpers/index.ts index 00c01cd..7dd38f2 100644 --- a/src/.helpers/index.ts +++ b/src/.helpers/index.ts @@ -90,3 +90,18 @@ export const Z85_VALUES = (() => { return table })() + +export const BASE58BTC_PREFIX = 'z' + +export const BASE58BTC_CHARS = + '123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz' + +export const BASE58BTC_VALUES = (() => { + const table = new Int16Array(128).fill(-1) + + for (let i = 0; i < BASE58BTC_CHARS.length; i++) { + table[BASE58BTC_CHARS.charCodeAt(i)] = i + } + + return table +})() diff --git a/src/fromBase58BtcString/index.ts b/src/fromBase58BtcString/index.ts new file mode 100644 index 0000000..289506d --- /dev/null +++ b/src/fromBase58BtcString/index.ts @@ -0,0 +1,41 @@ +/* + * Copyright 2026 Sovereignbase + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { BytecodecError } from '../.errors/class.js' +import { BASE58BTC_PREFIX } from '../.helpers/index.js' +import { fromBase58String } from '../fromBase58String/index.js' + +/** + * Decodes a multibase base58btc string with the `z` prefix. + * + * @param base58BtcString The base58btc string to decode. + * @returns A new `Uint8Array` containing the decoded bytes. + */ +export function fromBase58BtcString(base58BtcString: string): Uint8Array { + if (typeof base58BtcString !== 'string') + throw new BytecodecError( + 'BASE58BTC_INPUT_EXPECTED', + 'fromBase58BtcString expects a string input' + ) + + if (!base58BtcString.startsWith(BASE58BTC_PREFIX)) + throw new BytecodecError( + 'BASE58BTC_INVALID_PREFIX', + 'base58btc string must start with the multibase prefix "z"' + ) + + return fromBase58String(base58BtcString.slice(1)) +} diff --git a/src/fromBase58String/index.ts b/src/fromBase58String/index.ts new file mode 100644 index 0000000..7e08149 --- /dev/null +++ b/src/fromBase58String/index.ts @@ -0,0 +1,76 @@ +/* + * Copyright 2026 Sovereignbase + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { BytecodecError } from '../.errors/class.js' +import { BASE58BTC_VALUES } from '../.helpers/index.js' + +/** + * Decodes a base58btc-alphabet string without a multibase prefix. + * + * @param base58String The base58 string to decode. + * @returns A new `Uint8Array` containing the decoded bytes. + */ +export function fromBase58String(base58String: string): Uint8Array { + if (typeof base58String !== 'string') + throw new BytecodecError( + 'BASE58_INPUT_EXPECTED', + 'fromBase58String expects a string input' + ) + + if (base58String.length === 0) return new Uint8Array(0) + + let zeroCount = 0 + + while ( + zeroCount < base58String.length && + base58String.charCodeAt(zeroCount) === 49 + ) + zeroCount++ + + const bytes: number[] = [] + + for (let stringOffset = 0; stringOffset < base58String.length; stringOffset++) { + const code = base58String.charCodeAt(stringOffset) + const digit = code < 128 ? BASE58BTC_VALUES[code] : -1 + + if (digit === -1) + throw new BytecodecError( + 'BASE58_INVALID_CHARACTER', + `Invalid base58 character at index ${stringOffset}` + ) + + let carry = digit + + for (let byteOffset = 0; byteOffset < bytes.length; byteOffset++) { + carry += bytes[byteOffset] * 58 + bytes[byteOffset] = carry & 0xff + carry >>= 8 + } + + while (carry > 0) { + bytes.push(carry & 0xff) + carry >>= 8 + } + } + + const decoded = new Uint8Array(zeroCount + bytes.length) + + for (let index = 0; index < bytes.length; index++) { + decoded[decoded.length - 1 - index] = bytes[index] + } + + return decoded +} diff --git a/src/index.ts b/src/index.ts index a77cfeb..a6b1cd3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -14,6 +14,12 @@ * limitations under the License. */ +/***/ +import { fromBase58String } from './fromBase58String/index.js' +import { toBase58String } from './toBase58String/index.js' +/***/ +import { fromBase58BtcString } from './fromBase58BtcString/index.js' +import { toBase58BtcString } from './toBase58BtcString/index.js' /***/ import { fromBase64String } from './fromBase64String/index.js' import { toBase64String } from './toBase64String/index.js' @@ -59,6 +65,12 @@ export type ByteSource = export type { BytecodecErrorCode } from './.errors/class.js' export { + /***/ + fromBase58String, + toBase58String, + /***/ + fromBase58BtcString, + toBase58BtcString, /***/ fromBase64String, toBase64String, @@ -96,6 +108,34 @@ export { * Convenience wrapper around the codec functions. */ export class Bytes { + /** + * See {@link fromBase58String}. + */ + static fromBase58String(base58String: string): Uint8Array { + return fromBase58String(base58String) + } + + /** + * See {@link toBase58String}. + */ + static toBase58String(bytes: ByteSource): string { + return toBase58String(bytes) + } + + /** + * See {@link fromBase58BtcString}. + */ + static fromBase58BtcString(base58BtcString: string): Uint8Array { + return fromBase58BtcString(base58BtcString) + } + + /** + * See {@link toBase58BtcString}. + */ + static toBase58BtcString(bytes: ByteSource): string { + return toBase58BtcString(bytes) + } + /** * See {@link fromBase64String}. */ diff --git a/src/toBase58BtcString/index.ts b/src/toBase58BtcString/index.ts new file mode 100644 index 0000000..94d27f3 --- /dev/null +++ b/src/toBase58BtcString/index.ts @@ -0,0 +1,29 @@ +/* + * Copyright 2026 Sovereignbase + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { BASE58BTC_PREFIX } from '../.helpers/index.js' +import type { ByteSource } from '../index.js' +import { toBase58String } from '../toBase58String/index.js' + +/** + * Encodes bytes as a multibase base58btc string with the `z` prefix. + * + * @param bytes The bytes to encode. + * @returns A base58btc multibase string representation of `bytes`. + */ +export function toBase58BtcString(bytes: ByteSource): string { + return BASE58BTC_PREFIX + toBase58String(bytes) +} diff --git a/src/toBase58String/index.ts b/src/toBase58String/index.ts new file mode 100644 index 0000000..b808544 --- /dev/null +++ b/src/toBase58String/index.ts @@ -0,0 +1,59 @@ +/* + * Copyright 2026 Sovereignbase + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { BASE58BTC_CHARS } from '../.helpers/index.js' +import type { ByteSource } from '../index.js' +import { toUint8Array } from '../index.js' + +/** + * Encodes bytes as a base58btc-alphabet string without a multibase prefix. + * + * @param bytes The bytes to encode. + * @returns A base58 string representation of `bytes`. + */ +export function toBase58String(bytes: ByteSource): string { + const view = toUint8Array(bytes) + if (view.length === 0) return '' + + let zeroCount = 0 + + while (zeroCount < view.length && view[zeroCount] === 0) zeroCount++ + + const digits: number[] = [] + + for (const value of view) { + let carry = value + + for (let index = 0; index < digits.length; index++) { + carry += digits[index] << 8 + digits[index] = carry % 58 + carry = Math.floor(carry / 58) + } + + while (carry > 0) { + digits.push(carry % 58) + carry = Math.floor(carry / 58) + } + } + + let base58String = '1'.repeat(zeroCount) + + for (let index = digits.length - 1; index >= 0; index--) { + base58String += BASE58BTC_CHARS[digits[index]] + } + + return base58String +} diff --git a/test/e2e/shared/suite.mjs b/test/e2e/shared/suite.mjs index dc37365..28a21cb 100644 --- a/test/e2e/shared/suite.mjs +++ b/test/e2e/shared/suite.mjs @@ -10,6 +10,8 @@ export async function runBytecodecSuite(api, options = {}) { Bytes, concat, equals, + fromBase58BtcString, + fromBase58String, fromBase64String, fromBase64UrlString, fromCompressed, @@ -19,6 +21,8 @@ export async function runBytecodecSuite(api, options = {}) { fromString, fromZ85String, toArrayBuffer, + toBase58BtcString, + toBase58String, toBase64String, toBase64UrlString, toBigInt, @@ -109,6 +113,7 @@ export async function runBytecodecSuite(api, options = {}) { } const base64Payload = Uint8Array.from([104, 101, 108, 108, 111]) + const base58Payload = Uint8Array.from([0, 1, 2, 3, 4]) const utf8Text = 'h\u00e9llo \u2713 rocket \ud83d\ude80' const jsonValue = { ok: true, count: 3, list: ['x', { y: 1 }], nil: null } const compressionPayload = fromString('compress me please') @@ -116,6 +121,10 @@ export async function runBytecodecSuite(api, options = {}) { await runTest('exports shape', () => { assert(typeof Bytes === 'function', 'Bytes export missing') for (const fn of [ + fromBase58String, + toBase58String, + fromBase58BtcString, + toBase58BtcString, fromBase64String, toBase64String, fromBase64UrlString, @@ -150,6 +159,34 @@ export async function runBytecodecSuite(api, options = {}) { assertEqual(toBase64String(view), 'ZWxs') }) + await runTest('toBase58String', () => { + const encoded = toBase58String(base64Payload) + assertEqual(encoded, 'Cn8eVZg') + + const view = new DataView(base58Payload.buffer, 1, 3) + assertEqual(toBase58String(view), 'Ldp') + }) + + await runTest('fromBase58String', () => { + const decoded = fromBase58String('12VfUX') + assertArrayEqual(decoded, base58Payload) + assertThrows(() => fromBase58String('0'), /Invalid base58 character at index 0/) + }) + + await runTest('toBase58BtcString', () => { + const encoded = toBase58BtcString(base64Payload) + assertEqual(encoded, 'zCn8eVZg') + }) + + await runTest('fromBase58BtcString', () => { + const decoded = fromBase58BtcString('z12VfUX') + assertArrayEqual(decoded, base58Payload) + assertThrows( + () => fromBase58BtcString('12VfUX'), + /base58btc string must start with the multibase prefix "z"/ + ) + }) + await runTest('fromBase64String', () => { const decoded = fromBase64String('aGVsbG8=') assertArrayEqual(decoded, base64Payload) @@ -356,6 +393,14 @@ export async function runBytecodecSuite(api, options = {}) { await runTest('Bytes wrapper', async () => { const payload = Uint8Array.from([1, 2, 3, 4]) + const base58 = Bytes.toBase58String(payload) + assertEqual(base58, '2VfUX') + assertArrayEqual(Bytes.fromBase58String(base58), [1, 2, 3, 4]) + + const base58Btc = Bytes.toBase58BtcString(payload) + assertEqual(base58Btc, 'z2VfUX') + assertArrayEqual(Bytes.fromBase58BtcString(base58Btc), [1, 2, 3, 4]) + const base64 = Bytes.toBase64String(payload) assertEqual(base64, 'AQIDBA==') assertArrayEqual(Bytes.fromBase64String(base64), [1, 2, 3, 4]) diff --git a/test/integration/integration.test.js b/test/integration/integration.test.js index e52d810..046cf1f 100644 --- a/test/integration/integration.test.js +++ b/test/integration/integration.test.js @@ -2,6 +2,8 @@ import assert from 'node:assert/strict' import test from 'node:test' import { concat, + fromBase58BtcString, + fromBase58String, fromBase64String, fromBase64UrlString, fromBigInt, @@ -9,6 +11,8 @@ import { fromJSON, fromString, fromZ85String, + toBase58BtcString, + toBase58String, toBase64String, toBase64UrlString, toBigInt, @@ -26,6 +30,22 @@ test('integration: utf8 -> base64 -> utf8', () => { assert.equal(toString(decoded), text) }) +test('integration: utf8 -> base58 -> utf8', () => { + const text = 'pipeline check' + const bytes = fromString(text) + const encoded = toBase58String(bytes) + const decoded = fromBase58String(encoded) + assert.equal(toString(decoded), text) +}) + +test('integration: json -> bytes -> base58btc -> json', () => { + const value = { ok: true, list: [1, 2, 3] } + const bytes = fromJSON(value) + const encoded = toBase58BtcString(bytes) + const decoded = fromBase58BtcString(encoded) + assert.deepStrictEqual(toJSON(decoded), value) +}) + test('integration: utf8 -> base64url -> utf8', () => { const text = 'pipeline check' const bytes = fromString(text) diff --git a/test/unit/base58.test.js b/test/unit/base58.test.js new file mode 100644 index 0000000..f67f171 --- /dev/null +++ b/test/unit/base58.test.js @@ -0,0 +1,76 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { + fromBase58BtcString, + fromBase58String, + toBase58BtcString, + toBase58String, +} from '../../dist/index.js' + +test('base58 roundtrip', () => { + const payload = new Uint8Array([104, 101, 108, 108, 111]) + const encoded = toBase58String(payload) + assert.equal(encoded, 'Cn8eVZg') + const decoded = fromBase58String(encoded) + assert.deepStrictEqual([...decoded], [...payload]) +}) + +test('base58 preserves leading zero bytes', () => { + const payload = new Uint8Array([0, 0, 1, 2, 3, 4]) + const encoded = toBase58String(payload) + assert.equal(encoded, '112VfUX') + const decoded = fromBase58String(encoded) + assert.deepStrictEqual([...decoded], [...payload]) +}) + +test('base58 accepts ByteSource input', () => { + const encoded = toBase58String([1, 2, 3, 4]) + assert.equal(encoded, '2VfUX') + const decoded = fromBase58String(encoded) + assert.deepStrictEqual([...decoded], [1, 2, 3, 4]) +}) + +test('base58btc roundtrip', () => { + const payload = new Uint8Array([104, 101, 108, 108, 111]) + const encoded = toBase58BtcString(payload) + assert.equal(encoded, 'zCn8eVZg') + const decoded = fromBase58BtcString(encoded) + assert.deepStrictEqual([...decoded], [...payload]) +}) + +test('base58btc encodes empty input as just the multibase prefix', () => { + assert.equal(toBase58BtcString(new Uint8Array([])), 'z') + assert.deepStrictEqual([...fromBase58BtcString('z')], []) +}) + +test('base58 rejects invalid characters', () => { + assert.throws( + () => fromBase58String('0'), + /Invalid base58 character at index 0/ + ) + assert.throws( + () => fromBase58String('å'), + /Invalid base58 character at index 0/ + ) +}) + +test('base58 rejects non-string input', () => { + assert.throws( + () => fromBase58String(123), + /fromBase58String expects a string input/ + ) +}) + +test('base58btc rejects missing multibase prefix', () => { + assert.throws( + () => fromBase58BtcString('Cn8eVZg'), + /base58btc string must start with the multibase prefix "z"/ + ) +}) + +test('base58btc rejects non-string input', () => { + assert.throws( + () => fromBase58BtcString(123), + /fromBase58BtcString expects a string input/ + ) +}) diff --git a/test/unit/bytes-class.test.js b/test/unit/bytes-class.test.js index 1f86810..0876612 100644 --- a/test/unit/bytes-class.test.js +++ b/test/unit/bytes-class.test.js @@ -4,6 +4,14 @@ import { Bytes } from '../../dist/index.js' test('Bytes wrapper mirrors functions', async () => { const payload = Uint8Array.from([1, 2, 3, 4]) + const base58 = Bytes.toBase58String(payload) + assert.equal(base58, '2VfUX') + assert.deepStrictEqual(Bytes.fromBase58String(base58), payload) + + const base58Btc = Bytes.toBase58BtcString(payload) + assert.equal(base58Btc, 'z2VfUX') + assert.deepStrictEqual(Bytes.fromBase58BtcString(base58Btc), payload) + const base64 = Bytes.toBase64String(payload) assert.equal(base64, 'AQIDBA==') assert.deepStrictEqual(Bytes.fromBase64String(base64), payload) diff --git a/test/unit/errors.test.js b/test/unit/errors.test.js index 984c1ba..05a114b 100644 --- a/test/unit/errors.test.js +++ b/test/unit/errors.test.js @@ -51,9 +51,42 @@ test('public errors expose code, name, and prefixed message', async () => { }) test('validation errors use the same public error shape', async () => { - const { fromBase64UrlString, fromBigInt, fromHex, toZ85String } = + const { + fromBase58BtcString, + fromBase58String, + fromBase64UrlString, + fromBigInt, + fromHex, + toZ85String, + } = await importFreshBundle('validation-error') + assert.throws( + () => fromBase58String('0'), + (error) => { + assert.equal(error.code, 'BASE58_INVALID_CHARACTER') + assert.equal(error.name, 'BytecodecError') + assert.equal( + error.message, + '{@sovereignbase/bytecodec} Invalid base58 character at index 0' + ) + return true + } + ) + + assert.throws( + () => fromBase58BtcString('2VfUX'), + (error) => { + assert.equal(error.code, 'BASE58BTC_INVALID_PREFIX') + assert.equal(error.name, 'BytecodecError') + assert.equal( + error.message, + '{@sovereignbase/bytecodec} base58btc string must start with the multibase prefix "z"' + ) + return true + } + ) + assert.throws( () => fromBase64UrlString('a'), (error) => { diff --git a/test/unit/fallbacks.test.js b/test/unit/fallbacks.test.js index f71e4c0..3cecf95 100644 --- a/test/unit/fallbacks.test.js +++ b/test/unit/fallbacks.test.js @@ -14,10 +14,14 @@ const bundleUrl = new URL( ) const { concat, + fromBase58BtcString, + fromBase58String, fromBase64UrlString, fromCompressed, fromJSON, fromString, + toBase58BtcString, + toBase58String, toBase64UrlString, toCompressed, toJSON, @@ -146,6 +150,22 @@ test('fromBase64UrlString throws when no base64 decoder exists', () => { restoreGlobals() }) +test('base58 helpers do not depend on Buffer or browser base64 globals', () => { + globalThis.Buffer = undefined + globalThis.btoa = undefined + globalThis.atob = undefined + + const encoded = toBase58String(new Uint8Array([104, 101, 108, 108, 111])) + assert.equal(encoded, 'Cn8eVZg') + assert.deepStrictEqual([...fromBase58String(encoded)], [104, 101, 108, 108, 111]) + + const btcEncoded = toBase58BtcString(new Uint8Array([0, 1, 2, 3, 4])) + assert.equal(btcEncoded, 'z12VfUX') + assert.deepStrictEqual([...fromBase58BtcString(btcEncoded)], [0, 1, 2, 3, 4]) + + restoreGlobals() +}) + test('concat formats non-Error throws with String()', () => { globalThis.Error = FakeError assert.throws( diff --git a/tsup.config.ts b/tsup.config.ts index adfb019..170ab39 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -1,5 +1,23 @@ import { defineConfig } from 'tsup' +const apache2Banner = [ + '/*', + ' * Copyright 2026 Sovereignbase', + ' *', + ' * Licensed under the Apache License, Version 2.0 (the "License");', + ' * you may not use this file except in compliance with the License.', + ' * You may obtain a copy of the License at', + ' *', + ' * http://www.apache.org/licenses/LICENSE-2.0', + ' *', + ' * Unless required by applicable law or agreed to in writing, software', + ' * distributed under the License is distributed on an "AS IS" BASIS,', + ' * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.', + ' * See the License for the specific language governing permissions and', + ' * limitations under the License.', + ' */', +].join('\n') + export default defineConfig({ entry: ['src/index.ts'], format: ['esm', 'cjs'], @@ -10,7 +28,10 @@ export default defineConfig({ sourcemap: true, clean: true, splitting: true, - external: ['node:*'], + banner: { + js: `${apache2Banner}\n`, + }, + external: [], outExtension({ format }) { return { js: format === 'cjs' ? '.cjs' : '.js' } },