diff --git a/NOTICE b/NOTICE index 7742ebc0b4..865070d5e5 100644 --- a/NOTICE +++ b/NOTICE @@ -14,6 +14,9 @@ on 2020-06-05. The code in packages/utils was forked from https://github.com/iov-one/iov-core/tree/v2.3.2/packages/iov-utils on 2020-06-05. +The code in packages/encoding and packages/math was forked from https://github.com/iov-one/iov-core/tree/v2.3.2/packages/iov-encoding +on 2020-06-05. + Copyright 2018-2020 IOV SAS Copyright 2020 Confio UO Copyright 2020 Simon Warta diff --git a/packages/encoding/.eslintignore b/packages/encoding/.eslintignore new file mode 100644 index 0000000000..f373a53fc6 --- /dev/null +++ b/packages/encoding/.eslintignore @@ -0,0 +1,8 @@ +node_modules/ + +build/ +custom_types/ +dist/ +docs/ +generated/ +types/ diff --git a/packages/encoding/.gitignore b/packages/encoding/.gitignore new file mode 100644 index 0000000000..68bf373524 --- /dev/null +++ b/packages/encoding/.gitignore @@ -0,0 +1,3 @@ +build/ +dist/ +docs/ diff --git a/packages/encoding/README.md b/packages/encoding/README.md new file mode 100644 index 0000000000..c8845fa489 --- /dev/null +++ b/packages/encoding/README.md @@ -0,0 +1,24 @@ +# @cosmjs/encoding + +[![npm version](https://img.shields.io/npm/v/@cosmjs/encoding.svg)](https://www.npmjs.com/package/@cosmjs/encoding) + +This package is an extension to the JavaScript standard library that is not +bound to blockchain products. It provides basic hex/base64/ascii encoding to +Uint8Array that doesn't rely on Buffer and also provides better error messages +on invalid input. + +## Convert between bech32 and hex addresses + +``` +>> Bech32.encode("tiov", fromHex("1234ABCD0000AA0000FFFF0000AA00001234ABCD")) +'tiov1zg62hngqqz4qqq8lluqqp2sqqqfrf27dzrrmea' +>> toHex(Bech32.decode("tiov1zg62hngqqz4qqq8lluqqp2sqqqfrf27dzrrmea").data) +'1234abcd0000aa0000ffff0000aa00001234abcd' +``` + +## License + +This package is part of the cosmwasm-js repository, licensed under the Apache +License 2.0 (see +[NOTICE](https://github.com/confio/cosmwasm-js/blob/master/NOTICE) and +[LICENSE](https://github.com/confio/cosmwasm-js/blob/master/LICENSE)). diff --git a/packages/encoding/jasmine-testrunner.js b/packages/encoding/jasmine-testrunner.js new file mode 100755 index 0000000000..9fada59b28 --- /dev/null +++ b/packages/encoding/jasmine-testrunner.js @@ -0,0 +1,26 @@ +#!/usr/bin/env node + +require("source-map-support").install(); +const defaultSpecReporterConfig = require("../../jasmine-spec-reporter.config.json"); + +// setup Jasmine +const Jasmine = require("jasmine"); +const jasmine = new Jasmine(); +jasmine.loadConfig({ + spec_dir: "build", + spec_files: ["**/*.spec.js"], + helpers: [], + random: false, + seed: null, + stopSpecOnExpectationFailure: false, +}); +jasmine.jasmine.DEFAULT_TIMEOUT_INTERVAL = 15 * 1000; + +// setup reporter +const { SpecReporter } = require("jasmine-spec-reporter"); +const reporter = new SpecReporter({ ...defaultSpecReporterConfig }); + +// initialize and execute +jasmine.env.clearReporters(); +jasmine.addReporter(reporter); +jasmine.execute(); diff --git a/packages/encoding/karma.conf.js b/packages/encoding/karma.conf.js new file mode 100644 index 0000000000..006da5fe6b --- /dev/null +++ b/packages/encoding/karma.conf.js @@ -0,0 +1,47 @@ +module.exports = function (config) { + config.set({ + // base path that will be used to resolve all patterns (eg. files, exclude) + basePath: ".", + + // frameworks to use + // available frameworks: https://npmjs.org/browse/keyword/karma-adapter + frameworks: ["jasmine"], + + // list of files / patterns to load in the browser + files: ["dist/web/tests.js"], + + client: { + jasmine: { + random: false, + timeoutInterval: 15000, + }, + }, + + // test results reporter to use + // possible values: 'dots', 'progress' + // available reporters: https://npmjs.org/browse/keyword/karma-reporter + reporters: ["progress", "kjhtml"], + + // web server port + port: 9876, + + // enable / disable colors in the output (reporters and logs) + colors: true, + + // level of logging + // possible values: config.LOG_DISABLE || config.LOG_ERROR || config.LOG_WARN || config.LOG_INFO || config.LOG_DEBUG + logLevel: config.LOG_INFO, + + // enable / disable watching file and executing tests whenever any file changes + autoWatch: false, + + // start these browsers + // available browser launchers: https://npmjs.org/browse/keyword/karma-launcher + browsers: ["Firefox"], + + browserNoActivityTimeout: 90000, + + // Keep brower open for debugging. This is overridden by yarn scripts + singleRun: false, + }); +}; diff --git a/packages/encoding/nonces/README.txt b/packages/encoding/nonces/README.txt new file mode 100644 index 0000000000..092fe732f1 --- /dev/null +++ b/packages/encoding/nonces/README.txt @@ -0,0 +1 @@ +Directory used to trigger lerna package updates for all packages diff --git a/packages/encoding/package.json b/packages/encoding/package.json new file mode 100644 index 0000000000..5eedd10644 --- /dev/null +++ b/packages/encoding/package.json @@ -0,0 +1,47 @@ +{ + "name": "@cosmjs/encoding", + "version": "0.8.0", + "description": "Encoding helpers for blockchain projects", + "contributors": ["IOV SAS "], + "license": "Apache-2.0", + "main": "build/index.js", + "types": "types/index.d.ts", + "files": [ + "build/", + "types/", + "*.md", + "!*.spec.*", + "!**/testdata/" + ], + "repository": { + "type": "git", + "url": "https://github.com/CosmWasm/cosmwasm-js/tree/master/packages/encoding" + }, + "publishConfig": { + "access": "public" + }, + "scripts": { + "docs": "shx rm -rf docs && typedoc --options typedoc.js", + "lint": "eslint --max-warnings 0 \"**/*.{js,ts}\"", + "format": "prettier --write --loglevel warn \"./src/**/*.ts\"", + "test-node": "node jasmine-testrunner.js", + "test-edge": "yarn pack-web && karma start --single-run --browsers Edge", + "test-firefox": "yarn pack-web && karma start --single-run --browsers Firefox", + "test-chrome": "yarn pack-web && karma start --single-run --browsers ChromeHeadless", + "test-safari": "yarn pack-web && karma start --single-run --browsers Safari", + "test": "yarn build-or-skip && yarn test-node", + "move-types": "shx rm -r ./types/* && shx mv build/types/* ./types && rm -rf ./types/testdata && shx rm -f ./types/*.spec.d.ts", + "format-types": "prettier --write --loglevel warn \"./types/**/*.d.ts\"", + "build": "shx rm -rf ./build && tsc && yarn move-types && yarn format-types", + "build-or-skip": "[ -n \"$SKIP_BUILD\" ] || yarn build", + "pack-web": "yarn build-or-skip && webpack --mode development --config webpack.web.config.js" + }, + "dependencies": { + "base64-js": "^1.3.0", + "bech32": "^1.1.4", + "readonly-date": "^1.0.0" + }, + "devDependencies": { + "@types/base64-js": "^1.2.5" + } +} diff --git a/packages/encoding/src/ascii.spec.ts b/packages/encoding/src/ascii.spec.ts new file mode 100644 index 0000000000..a6942ec151 --- /dev/null +++ b/packages/encoding/src/ascii.spec.ts @@ -0,0 +1,26 @@ +import { fromAscii, toAscii } from "./ascii"; + +describe("ascii", () => { + it("encodes to ascii", () => { + expect(toAscii("")).toEqual(new Uint8Array([])); + expect(toAscii("abc")).toEqual(new Uint8Array([0x61, 0x62, 0x63])); + expect(toAscii(" ?=-n|~+-*/\\")).toEqual( + new Uint8Array([0x20, 0x3f, 0x3d, 0x2d, 0x6e, 0x7c, 0x7e, 0x2b, 0x2d, 0x2a, 0x2f, 0x5c]), + ); + + expect(() => toAscii("ö")).toThrow(); + expect(() => toAscii("ß")).toThrow(); + }); + + it("decodes from ascii", () => { + expect(fromAscii(new Uint8Array([]))).toEqual(""); + expect(fromAscii(new Uint8Array([0x61, 0x62, 0x63]))).toEqual("abc"); + expect( + fromAscii(new Uint8Array([0x20, 0x3f, 0x3d, 0x2d, 0x6e, 0x7c, 0x7e, 0x2b, 0x2d, 0x2a, 0x2f, 0x5c])), + ).toEqual(" ?=-n|~+-*/\\"); + + expect(() => fromAscii(new Uint8Array([0x00]))).toThrow(); + expect(() => fromAscii(new Uint8Array([0x7f]))).toThrow(); + expect(() => fromAscii(new Uint8Array([0xff]))).toThrow(); + }); +}); diff --git a/packages/encoding/src/ascii.ts b/packages/encoding/src/ascii.ts new file mode 100644 index 0000000000..d488317de8 --- /dev/null +++ b/packages/encoding/src/ascii.ts @@ -0,0 +1,31 @@ +export function toAscii(input: string): Uint8Array { + const toNums = (str: string): readonly number[] => + str.split("").map((x: string) => { + const charCode = x.charCodeAt(0); + // 0x00–0x1F control characters + // 0x20–0x7E printable characters + // 0x7F delete character + // 0x80–0xFF out of 7 bit ascii range + if (charCode < 0x20 || charCode > 0x7e) { + throw new Error("Cannot encode character that is out of printable ASCII range: " + charCode); + } + return charCode; + }); + return Uint8Array.from(toNums(input)); +} + +export function fromAscii(data: Uint8Array): string { + const fromNums = (listOfNumbers: readonly number[]): readonly string[] => + listOfNumbers.map((x: number): string => { + // 0x00–0x1F control characters + // 0x20–0x7E printable characters + // 0x7F delete character + // 0x80–0xFF out of 7 bit ascii range + if (x < 0x20 || x > 0x7e) { + throw new Error("Cannot decode character that is out of printable ASCII range: " + x); + } + return String.fromCharCode(x); + }); + + return fromNums(Array.from(data)).join(""); +} diff --git a/packages/encoding/src/base64.spec.ts b/packages/encoding/src/base64.spec.ts new file mode 100644 index 0000000000..f0f59039f5 --- /dev/null +++ b/packages/encoding/src/base64.spec.ts @@ -0,0 +1,65 @@ +import { fromBase64, toBase64 } from "./base64"; + +describe("base64", () => { + it("encodes to base64", () => { + expect(toBase64(new Uint8Array([]))).toEqual(""); + expect(toBase64(new Uint8Array([0x00]))).toEqual("AA=="); + expect(toBase64(new Uint8Array([0x00, 0x00]))).toEqual("AAA="); + expect(toBase64(new Uint8Array([0x00, 0x00, 0x00]))).toEqual("AAAA"); + expect(toBase64(new Uint8Array([0x00, 0x00, 0x00, 0x00]))).toEqual("AAAAAA=="); + expect(toBase64(new Uint8Array([0x00, 0x00, 0x00, 0x00, 0x00]))).toEqual("AAAAAAA="); + expect(toBase64(new Uint8Array([0x00, 0x00, 0x00, 0x00, 0x00, 0x00]))).toEqual("AAAAAAAA"); + expect(toBase64(new Uint8Array([0x61]))).toEqual("YQ=="); + expect(toBase64(new Uint8Array([0x62]))).toEqual("Yg=="); + expect(toBase64(new Uint8Array([0x63]))).toEqual("Yw=="); + expect(toBase64(new Uint8Array([0x61, 0x62, 0x63]))).toEqual("YWJj"); + }); + + it("decodes from base64", () => { + expect(fromBase64("")).toEqual(new Uint8Array([])); + expect(fromBase64("AA==")).toEqual(new Uint8Array([0x00])); + expect(fromBase64("AAA=")).toEqual(new Uint8Array([0x00, 0x00])); + expect(fromBase64("AAAA")).toEqual(new Uint8Array([0x00, 0x00, 0x00])); + expect(fromBase64("AAAAAA==")).toEqual(new Uint8Array([0x00, 0x00, 0x00, 0x00])); + expect(fromBase64("AAAAAAA=")).toEqual(new Uint8Array([0x00, 0x00, 0x00, 0x00, 0x00])); + expect(fromBase64("AAAAAAAA")).toEqual(new Uint8Array([0x00, 0x00, 0x00, 0x00, 0x00, 0x00])); + expect(fromBase64("YQ==")).toEqual(new Uint8Array([0x61])); + expect(fromBase64("Yg==")).toEqual(new Uint8Array([0x62])); + expect(fromBase64("Yw==")).toEqual(new Uint8Array([0x63])); + expect(fromBase64("YWJj")).toEqual(new Uint8Array([0x61, 0x62, 0x63])); + + // invalid length + expect(() => fromBase64("a")).toThrow(); + expect(() => fromBase64("aa")).toThrow(); + expect(() => fromBase64("aaa")).toThrow(); + + // proper length including invalid character + expect(() => fromBase64("aaa!")).toThrow(); + expect(() => fromBase64("aaa*")).toThrow(); + expect(() => fromBase64("aaaä")).toThrow(); + + // proper length plus invalid character + expect(() => fromBase64("aaaa!")).toThrow(); + expect(() => fromBase64("aaaa*")).toThrow(); + expect(() => fromBase64("aaaaä")).toThrow(); + + // extra spaces + expect(() => fromBase64("aaaa ")).toThrow(); + expect(() => fromBase64(" aaaa")).toThrow(); + expect(() => fromBase64("aa aa")).toThrow(); + expect(() => fromBase64("aaaa\n")).toThrow(); + expect(() => fromBase64("\naaaa")).toThrow(); + expect(() => fromBase64("aa\naa")).toThrow(); + + // position of = + expect(() => fromBase64("=aaa")).toThrow(); + expect(() => fromBase64("==aa")).toThrow(); + + // concatenated base64 strings should not be supported + // see https://github.com/beatgammit/base64-js/issues/42 + expect(() => fromBase64("AAA=AAA=")).toThrow(); + + // wrong number of = + expect(() => fromBase64("a===")).toThrow(); + }); +}); diff --git a/packages/encoding/src/base64.ts b/packages/encoding/src/base64.ts new file mode 100644 index 0000000000..df25db1b57 --- /dev/null +++ b/packages/encoding/src/base64.ts @@ -0,0 +1,12 @@ +import * as base64js from "base64-js"; + +export function toBase64(data: Uint8Array): string { + return base64js.fromByteArray(data); +} + +export function fromBase64(base64String: string): Uint8Array { + if (!base64String.match(/^[a-zA-Z0-9+/]*={0,2}$/)) { + throw new Error("Invalid base64 string format"); + } + return base64js.toByteArray(base64String); +} diff --git a/packages/encoding/src/bech32.spec.ts b/packages/encoding/src/bech32.spec.ts new file mode 100644 index 0000000000..59e6d2c531 --- /dev/null +++ b/packages/encoding/src/bech32.spec.ts @@ -0,0 +1,19 @@ +import { Bech32 } from "./bech32"; +import { fromHex } from "./hex"; + +describe("Bech32", () => { + // test data generate using https://github.com/nym-zone/bech32 + // bech32 -e -h eth 9d4e856e572e442f0a4b2763e72d08a0e99d8ded + const ethAddressRaw = fromHex("9d4e856e572e442f0a4b2763e72d08a0e99d8ded"); + + it("encodes", () => { + expect(Bech32.encode("eth", ethAddressRaw)).toEqual("eth1n48g2mjh9ezz7zjtya37wtgg5r5emr0drkwlgw"); + }); + + it("decodes", () => { + expect(Bech32.decode("eth1n48g2mjh9ezz7zjtya37wtgg5r5emr0drkwlgw")).toEqual({ + prefix: "eth", + data: ethAddressRaw, + }); + }); +}); diff --git a/packages/encoding/src/bech32.ts b/packages/encoding/src/bech32.ts new file mode 100644 index 0000000000..38fedde429 --- /dev/null +++ b/packages/encoding/src/bech32.ts @@ -0,0 +1,16 @@ +import * as bech32 from "bech32"; + +export class Bech32 { + public static encode(prefix: string, data: Uint8Array): string { + const address = bech32.encode(prefix, bech32.toWords(data)); + return address; + } + + public static decode(address: string): { readonly prefix: string; readonly data: Uint8Array } { + const decodedAddress = bech32.decode(address); + return { + prefix: decodedAddress.prefix, + data: new Uint8Array(bech32.fromWords(decodedAddress.words)), + }; + } +} diff --git a/packages/encoding/src/hex.spec.ts b/packages/encoding/src/hex.spec.ts new file mode 100644 index 0000000000..7a7eed0c4b --- /dev/null +++ b/packages/encoding/src/hex.spec.ts @@ -0,0 +1,44 @@ +import { fromHex, toHex } from "./hex"; + +describe("fromHex", () => { + it("works", () => { + // simple + expect(fromHex("")).toEqual(new Uint8Array([])); + expect(fromHex("00")).toEqual(new Uint8Array([0x00])); + expect(fromHex("01")).toEqual(new Uint8Array([0x01])); + expect(fromHex("10")).toEqual(new Uint8Array([0x10])); + expect(fromHex("11")).toEqual(new Uint8Array([0x11])); + expect(fromHex("112233")).toEqual(new Uint8Array([0x11, 0x22, 0x33])); + expect(fromHex("0123456789abcdef")).toEqual( + new Uint8Array([0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef]), + ); + + // capital letters + expect(fromHex("AA")).toEqual(new Uint8Array([0xaa])); + expect(fromHex("aAbBcCdDeEfF")).toEqual(new Uint8Array([0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff])); + + // error + expect(() => fromHex("a")).toThrow(); + expect(() => fromHex("aaa")).toThrow(); + expect(() => fromHex("a!")).toThrow(); + expect(() => fromHex("a ")).toThrow(); + expect(() => fromHex("aa ")).toThrow(); + expect(() => fromHex(" aa")).toThrow(); + expect(() => fromHex("a a")).toThrow(); + expect(() => fromHex("gg")).toThrow(); + }); +}); + +describe("toHex", () => { + it("works", () => { + expect(toHex(new Uint8Array([]))).toEqual(""); + expect(toHex(new Uint8Array([0x00]))).toEqual("00"); + expect(toHex(new Uint8Array([0x01]))).toEqual("01"); + expect(toHex(new Uint8Array([0x10]))).toEqual("10"); + expect(toHex(new Uint8Array([0x11]))).toEqual("11"); + expect(toHex(new Uint8Array([0x11, 0x22, 0x33]))).toEqual("112233"); + expect(toHex(new Uint8Array([0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef]))).toEqual( + "0123456789abcdef", + ); + }); +}); diff --git a/packages/encoding/src/hex.ts b/packages/encoding/src/hex.ts new file mode 100644 index 0000000000..388346c019 --- /dev/null +++ b/packages/encoding/src/hex.ts @@ -0,0 +1,23 @@ +export function toHex(data: Uint8Array): string { + let out = ""; + for (const byte of data) { + out += ("0" + byte.toString(16)).slice(-2); + } + return out; +} + +export function fromHex(hexstring: string): Uint8Array { + if (hexstring.length % 2 !== 0) { + throw new Error("hex string length must be a multiple of 2"); + } + + const listOfInts: number[] = []; + for (let i = 0; i < hexstring.length; i += 2) { + const hexByteAsString = hexstring.substr(i, 2); + if (!hexByteAsString.match(/[0-9a-f]{2}/i)) { + throw new Error("hex string contains invalid characters"); + } + listOfInts.push(parseInt(hexByteAsString, 16)); + } + return new Uint8Array(listOfInts); +} diff --git a/packages/encoding/src/index.ts b/packages/encoding/src/index.ts new file mode 100644 index 0000000000..6fb102e2e6 --- /dev/null +++ b/packages/encoding/src/index.ts @@ -0,0 +1,6 @@ +export { fromAscii, toAscii } from "./ascii"; +export { fromBase64, toBase64 } from "./base64"; +export { Bech32 } from "./bech32"; +export { fromHex, toHex } from "./hex"; +export { fromRfc3339, toRfc3339 } from "./rfc3339"; +export { fromUtf8, toUtf8 } from "./utf8"; diff --git a/packages/encoding/src/rfc3339.spec.ts b/packages/encoding/src/rfc3339.spec.ts new file mode 100644 index 0000000000..227f1e328e --- /dev/null +++ b/packages/encoding/src/rfc3339.spec.ts @@ -0,0 +1,178 @@ +import { fromRfc3339, toRfc3339 } from "./rfc3339"; + +describe("RFC3339", () => { + it("parses dates with different time zones", () => { + // time zone +/- 0 + expect(fromRfc3339("2002-10-02T11:12:13+00:00")).toEqual(new Date(Date.UTC(2002, 9, 2, 11, 12, 13))); + expect(fromRfc3339("2002-10-02T11:12:13-00:00")).toEqual(new Date(Date.UTC(2002, 9, 2, 11, 12, 13))); + expect(fromRfc3339("2002-10-02T11:12:13Z")).toEqual(new Date(Date.UTC(2002, 9, 2, 11, 12, 13))); + + // time zone positive (full hours) + expect(fromRfc3339("2002-10-02T11:12:13+01:00")).toEqual(new Date(Date.UTC(2002, 9, 2, 11 - 1, 12, 13))); + expect(fromRfc3339("2002-10-02T11:12:13+02:00")).toEqual(new Date(Date.UTC(2002, 9, 2, 11 - 2, 12, 13))); + expect(fromRfc3339("2002-10-02T11:12:13+03:00")).toEqual(new Date(Date.UTC(2002, 9, 2, 11 - 3, 12, 13))); + expect(fromRfc3339("2002-10-02T11:12:13+11:00")).toEqual(new Date(Date.UTC(2002, 9, 2, 11 - 11, 12, 13))); + + // time zone negative (full hours) + expect(fromRfc3339("2002-10-02T11:12:13-01:00")).toEqual(new Date(Date.UTC(2002, 9, 2, 11 + 1, 12, 13))); + expect(fromRfc3339("2002-10-02T11:12:13-02:00")).toEqual(new Date(Date.UTC(2002, 9, 2, 11 + 2, 12, 13))); + expect(fromRfc3339("2002-10-02T11:12:13-03:00")).toEqual(new Date(Date.UTC(2002, 9, 2, 11 + 3, 12, 13))); + expect(fromRfc3339("2002-10-02T11:12:13-11:00")).toEqual(new Date(Date.UTC(2002, 9, 2, 11 + 11, 12, 13))); + + // time zone positive (minutes only) + expect(fromRfc3339("2002-10-02T11:12:13+00:01")).toEqual(new Date(Date.UTC(2002, 9, 2, 11, 12 - 1, 13))); + expect(fromRfc3339("2002-10-02T11:12:13+00:30")).toEqual(new Date(Date.UTC(2002, 9, 2, 11, 12 - 30, 13))); + expect(fromRfc3339("2002-10-02T11:12:13+00:45")).toEqual(new Date(Date.UTC(2002, 9, 2, 11, 12 - 45, 13))); + + // time zone negative (minutes only) + expect(fromRfc3339("2002-10-02T11:12:13-00:01")).toEqual(new Date(Date.UTC(2002, 9, 2, 11, 12 + 1, 13))); + expect(fromRfc3339("2002-10-02T11:12:13-00:30")).toEqual(new Date(Date.UTC(2002, 9, 2, 11, 12 + 30, 13))); + expect(fromRfc3339("2002-10-02T11:12:13-00:45")).toEqual(new Date(Date.UTC(2002, 9, 2, 11, 12 + 45, 13))); + + // time zone positive (hours and minutes) + expect(fromRfc3339("2002-10-02T11:12:13+01:01")).toEqual( + new Date(Date.UTC(2002, 9, 2, 11 - 1, 12 - 1, 13)), + ); + expect(fromRfc3339("2002-10-02T11:12:13+04:30")).toEqual( + new Date(Date.UTC(2002, 9, 2, 11 - 4, 12 - 30, 13)), + ); + expect(fromRfc3339("2002-10-02T11:12:13+10:20")).toEqual( + new Date(Date.UTC(2002, 9, 2, 11 - 10, 12 - 20, 13)), + ); + + // time zone negative (hours and minutes) + expect(fromRfc3339("2002-10-02T11:12:13-01:01")).toEqual( + new Date(Date.UTC(2002, 9, 2, 11 + 1, 12 + 1, 13)), + ); + expect(fromRfc3339("2002-10-02T11:12:13-04:30")).toEqual( + new Date(Date.UTC(2002, 9, 2, 11 + 4, 12 + 30, 13)), + ); + expect(fromRfc3339("2002-10-02T11:12:13-10:20")).toEqual( + new Date(Date.UTC(2002, 9, 2, 11 + 10, 12 + 20, 13)), + ); + }); + + it("parses dates with milliseconds", () => { + expect(fromRfc3339("2002-10-02T11:12:13.000Z")).toEqual(new Date(Date.UTC(2002, 9, 2, 11, 12, 13, 0))); + expect(fromRfc3339("2002-10-02T11:12:13.123Z")).toEqual(new Date(Date.UTC(2002, 9, 2, 11, 12, 13, 123))); + expect(fromRfc3339("2002-10-02T11:12:13.999Z")).toEqual(new Date(Date.UTC(2002, 9, 2, 11, 12, 13, 999))); + }); + + it("parses dates with low precision fractional seconds", () => { + // 1 digit + expect(fromRfc3339("2002-10-02T11:12:13.0Z")).toEqual(new Date(Date.UTC(2002, 9, 2, 11, 12, 13, 0))); + expect(fromRfc3339("2002-10-02T11:12:13.1Z")).toEqual(new Date(Date.UTC(2002, 9, 2, 11, 12, 13, 100))); + expect(fromRfc3339("2002-10-02T11:12:13.9Z")).toEqual(new Date(Date.UTC(2002, 9, 2, 11, 12, 13, 900))); + + // 2 digit + expect(fromRfc3339("2002-10-02T11:12:13.00Z")).toEqual(new Date(Date.UTC(2002, 9, 2, 11, 12, 13, 0))); + expect(fromRfc3339("2002-10-02T11:12:13.12Z")).toEqual(new Date(Date.UTC(2002, 9, 2, 11, 12, 13, 120))); + expect(fromRfc3339("2002-10-02T11:12:13.99Z")).toEqual(new Date(Date.UTC(2002, 9, 2, 11, 12, 13, 990))); + }); + + it("parses dates with high precision fractional seconds", () => { + // everything after the 3rd digit is truncated + + // 4 digits + expect(fromRfc3339("2002-10-02T11:12:13.0000Z")).toEqual(new Date(Date.UTC(2002, 9, 2, 11, 12, 13, 0))); + expect(fromRfc3339("2002-10-02T11:12:13.1234Z")).toEqual(new Date(Date.UTC(2002, 9, 2, 11, 12, 13, 123))); + expect(fromRfc3339("2002-10-02T11:12:13.9999Z")).toEqual(new Date(Date.UTC(2002, 9, 2, 11, 12, 13, 999))); + + // 5 digits + expect(fromRfc3339("2002-10-02T11:12:13.00000Z")).toEqual(new Date(Date.UTC(2002, 9, 2, 11, 12, 13, 0))); + expect(fromRfc3339("2002-10-02T11:12:13.12345Z")).toEqual( + new Date(Date.UTC(2002, 9, 2, 11, 12, 13, 123)), + ); + expect(fromRfc3339("2002-10-02T11:12:13.99999Z")).toEqual( + new Date(Date.UTC(2002, 9, 2, 11, 12, 13, 999)), + ); + + // 6 digits + expect(fromRfc3339("2002-10-02T11:12:13.000000Z")).toEqual(new Date(Date.UTC(2002, 9, 2, 11, 12, 13, 0))); + expect(fromRfc3339("2002-10-02T11:12:13.123456Z")).toEqual( + new Date(Date.UTC(2002, 9, 2, 11, 12, 13, 123)), + ); + expect(fromRfc3339("2002-10-02T11:12:13.999999Z")).toEqual( + new Date(Date.UTC(2002, 9, 2, 11, 12, 13, 999)), + ); + + // 7 digits + expect(fromRfc3339("2002-10-02T11:12:13.0000000Z")).toEqual( + new Date(Date.UTC(2002, 9, 2, 11, 12, 13, 0)), + ); + expect(fromRfc3339("2002-10-02T11:12:13.1234567Z")).toEqual( + new Date(Date.UTC(2002, 9, 2, 11, 12, 13, 123)), + ); + expect(fromRfc3339("2002-10-02T11:12:13.9999999Z")).toEqual( + new Date(Date.UTC(2002, 9, 2, 11, 12, 13, 999)), + ); + + // 8 digits + expect(fromRfc3339("2002-10-02T11:12:13.00000000Z")).toEqual( + new Date(Date.UTC(2002, 9, 2, 11, 12, 13, 0)), + ); + expect(fromRfc3339("2002-10-02T11:12:13.12345678Z")).toEqual( + new Date(Date.UTC(2002, 9, 2, 11, 12, 13, 123)), + ); + expect(fromRfc3339("2002-10-02T11:12:13.99999999Z")).toEqual( + new Date(Date.UTC(2002, 9, 2, 11, 12, 13, 999)), + ); + + // 9 digits + expect(fromRfc3339("2002-10-02T11:12:13.000000000Z")).toEqual( + new Date(Date.UTC(2002, 9, 2, 11, 12, 13, 0)), + ); + expect(fromRfc3339("2002-10-02T11:12:13.123456789Z")).toEqual( + new Date(Date.UTC(2002, 9, 2, 11, 12, 13, 123)), + ); + expect(fromRfc3339("2002-10-02T11:12:13.999999999Z")).toEqual( + new Date(Date.UTC(2002, 9, 2, 11, 12, 13, 999)), + ); + }); + + it("accepts space separators", () => { + // https://tools.ietf.org/html/rfc3339#section-5.6 + // Applications using this syntax may choose, for the sake of readability, + // to specify a full-date and full-time separated by (say) a space character. + expect(fromRfc3339("2002-10-02 11:12:13Z")).toEqual(new Date(Date.UTC(2002, 9, 2, 11, 12, 13))); + }); + + it("throws for invalid format", () => { + // extra whitespace + expect(() => fromRfc3339(" 2002-10-02T11:12:13Z")).toThrow(); + expect(() => fromRfc3339("2002-10-02T11:12:13Z ")).toThrow(); + expect(() => fromRfc3339("2002-10-02T11:12:13 Z")).toThrow(); + + // wrong date separators + expect(() => fromRfc3339("2002:10-02T11:12:13Z")).toThrow(); + expect(() => fromRfc3339("2002-10:02T11:12:13Z")).toThrow(); + + // wrong time separators + expect(() => fromRfc3339("2002-10-02T11-12:13Z")).toThrow(); + expect(() => fromRfc3339("2002-10-02T11:12-13Z")).toThrow(); + + // wrong separator + expect(() => fromRfc3339("2002-10-02TT11:12:13Z")).toThrow(); + expect(() => fromRfc3339("2002-10-02 T11:12:13Z")).toThrow(); + expect(() => fromRfc3339("2002-10-02T 11:12:13Z")).toThrow(); + expect(() => fromRfc3339("2002-10-02t11:12:13Z")).toThrow(); + expect(() => fromRfc3339("2002-10-02x11:12:13Z")).toThrow(); + expect(() => fromRfc3339("2002-10-02311:12:13Z")).toThrow(); + expect(() => fromRfc3339("2002-10-02.11:12:13Z")).toThrow(); + + // wrong time zone + expect(() => fromRfc3339("2002-10-02T11:12:13")).toThrow(); + expect(() => fromRfc3339("2002-10-02T11:12:13z")).toThrow(); + expect(() => fromRfc3339("2002-10-02T11:12:13 00:00")).toThrow(); + expect(() => fromRfc3339("2002-10-02T11:12:13+0000")).toThrow(); + + // wrong fractional seconds + expect(() => fromRfc3339("2018-07-30T19:21:12345Z")).toThrow(); + expect(() => fromRfc3339("2018-07-30T19:21:12.Z")).toThrow(); + }); + + it("encodes dates", () => { + expect(toRfc3339(new Date(Date.UTC(0, 0, 1, 0, 0, 0)))).toEqual("1900-01-01T00:00:00.000Z"); + expect(toRfc3339(new Date(Date.UTC(2002, 9, 2, 11, 12, 13, 456)))).toEqual("2002-10-02T11:12:13.456Z"); + }); +}); diff --git a/packages/encoding/src/rfc3339.ts b/packages/encoding/src/rfc3339.ts new file mode 100644 index 0000000000..1b58fe140e --- /dev/null +++ b/packages/encoding/src/rfc3339.ts @@ -0,0 +1,58 @@ +import { ReadonlyDate } from "readonly-date"; + +const rfc3339Matcher = /^(\d{4})-(\d{2})-(\d{2})[T ](\d{2}):(\d{2}):(\d{2})(\.\d{1,9})?((?:[+-]\d{2}:\d{2})|Z)$/; + +function padded(integer: number, length = 2): string { + const filled = "00000" + integer.toString(); + return filled.substring(filled.length - length); +} + +export function fromRfc3339(str: string): ReadonlyDate { + const matches = rfc3339Matcher.exec(str); + if (!matches) { + throw new Error("Date string is not in RFC3339 format"); + } + + const year = +matches[1]; + const month = +matches[2]; + const day = +matches[3]; + const hour = +matches[4]; + const minute = +matches[5]; + const second = +matches[6]; + + // fractional seconds match either undefined or a string like ".1", ".123456789" + const milliSeconds = matches[7] ? Math.floor(+matches[7] * 1000) : 0; + + let tzOffsetSign: number; + let tzOffsetHours: number; + let tzOffsetMinutes: number; + + // if timezone is undefined, it must be Z or nothing (otherwise the group would have captured). + if (matches[8] === "Z") { + tzOffsetSign = 1; + tzOffsetHours = 0; + tzOffsetMinutes = 0; + } else { + tzOffsetSign = matches[8].substring(0, 1) === "-" ? -1 : 1; + tzOffsetHours = +matches[8].substring(1, 3); + tzOffsetMinutes = +matches[8].substring(4, 6); + } + + const tzOffset = tzOffsetSign * (tzOffsetHours * 60 + tzOffsetMinutes) * 60; // seconds + + return new ReadonlyDate( + ReadonlyDate.UTC(year, month - 1, day, hour, minute, second, milliSeconds) - tzOffset * 1000, + ); +} + +export function toRfc3339(date: Date | ReadonlyDate): string { + const year = date.getUTCFullYear(); + const month = padded(date.getUTCMonth() + 1); + const day = padded(date.getUTCDate()); + const hour = padded(date.getUTCHours()); + const minute = padded(date.getUTCMinutes()); + const second = padded(date.getUTCSeconds()); + const ms = padded(date.getUTCMilliseconds(), 3); + + return `${year}-${month}-${day}T${hour}:${minute}:${second}.${ms}Z`; +} diff --git a/packages/encoding/src/utf8.spec.ts b/packages/encoding/src/utf8.spec.ts new file mode 100644 index 0000000000..be52ef9d99 --- /dev/null +++ b/packages/encoding/src/utf8.spec.ts @@ -0,0 +1,62 @@ +import { fromUtf8, toUtf8 } from "./utf8"; + +describe("utf8", () => { + it("encodes ascii strings", () => { + expect(toUtf8("")).toEqual(new Uint8Array([])); + expect(toUtf8("abc")).toEqual(new Uint8Array([0x61, 0x62, 0x63])); + expect(toUtf8(" ?=-n|~+-*/\\")).toEqual( + new Uint8Array([0x20, 0x3f, 0x3d, 0x2d, 0x6e, 0x7c, 0x7e, 0x2b, 0x2d, 0x2a, 0x2f, 0x5c]), + ); + }); + + it("decodes ascii string", () => { + expect(fromUtf8(new Uint8Array([]))).toEqual(""); + expect(fromUtf8(new Uint8Array([0x61, 0x62, 0x63]))).toEqual("abc"); + expect( + fromUtf8(new Uint8Array([0x20, 0x3f, 0x3d, 0x2d, 0x6e, 0x7c, 0x7e, 0x2b, 0x2d, 0x2a, 0x2f, 0x5c])), + ).toEqual(" ?=-n|~+-*/\\"); + }); + + it("encodes null character", () => { + expect(toUtf8("\u0000")).toEqual(new Uint8Array([0x00])); + }); + + it("decodes null byte", () => { + expect(fromUtf8(new Uint8Array([0x00]))).toEqual("\u0000"); + }); + + it("encodes Basic Multilingual Plane strings", () => { + expect(toUtf8("ö")).toEqual(new Uint8Array([0xc3, 0xb6])); + expect(toUtf8("¥")).toEqual(new Uint8Array([0xc2, 0xa5])); + expect(toUtf8("Ф")).toEqual(new Uint8Array([0xd0, 0xa4])); + expect(toUtf8("ⱴ")).toEqual(new Uint8Array([0xe2, 0xb1, 0xb4])); + expect(toUtf8("ⵘ")).toEqual(new Uint8Array([0xe2, 0xb5, 0x98])); + }); + + it("decodes Basic Multilingual Plane strings", () => { + expect(fromUtf8(new Uint8Array([0xc3, 0xb6]))).toEqual("ö"); + expect(fromUtf8(new Uint8Array([0xc2, 0xa5]))).toEqual("¥"); + expect(fromUtf8(new Uint8Array([0xd0, 0xa4]))).toEqual("Ф"); + expect(fromUtf8(new Uint8Array([0xe2, 0xb1, 0xb4]))).toEqual("ⱴ"); + expect(fromUtf8(new Uint8Array([0xe2, 0xb5, 0x98]))).toEqual("ⵘ"); + }); + + it("encodes Supplementary Multilingual Plane strings", () => { + // U+1F0A1 + expect(toUtf8("🂡")).toEqual(new Uint8Array([0xf0, 0x9f, 0x82, 0xa1])); + // U+1034A + expect(toUtf8("𐍊")).toEqual(new Uint8Array([0xf0, 0x90, 0x8d, 0x8a])); + }); + + it("decodes Supplementary Multilingual Plane strings", () => { + // U+1F0A1 + expect(fromUtf8(new Uint8Array([0xf0, 0x9f, 0x82, 0xa1]))).toEqual("🂡"); + // U+1034A + expect(fromUtf8(new Uint8Array([0xf0, 0x90, 0x8d, 0x8a]))).toEqual("𐍊"); + }); + + it("throws on invalid utf8 bytes", () => { + // Broken UTF8 example from https://github.com/nodejs/node/issues/16894 + expect(() => fromUtf8(new Uint8Array([0xf0, 0x80, 0x80]))).toThrow(); + }); +}); diff --git a/packages/encoding/src/utf8.ts b/packages/encoding/src/utf8.ts new file mode 100644 index 0000000000..75b16d557d --- /dev/null +++ b/packages/encoding/src/utf8.ts @@ -0,0 +1,36 @@ +// Global symbols in some environments +// https://developer.mozilla.org/en-US/docs/Web/API/TextEncoder +// https://developer.mozilla.org/en-US/docs/Web/API/TextDecoder +declare const TextEncoder: any | undefined; +declare const TextDecoder: any | undefined; + +function isValidUtf8(data: Uint8Array): boolean { + const toStringAndBack = Buffer.from(Buffer.from(data).toString("utf8"), "utf8"); + return Buffer.compare(Buffer.from(data), toStringAndBack) === 0; +} + +export function toUtf8(str: string): Uint8Array { + // Browser and future nodejs (https://github.com/nodejs/node/issues/20365) + if (typeof TextEncoder !== "undefined") { + return new TextEncoder().encode(str); + } + + // Use Buffer hack instead of nodejs util.TextEncoder to ensure + // webpack does not bundle the util module for browsers. + return new Uint8Array(Buffer.from(str, "utf8")); +} + +export function fromUtf8(data: Uint8Array): string { + // Browser and future nodejs (https://github.com/nodejs/node/issues/20365) + if (typeof TextDecoder !== "undefined") { + return new TextDecoder("utf-8", { fatal: true }).decode(data); + } + + // Use Buffer hack instead of nodejs util.TextDecoder to ensure + // webpack does not bundle the util module for browsers. + // Buffer.toString has no fatal option + if (!isValidUtf8(data)) { + throw new Error("Invalid UTF8 data"); + } + return Buffer.from(data).toString("utf8"); +} diff --git a/packages/encoding/tsconfig.json b/packages/encoding/tsconfig.json new file mode 100644 index 0000000000..167e8c0226 --- /dev/null +++ b/packages/encoding/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "baseUrl": ".", + "outDir": "build", + "declarationDir": "build/types", + "rootDir": "src" + }, + "include": [ + "src/**/*" + ] +} diff --git a/packages/encoding/typedoc.js b/packages/encoding/typedoc.js new file mode 100644 index 0000000000..e2387c7de4 --- /dev/null +++ b/packages/encoding/typedoc.js @@ -0,0 +1,14 @@ +const packageJson = require("./package.json"); + +module.exports = { + src: ["./src"], + out: "docs", + exclude: "**/*.spec.ts", + target: "es6", + name: `${packageJson.name} Documentation`, + readme: "README.md", + mode: "file", + excludeExternals: true, + excludeNotExported: true, + excludePrivate: true, +}; diff --git a/packages/encoding/types/ascii.d.ts b/packages/encoding/types/ascii.d.ts new file mode 100644 index 0000000000..42d32698fe --- /dev/null +++ b/packages/encoding/types/ascii.d.ts @@ -0,0 +1,2 @@ +export declare function toAscii(input: string): Uint8Array; +export declare function fromAscii(data: Uint8Array): string; diff --git a/packages/encoding/types/base64.d.ts b/packages/encoding/types/base64.d.ts new file mode 100644 index 0000000000..3eb3915c1d --- /dev/null +++ b/packages/encoding/types/base64.d.ts @@ -0,0 +1,2 @@ +export declare function toBase64(data: Uint8Array): string; +export declare function fromBase64(base64String: string): Uint8Array; diff --git a/packages/encoding/types/bech32.d.ts b/packages/encoding/types/bech32.d.ts new file mode 100644 index 0000000000..8ec29b94ae --- /dev/null +++ b/packages/encoding/types/bech32.d.ts @@ -0,0 +1,9 @@ +export declare class Bech32 { + static encode(prefix: string, data: Uint8Array): string; + static decode( + address: string, + ): { + readonly prefix: string; + readonly data: Uint8Array; + }; +} diff --git a/packages/encoding/types/hex.d.ts b/packages/encoding/types/hex.d.ts new file mode 100644 index 0000000000..a337851fff --- /dev/null +++ b/packages/encoding/types/hex.d.ts @@ -0,0 +1,2 @@ +export declare function toHex(data: Uint8Array): string; +export declare function fromHex(hexstring: string): Uint8Array; diff --git a/packages/encoding/types/index.d.ts b/packages/encoding/types/index.d.ts new file mode 100644 index 0000000000..6fb102e2e6 --- /dev/null +++ b/packages/encoding/types/index.d.ts @@ -0,0 +1,6 @@ +export { fromAscii, toAscii } from "./ascii"; +export { fromBase64, toBase64 } from "./base64"; +export { Bech32 } from "./bech32"; +export { fromHex, toHex } from "./hex"; +export { fromRfc3339, toRfc3339 } from "./rfc3339"; +export { fromUtf8, toUtf8 } from "./utf8"; diff --git a/packages/encoding/types/rfc3339.d.ts b/packages/encoding/types/rfc3339.d.ts new file mode 100644 index 0000000000..e19a9e8def --- /dev/null +++ b/packages/encoding/types/rfc3339.d.ts @@ -0,0 +1,3 @@ +import { ReadonlyDate } from "readonly-date"; +export declare function fromRfc3339(str: string): ReadonlyDate; +export declare function toRfc3339(date: Date | ReadonlyDate): string; diff --git a/packages/encoding/types/utf8.d.ts b/packages/encoding/types/utf8.d.ts new file mode 100644 index 0000000000..53df44c1bb --- /dev/null +++ b/packages/encoding/types/utf8.d.ts @@ -0,0 +1,2 @@ +export declare function toUtf8(str: string): Uint8Array; +export declare function fromUtf8(data: Uint8Array): string; diff --git a/packages/encoding/webpack.web.config.js b/packages/encoding/webpack.web.config.js new file mode 100644 index 0000000000..9d5836a8fb --- /dev/null +++ b/packages/encoding/webpack.web.config.js @@ -0,0 +1,17 @@ +const glob = require("glob"); +const path = require("path"); + +const target = "web"; +const distdir = path.join(__dirname, "dist", "web"); + +module.exports = [ + { + // bundle used for Karma tests + target: target, + entry: glob.sync("./build/**/*.spec.js"), + output: { + path: distdir, + filename: "tests.js", + }, + }, +]; diff --git a/packages/math/.eslintignore b/packages/math/.eslintignore new file mode 100644 index 0000000000..f373a53fc6 --- /dev/null +++ b/packages/math/.eslintignore @@ -0,0 +1,8 @@ +node_modules/ + +build/ +custom_types/ +dist/ +docs/ +generated/ +types/ diff --git a/packages/math/.gitignore b/packages/math/.gitignore new file mode 100644 index 0000000000..68bf373524 --- /dev/null +++ b/packages/math/.gitignore @@ -0,0 +1,3 @@ +build/ +dist/ +docs/ diff --git a/packages/math/README.md b/packages/math/README.md new file mode 100644 index 0000000000..3609209510 --- /dev/null +++ b/packages/math/README.md @@ -0,0 +1,10 @@ +# @cosmjs/math + +[![npm version](https://img.shields.io/npm/v/@cosmjs/math.svg)](https://www.npmjs.com/package/@cosmjs/math) + +## License + +This package is part of the cosmwasm-js repository, licensed under the Apache +License 2.0 (see +[NOTICE](https://github.com/confio/cosmwasm-js/blob/master/NOTICE) and +[LICENSE](https://github.com/confio/cosmwasm-js/blob/master/LICENSE)). diff --git a/packages/math/jasmine-testrunner.js b/packages/math/jasmine-testrunner.js new file mode 100755 index 0000000000..9fada59b28 --- /dev/null +++ b/packages/math/jasmine-testrunner.js @@ -0,0 +1,26 @@ +#!/usr/bin/env node + +require("source-map-support").install(); +const defaultSpecReporterConfig = require("../../jasmine-spec-reporter.config.json"); + +// setup Jasmine +const Jasmine = require("jasmine"); +const jasmine = new Jasmine(); +jasmine.loadConfig({ + spec_dir: "build", + spec_files: ["**/*.spec.js"], + helpers: [], + random: false, + seed: null, + stopSpecOnExpectationFailure: false, +}); +jasmine.jasmine.DEFAULT_TIMEOUT_INTERVAL = 15 * 1000; + +// setup reporter +const { SpecReporter } = require("jasmine-spec-reporter"); +const reporter = new SpecReporter({ ...defaultSpecReporterConfig }); + +// initialize and execute +jasmine.env.clearReporters(); +jasmine.addReporter(reporter); +jasmine.execute(); diff --git a/packages/math/karma.conf.js b/packages/math/karma.conf.js new file mode 100644 index 0000000000..006da5fe6b --- /dev/null +++ b/packages/math/karma.conf.js @@ -0,0 +1,47 @@ +module.exports = function (config) { + config.set({ + // base path that will be used to resolve all patterns (eg. files, exclude) + basePath: ".", + + // frameworks to use + // available frameworks: https://npmjs.org/browse/keyword/karma-adapter + frameworks: ["jasmine"], + + // list of files / patterns to load in the browser + files: ["dist/web/tests.js"], + + client: { + jasmine: { + random: false, + timeoutInterval: 15000, + }, + }, + + // test results reporter to use + // possible values: 'dots', 'progress' + // available reporters: https://npmjs.org/browse/keyword/karma-reporter + reporters: ["progress", "kjhtml"], + + // web server port + port: 9876, + + // enable / disable colors in the output (reporters and logs) + colors: true, + + // level of logging + // possible values: config.LOG_DISABLE || config.LOG_ERROR || config.LOG_WARN || config.LOG_INFO || config.LOG_DEBUG + logLevel: config.LOG_INFO, + + // enable / disable watching file and executing tests whenever any file changes + autoWatch: false, + + // start these browsers + // available browser launchers: https://npmjs.org/browse/keyword/karma-launcher + browsers: ["Firefox"], + + browserNoActivityTimeout: 90000, + + // Keep brower open for debugging. This is overridden by yarn scripts + singleRun: false, + }); +}; diff --git a/packages/math/nonces/README.txt b/packages/math/nonces/README.txt new file mode 100644 index 0000000000..092fe732f1 --- /dev/null +++ b/packages/math/nonces/README.txt @@ -0,0 +1 @@ +Directory used to trigger lerna package updates for all packages diff --git a/packages/math/package.json b/packages/math/package.json new file mode 100644 index 0000000000..b1c3953391 --- /dev/null +++ b/packages/math/package.json @@ -0,0 +1,49 @@ +{ + "name": "@cosmjs/math", + "version": "0.8.0", + "description": "Math helpers for blockchain projects", + "contributors": ["IOV SAS "], + "license": "Apache-2.0", + "main": "build/index.js", + "types": "types/index.d.ts", + "files": [ + "build/", + "types/", + "*.md", + "!*.spec.*", + "!**/testdata/" + ], + "repository": { + "type": "git", + "url": "https://github.com/CosmWasm/cosmwasm-js/tree/master/packages/math" + }, + "publishConfig": { + "access": "public" + }, + "scripts": { + "docs": "shx rm -rf docs && typedoc --options typedoc.js", + "lint": "eslint --max-warnings 0 \"**/*.{js,ts}\"", + "format": "prettier --write --loglevel warn \"./src/**/*.ts\"", + "test-node": "node jasmine-testrunner.js", + "test-edge": "yarn pack-web && karma start --single-run --browsers Edge", + "test-firefox": "yarn pack-web && karma start --single-run --browsers Firefox", + "test-chrome": "yarn pack-web && karma start --single-run --browsers ChromeHeadless", + "test-safari": "yarn pack-web && karma start --single-run --browsers Safari", + "test": "yarn build-or-skip && yarn test-node", + "move-types": "shx rm -r ./types/* && shx mv build/types/* ./types && rm -rf ./types/testdata && shx rm -f ./types/*.spec.d.ts", + "format-types": "prettier --write --loglevel warn \"./types/**/*.d.ts\"", + "build": "shx rm -rf ./build && tsc && yarn move-types && yarn format-types", + "build-or-skip": "[ -n \"$SKIP_BUILD\" ] || yarn build", + "pack-web": "yarn build-or-skip && webpack --mode development --config webpack.web.config.js" + }, + "dependencies": { + "base64-js": "^1.3.0", + "bech32": "^1.1.4", + "bn.js": "^4.11.8", + "readonly-date": "^1.0.0" + }, + "devDependencies": { + "@types/base64-js": "^1.2.5", + "@types/bn.js": "^4.11.6" + } +} diff --git a/packages/math/src/decimal.spec.ts b/packages/math/src/decimal.spec.ts new file mode 100644 index 0000000000..f0d6f1d354 --- /dev/null +++ b/packages/math/src/decimal.spec.ts @@ -0,0 +1,213 @@ +import { Decimal } from "./decimal"; + +describe("Decimal", () => { + describe("fromAtomics", () => { + it("leads to correct atomics value", () => { + expect(Decimal.fromAtomics("1", 0).atomics).toEqual("1"); + expect(Decimal.fromAtomics("1", 1).atomics).toEqual("1"); + expect(Decimal.fromAtomics("1", 2).atomics).toEqual("1"); + + expect(Decimal.fromAtomics("1", 5).atomics).toEqual("1"); + expect(Decimal.fromAtomics("2", 5).atomics).toEqual("2"); + expect(Decimal.fromAtomics("3", 5).atomics).toEqual("3"); + expect(Decimal.fromAtomics("10", 5).atomics).toEqual("10"); + expect(Decimal.fromAtomics("20", 5).atomics).toEqual("20"); + expect(Decimal.fromAtomics("30", 5).atomics).toEqual("30"); + expect(Decimal.fromAtomics("100000000000000000000000", 5).atomics).toEqual("100000000000000000000000"); + expect(Decimal.fromAtomics("200000000000000000000000", 5).atomics).toEqual("200000000000000000000000"); + expect(Decimal.fromAtomics("300000000000000000000000", 5).atomics).toEqual("300000000000000000000000"); + + expect(Decimal.fromAtomics("44", 5).atomics).toEqual("44"); + expect(Decimal.fromAtomics("044", 5).atomics).toEqual("44"); + expect(Decimal.fromAtomics("0044", 5).atomics).toEqual("44"); + expect(Decimal.fromAtomics("00044", 5).atomics).toEqual("44"); + }); + + it("reads fractional digits correctly", () => { + expect(Decimal.fromAtomics("44", 0).toString()).toEqual("44"); + expect(Decimal.fromAtomics("44", 1).toString()).toEqual("4.4"); + expect(Decimal.fromAtomics("44", 2).toString()).toEqual("0.44"); + expect(Decimal.fromAtomics("44", 3).toString()).toEqual("0.044"); + expect(Decimal.fromAtomics("44", 4).toString()).toEqual("0.0044"); + }); + }); + + describe("fromUserInput", () => { + it("throws helpful error message for invalid characters", () => { + expect(() => Decimal.fromUserInput(" 13", 5)).toThrowError(/invalid character at position 1/i); + expect(() => Decimal.fromUserInput("1,3", 5)).toThrowError(/invalid character at position 2/i); + expect(() => Decimal.fromUserInput("13-", 5)).toThrowError(/invalid character at position 3/i); + expect(() => Decimal.fromUserInput("13/", 5)).toThrowError(/invalid character at position 3/i); + expect(() => Decimal.fromUserInput("13\\", 5)).toThrowError(/invalid character at position 3/i); + }); + + it("throws for more than one separator", () => { + expect(() => Decimal.fromUserInput("1.3.5", 5)).toThrowError(/more than one separator found/i); + expect(() => Decimal.fromUserInput("1..3", 5)).toThrowError(/more than one separator found/i); + expect(() => Decimal.fromUserInput("..", 5)).toThrowError(/more than one separator found/i); + }); + + it("throws for separator only", () => { + expect(() => Decimal.fromUserInput(".", 5)).toThrowError(/fractional part missing/i); + }); + + it("throws for more fractional digits than supported", () => { + expect(() => Decimal.fromUserInput("44.123456", 5)).toThrowError( + /got more fractional digits than supported/i, + ); + expect(() => Decimal.fromUserInput("44.1", 0)).toThrowError( + /got more fractional digits than supported/i, + ); + }); + + it("throws for fractional digits that are not non-negative integers", () => { + // no integer + expect(() => Decimal.fromUserInput("1", Number.NaN)).toThrowError( + /fractional digits is not an integer/i, + ); + expect(() => Decimal.fromUserInput("1", Number.POSITIVE_INFINITY)).toThrowError( + /fractional digits is not an integer/i, + ); + expect(() => Decimal.fromUserInput("1", Number.NEGATIVE_INFINITY)).toThrowError( + /fractional digits is not an integer/i, + ); + expect(() => Decimal.fromUserInput("1", 1.78945544484)).toThrowError( + /fractional digits is not an integer/i, + ); + + // negative + expect(() => Decimal.fromUserInput("1", -1)).toThrowError(/fractional digits must not be negative/i); + expect(() => Decimal.fromUserInput("1", Number.MIN_SAFE_INTEGER)).toThrowError( + /fractional digits must not be negative/i, + ); + + // exceeds supported range + expect(() => Decimal.fromUserInput("1", 101)).toThrowError(/fractional digits must not exceed 100/i); + }); + + it("returns correct value", () => { + expect(Decimal.fromUserInput("44", 0).atomics).toEqual("44"); + expect(Decimal.fromUserInput("44", 1).atomics).toEqual("440"); + expect(Decimal.fromUserInput("44", 2).atomics).toEqual("4400"); + expect(Decimal.fromUserInput("44", 3).atomics).toEqual("44000"); + + expect(Decimal.fromUserInput("44.2", 1).atomics).toEqual("442"); + expect(Decimal.fromUserInput("44.2", 2).atomics).toEqual("4420"); + expect(Decimal.fromUserInput("44.2", 3).atomics).toEqual("44200"); + + expect(Decimal.fromUserInput("44.1", 6).atomics).toEqual("44100000"); + expect(Decimal.fromUserInput("44.12", 6).atomics).toEqual("44120000"); + expect(Decimal.fromUserInput("44.123", 6).atomics).toEqual("44123000"); + expect(Decimal.fromUserInput("44.1234", 6).atomics).toEqual("44123400"); + expect(Decimal.fromUserInput("44.12345", 6).atomics).toEqual("44123450"); + expect(Decimal.fromUserInput("44.123456", 6).atomics).toEqual("44123456"); + }); + + it("cuts leading zeros", () => { + expect(Decimal.fromUserInput("4", 2).atomics).toEqual("400"); + expect(Decimal.fromUserInput("04", 2).atomics).toEqual("400"); + expect(Decimal.fromUserInput("004", 2).atomics).toEqual("400"); + }); + + it("cuts tailing zeros", () => { + expect(Decimal.fromUserInput("4.12", 5).atomics).toEqual("412000"); + expect(Decimal.fromUserInput("4.120", 5).atomics).toEqual("412000"); + expect(Decimal.fromUserInput("4.1200", 5).atomics).toEqual("412000"); + expect(Decimal.fromUserInput("4.12000", 5).atomics).toEqual("412000"); + expect(Decimal.fromUserInput("4.120000", 5).atomics).toEqual("412000"); + expect(Decimal.fromUserInput("4.1200000", 5).atomics).toEqual("412000"); + }); + + it("interprets the empty string as zero", () => { + expect(Decimal.fromUserInput("", 0).atomics).toEqual("0"); + expect(Decimal.fromUserInput("", 1).atomics).toEqual("0"); + expect(Decimal.fromUserInput("", 2).atomics).toEqual("0"); + expect(Decimal.fromUserInput("", 3).atomics).toEqual("0"); + }); + + it("accepts american notation with skipped leading zero", () => { + expect(Decimal.fromUserInput(".1", 3).atomics).toEqual("100"); + expect(Decimal.fromUserInput(".12", 3).atomics).toEqual("120"); + expect(Decimal.fromUserInput(".123", 3).atomics).toEqual("123"); + }); + }); + + describe("toString", () => { + it("displays no decimal point for full numbers", () => { + expect(Decimal.fromUserInput("44", 0).toString()).toEqual("44"); + expect(Decimal.fromUserInput("44", 1).toString()).toEqual("44"); + expect(Decimal.fromUserInput("44", 2).toString()).toEqual("44"); + + expect(Decimal.fromUserInput("44", 2).toString()).toEqual("44"); + expect(Decimal.fromUserInput("44.0", 2).toString()).toEqual("44"); + expect(Decimal.fromUserInput("44.00", 2).toString()).toEqual("44"); + expect(Decimal.fromUserInput("44.000", 2).toString()).toEqual("44"); + }); + + it("only shows significant digits", () => { + expect(Decimal.fromUserInput("44.1", 2).toString()).toEqual("44.1"); + expect(Decimal.fromUserInput("44.10", 2).toString()).toEqual("44.1"); + expect(Decimal.fromUserInput("44.100", 2).toString()).toEqual("44.1"); + }); + + it("fills up leading zeros", () => { + expect(Decimal.fromAtomics("3", 0).toString()).toEqual("3"); + expect(Decimal.fromAtomics("3", 1).toString()).toEqual("0.3"); + expect(Decimal.fromAtomics("3", 2).toString()).toEqual("0.03"); + expect(Decimal.fromAtomics("3", 3).toString()).toEqual("0.003"); + }); + }); + + describe("toFloatApproximation", () => { + it("works", () => { + expect(Decimal.fromUserInput("0", 5).toFloatApproximation()).toEqual(0); + expect(Decimal.fromUserInput("1", 5).toFloatApproximation()).toEqual(1); + expect(Decimal.fromUserInput("1.5", 5).toFloatApproximation()).toEqual(1.5); + expect(Decimal.fromUserInput("0.1", 5).toFloatApproximation()).toEqual(0.1); + + expect(Decimal.fromUserInput("1234500000000000", 5).toFloatApproximation()).toEqual(1.2345e15); + expect(Decimal.fromUserInput("1234500000000000.002", 5).toFloatApproximation()).toEqual(1.2345e15); + }); + }); + + describe("plus", () => { + it("returns correct values", () => { + const zero = Decimal.fromUserInput("0", 5); + expect(zero.plus(Decimal.fromUserInput("0", 5)).toString()).toEqual("0"); + expect(zero.plus(Decimal.fromUserInput("1", 5)).toString()).toEqual("1"); + expect(zero.plus(Decimal.fromUserInput("2", 5)).toString()).toEqual("2"); + expect(zero.plus(Decimal.fromUserInput("2.8", 5)).toString()).toEqual("2.8"); + expect(zero.plus(Decimal.fromUserInput("0.12345", 5)).toString()).toEqual("0.12345"); + + const one = Decimal.fromUserInput("1", 5); + expect(one.plus(Decimal.fromUserInput("0", 5)).toString()).toEqual("1"); + expect(one.plus(Decimal.fromUserInput("1", 5)).toString()).toEqual("2"); + expect(one.plus(Decimal.fromUserInput("2", 5)).toString()).toEqual("3"); + expect(one.plus(Decimal.fromUserInput("2.8", 5)).toString()).toEqual("3.8"); + expect(one.plus(Decimal.fromUserInput("0.12345", 5)).toString()).toEqual("1.12345"); + + const oneDotFive = Decimal.fromUserInput("1.5", 5); + expect(oneDotFive.plus(Decimal.fromUserInput("0", 5)).toString()).toEqual("1.5"); + expect(oneDotFive.plus(Decimal.fromUserInput("1", 5)).toString()).toEqual("2.5"); + expect(oneDotFive.plus(Decimal.fromUserInput("2", 5)).toString()).toEqual("3.5"); + expect(oneDotFive.plus(Decimal.fromUserInput("2.8", 5)).toString()).toEqual("4.3"); + expect(oneDotFive.plus(Decimal.fromUserInput("0.12345", 5)).toString()).toEqual("1.62345"); + + // original value remain unchanged + expect(zero.toString()).toEqual("0"); + expect(one.toString()).toEqual("1"); + expect(oneDotFive.toString()).toEqual("1.5"); + }); + + it("throws for different fractional digits", () => { + const zero = Decimal.fromUserInput("0", 5); + expect(() => zero.plus(Decimal.fromUserInput("1", 1))).toThrowError(/do not match/i); + expect(() => zero.plus(Decimal.fromUserInput("1", 2))).toThrowError(/do not match/i); + expect(() => zero.plus(Decimal.fromUserInput("1", 3))).toThrowError(/do not match/i); + expect(() => zero.plus(Decimal.fromUserInput("1", 4))).toThrowError(/do not match/i); + + expect(() => zero.plus(Decimal.fromUserInput("1", 6))).toThrowError(/do not match/i); + expect(() => zero.plus(Decimal.fromUserInput("1", 7))).toThrowError(/do not match/i); + }); + }); +}); diff --git a/packages/math/src/decimal.ts b/packages/math/src/decimal.ts new file mode 100644 index 0000000000..e126e4ed9c --- /dev/null +++ b/packages/math/src/decimal.ts @@ -0,0 +1,121 @@ +import BN from "bn.js"; + +// Too large values lead to massive memory usage. Limit to something sensible. +// The largest value we need is 18 (Ether). +const maxFractionalDigits = 100; + +/** + * A type for arbitrary precision, non-negative decimals. + * + * Instances of this class are immutable. + */ +export class Decimal { + public static fromUserInput(input: string, fractionalDigits: number): Decimal { + Decimal.verifyFractionalDigits(fractionalDigits); + + const badCharacter = input.match(/[^0-9.]/); + if (badCharacter) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + throw new Error(`Invalid character at position ${badCharacter.index! + 1}`); + } + + let whole: string; + let fractional: string; + + if (input.search(/\./) === -1) { + // integer format, no separator + whole = input; + fractional = ""; + } else { + const parts = input.split("."); + switch (parts.length) { + case 0: + case 1: + throw new Error("Fewer than two elements in split result. This must not happen here."); + case 2: + if (!parts[1]) throw new Error("Fractional part missing"); + whole = parts[0]; + fractional = parts[1].replace(/0+$/, ""); + break; + default: + throw new Error("More than one separator found"); + } + } + + if (fractional.length > fractionalDigits) { + throw new Error("Got more fractional digits than supported"); + } + + const quantity = `${whole}${fractional.padEnd(fractionalDigits, "0")}`; + + return new Decimal(quantity, fractionalDigits); + } + + public static fromAtomics(atomics: string, fractionalDigits: number): Decimal { + Decimal.verifyFractionalDigits(fractionalDigits); + return new Decimal(atomics, fractionalDigits); + } + + private static verifyFractionalDigits(fractionalDigits: number): void { + if (!Number.isInteger(fractionalDigits)) throw new Error("Fractional digits is not an integer"); + if (fractionalDigits < 0) throw new Error("Fractional digits must not be negative"); + if (fractionalDigits > maxFractionalDigits) { + throw new Error(`Fractional digits must not exceed ${maxFractionalDigits}`); + } + } + + public get atomics(): string { + return this.data.atomics.toString(); + } + + public get fractionalDigits(): number { + return this.data.fractionalDigits; + } + + private readonly data: { + readonly atomics: BN; + readonly fractionalDigits: number; + }; + + private constructor(atomics: string, fractionalDigits: number) { + this.data = { + atomics: new BN(atomics), + fractionalDigits: fractionalDigits, + }; + } + + public toString(): string { + const factor = new BN(10).pow(new BN(this.data.fractionalDigits)); + const whole = this.data.atomics.div(factor); + const fractional = this.data.atomics.mod(factor); + + if (fractional.isZero()) { + return whole.toString(); + } else { + const fullFractionalPart = fractional.toString().padStart(this.data.fractionalDigits, "0"); + const trimmedFractionalPart = fullFractionalPart.replace(/0+$/, ""); + return `${whole.toString()}.${trimmedFractionalPart}`; + } + } + + /** + * Returns an approximation as a float type. Only use this if no + * exact calculation is required. + */ + public toFloatApproximation(): number { + const out = Number(this.toString()); + if (Number.isNaN(out)) throw new Error("Conversion to number failed"); + return out; + } + + /** + * a.plus(b) returns a+b. + * + * Both values need to have the same fractional digits. + */ + public plus(b: Decimal): Decimal { + if (this.fractionalDigits !== b.fractionalDigits) throw new Error("Fractional digits do not match"); + const sum = this.data.atomics.add(new BN(b.atomics)); + return new Decimal(sum.toString(), this.fractionalDigits); + } +} diff --git a/packages/math/src/index.ts b/packages/math/src/index.ts new file mode 100644 index 0000000000..b888136fd9 --- /dev/null +++ b/packages/math/src/index.ts @@ -0,0 +1,2 @@ +export { Decimal } from "./decimal"; +export { Int53, Uint32, Uint53, Uint64 } from "./integers"; diff --git a/packages/math/src/integers.spec.ts b/packages/math/src/integers.spec.ts new file mode 100644 index 0000000000..2b3a5b5aa3 --- /dev/null +++ b/packages/math/src/integers.spec.ts @@ -0,0 +1,475 @@ +import { Int53, Uint32, Uint53, Uint64 } from "./integers"; + +describe("Integers", () => { + describe("Uint32", () => { + it("can be constructed", () => { + expect(new Uint32(0)).toBeTruthy(); + expect(new Uint32(1)).toBeTruthy(); + expect(new Uint32(1.0)).toBeTruthy(); + expect(new Uint32(42)).toBeTruthy(); + expect(new Uint32(1000000000)).toBeTruthy(); + expect(new Uint32(2147483647)).toBeTruthy(); + expect(new Uint32(2147483648)).toBeTruthy(); + expect(new Uint32(4294967295)).toBeTruthy(); + }); + + it("throws for invald numbers", () => { + expect(() => new Uint32(NaN)).toThrowError(/not a number/); + + expect(() => new Uint32(1.1)).toThrowError(/not an integer/i); + expect(() => new Uint32(Number.NEGATIVE_INFINITY)).toThrowError(/not an integer/i); + expect(() => new Uint32(Number.POSITIVE_INFINITY)).toThrowError(/not an integer/i); + }); + + it("throws for values out of range", () => { + expect(() => new Uint32(-1)).toThrowError(/not in uint32 range/); + expect(() => new Uint32(4294967296)).toThrowError(/not in uint32 range/); + expect(() => new Uint32(Number.MIN_SAFE_INTEGER)).toThrowError(/not in uint32 range/); + expect(() => new Uint32(Number.MAX_SAFE_INTEGER)).toThrowError(/not in uint32 range/); + }); + + it("can convert to number", () => { + expect(new Uint32(0).toNumber()).toEqual(0); + expect(new Uint32(1).toNumber()).toEqual(1); + expect(new Uint32(42).toNumber()).toEqual(42); + expect(new Uint32(1000000000).toNumber()).toEqual(1000000000); + expect(new Uint32(2147483647).toNumber()).toEqual(2147483647); + expect(new Uint32(2147483648).toNumber()).toEqual(2147483648); + expect(new Uint32(4294967295).toNumber()).toEqual(4294967295); + }); + + it("can convert to string", () => { + expect(new Uint32(0).toString()).toEqual("0"); + expect(new Uint32(1).toString()).toEqual("1"); + expect(new Uint32(42).toString()).toEqual("42"); + expect(new Uint32(1000000000).toString()).toEqual("1000000000"); + expect(new Uint32(2147483647).toString()).toEqual("2147483647"); + expect(new Uint32(2147483648).toString()).toEqual("2147483648"); + expect(new Uint32(4294967295).toString()).toEqual("4294967295"); + }); + + describe("toBytesBigEndian", () => { + it("works", () => { + expect(new Uint32(0).toBytesBigEndian()).toEqual(new Uint8Array([0, 0, 0, 0])); + expect(new Uint32(1).toBytesBigEndian()).toEqual(new Uint8Array([0, 0, 0, 1])); + expect(new Uint32(42).toBytesBigEndian()).toEqual(new Uint8Array([0, 0, 0, 42])); + expect(new Uint32(1000000000).toBytesBigEndian()).toEqual(new Uint8Array([0x3b, 0x9a, 0xca, 0x00])); + expect(new Uint32(2147483647).toBytesBigEndian()).toEqual(new Uint8Array([0x7f, 0xff, 0xff, 0xff])); + expect(new Uint32(2147483648).toBytesBigEndian()).toEqual(new Uint8Array([0x80, 0x00, 0x00, 0x00])); + expect(new Uint32(4294967295).toBytesBigEndian()).toEqual(new Uint8Array([0xff, 0xff, 0xff, 0xff])); + }); + }); + + describe("toBytesLittleEndian", () => { + it("works", () => { + expect(new Uint32(0).toBytesLittleEndian()).toEqual(new Uint8Array([0, 0, 0, 0])); + expect(new Uint32(1).toBytesLittleEndian()).toEqual(new Uint8Array([1, 0, 0, 0])); + expect(new Uint32(42).toBytesLittleEndian()).toEqual(new Uint8Array([42, 0, 0, 0])); + expect(new Uint32(1000000000).toBytesLittleEndian()).toEqual( + new Uint8Array([0x00, 0xca, 0x9a, 0x3b]), + ); + expect(new Uint32(2147483647).toBytesLittleEndian()).toEqual( + new Uint8Array([0xff, 0xff, 0xff, 0x7f]), + ); + expect(new Uint32(2147483648).toBytesLittleEndian()).toEqual( + new Uint8Array([0x00, 0x00, 0x00, 0x80]), + ); + expect(new Uint32(4294967295).toBytesLittleEndian()).toEqual( + new Uint8Array([0xff, 0xff, 0xff, 0xff]), + ); + }); + }); + + describe("fromBigEndianBytes", () => { + it("can be constructed from to byte array", () => { + expect(Uint32.fromBigEndianBytes([0, 0, 0, 0]).toNumber()).toEqual(0); + expect(Uint32.fromBigEndianBytes([0, 0, 0, 1]).toNumber()).toEqual(1); + expect(Uint32.fromBigEndianBytes([0, 0, 0, 42]).toNumber()).toEqual(42); + expect(Uint32.fromBigEndianBytes([0x3b, 0x9a, 0xca, 0x00]).toNumber()).toEqual(1000000000); + expect(Uint32.fromBigEndianBytes([0x7f, 0xff, 0xff, 0xff]).toNumber()).toEqual(2147483647); + expect(Uint32.fromBigEndianBytes([0x80, 0x00, 0x00, 0x00]).toNumber()).toEqual(2147483648); + expect(Uint32.fromBigEndianBytes([0xff, 0xff, 0xff, 0xff]).toNumber()).toEqual(4294967295); + }); + + it("can be constructed from Buffer", () => { + expect(Uint32.fromBigEndianBytes(Buffer.from([0, 0, 0, 0])).toNumber()).toEqual(0); + expect(Uint32.fromBigEndianBytes(Buffer.from([0, 0, 0, 1])).toNumber()).toEqual(1); + expect(Uint32.fromBigEndianBytes(Buffer.from([0, 0, 0, 42])).toNumber()).toEqual(42); + expect(Uint32.fromBigEndianBytes(Buffer.from([0x3b, 0x9a, 0xca, 0x00])).toNumber()).toEqual( + 1000000000, + ); + expect(Uint32.fromBigEndianBytes(Buffer.from([0x7f, 0xff, 0xff, 0xff])).toNumber()).toEqual( + 2147483647, + ); + expect(Uint32.fromBigEndianBytes(Buffer.from([0x80, 0x00, 0x00, 0x00])).toNumber()).toEqual( + 2147483648, + ); + expect(Uint32.fromBigEndianBytes(Buffer.from([0xff, 0xff, 0xff, 0xff])).toNumber()).toEqual( + 4294967295, + ); + }); + + it("throws for invalid input length", () => { + expect(() => Uint32.fromBigEndianBytes([])).toThrowError(/Invalid input length/); + expect(() => Uint32.fromBigEndianBytes([0, 0, 0])).toThrowError(/Invalid input length/); + expect(() => Uint32.fromBigEndianBytes([0, 0, 0, 0, 0])).toThrowError(/Invalid input length/); + }); + + it("throws for invalid values", () => { + expect(() => Uint32.fromBigEndianBytes([0, 0, 0, -1])).toThrowError(/Invalid value in byte/); + expect(() => Uint32.fromBigEndianBytes([0, 0, 0, 1.5])).toThrowError(/Invalid value in byte/); + expect(() => Uint32.fromBigEndianBytes([0, 0, 0, 256])).toThrowError(/Invalid value in byte/); + expect(() => Uint32.fromBigEndianBytes([0, 0, 0, NaN])).toThrowError(/Invalid value in byte/); + expect(() => Uint32.fromBigEndianBytes([0, 0, 0, Number.NEGATIVE_INFINITY])).toThrowError( + /Invalid value in byte/, + ); + expect(() => Uint32.fromBigEndianBytes([0, 0, 0, Number.POSITIVE_INFINITY])).toThrowError( + /Invalid value in byte/, + ); + }); + }); + }); + + describe("Int53", () => { + it("can be constructed", () => { + expect(new Int53(0)).toBeTruthy(); + expect(new Int53(1)).toBeTruthy(); + expect(new Int53(1.0)).toBeTruthy(); + expect(new Int53(42)).toBeTruthy(); + expect(new Int53(1000000000)).toBeTruthy(); + expect(new Int53(2147483647)).toBeTruthy(); + expect(new Int53(2147483648)).toBeTruthy(); + expect(new Int53(4294967295)).toBeTruthy(); + expect(new Int53(9007199254740991)).toBeTruthy(); + + expect(new Int53(-1)).toBeTruthy(); + expect(new Int53(-42)).toBeTruthy(); + expect(new Int53(-2147483648)).toBeTruthy(); + expect(new Int53(-2147483649)).toBeTruthy(); + expect(new Int53(-9007199254740991)).toBeTruthy(); + }); + + it("throws for invald numbers", () => { + expect(() => new Int53(NaN)).toThrowError(/not a number/); + + expect(() => new Int53(1.1)).toThrowError(/not an integer/i); + expect(() => new Int53(Number.NEGATIVE_INFINITY)).toThrowError(/not an integer/i); + expect(() => new Int53(Number.POSITIVE_INFINITY)).toThrowError(/not an integer/i); + }); + + it("throws for values out of range", () => { + expect(() => new Int53(Number.MIN_SAFE_INTEGER - 1)).toThrowError(/not in int53 range/); + expect(() => new Int53(Number.MAX_SAFE_INTEGER + 1)).toThrowError(/not in int53 range/); + }); + + it("can convert to number", () => { + expect(new Int53(0).toNumber()).toEqual(0); + expect(new Int53(1).toNumber()).toEqual(1); + expect(new Int53(42).toNumber()).toEqual(42); + expect(new Int53(1000000000).toNumber()).toEqual(1000000000); + expect(new Int53(2147483647).toNumber()).toEqual(2147483647); + expect(new Int53(2147483648).toNumber()).toEqual(2147483648); + expect(new Int53(4294967295).toNumber()).toEqual(4294967295); + expect(new Int53(9007199254740991).toNumber()).toEqual(9007199254740991); + + expect(new Int53(-1).toNumber()).toEqual(-1); + expect(new Int53(-9007199254740991).toNumber()).toEqual(-9007199254740991); + }); + + it("can convert to string", () => { + expect(new Int53(0).toString()).toEqual("0"); + expect(new Int53(1).toString()).toEqual("1"); + expect(new Int53(42).toString()).toEqual("42"); + expect(new Int53(1000000000).toString()).toEqual("1000000000"); + expect(new Int53(2147483647).toString()).toEqual("2147483647"); + expect(new Int53(2147483648).toString()).toEqual("2147483648"); + expect(new Int53(4294967295).toString()).toEqual("4294967295"); + expect(new Int53(9007199254740991).toString()).toEqual("9007199254740991"); + + expect(new Int53(-1).toString()).toEqual("-1"); + expect(new Int53(-9007199254740991).toString()).toEqual("-9007199254740991"); + }); + + it("can be constructed from string", () => { + expect(Int53.fromString("0").toString()).toEqual("0"); + expect(Int53.fromString("1").toString()).toEqual("1"); + expect(Int53.fromString("9007199254740991").toString()).toEqual("9007199254740991"); + + expect(Int53.fromString("-1").toString()).toEqual("-1"); + expect(Int53.fromString("-9007199254740991").toString()).toEqual("-9007199254740991"); + }); + + it("throws for invalid string format", () => { + expect(() => Int53.fromString(" 0")).toThrowError(/invalid string format/i); + expect(() => Int53.fromString("+0")).toThrowError(/invalid string format/i); + expect(() => Int53.fromString("1e6")).toThrowError(/invalid string format/i); + + expect(() => Int53.fromString("9007199254740992")).toThrowError(/input not in int53 range/i); + expect(() => Int53.fromString("-9007199254740992")).toThrowError(/input not in int53 range/i); + }); + }); + + describe("Uint53", () => { + it("can be constructed", () => { + expect(new Uint53(0)).toBeTruthy(); + expect(new Uint53(1)).toBeTruthy(); + expect(new Uint53(1.0)).toBeTruthy(); + expect(new Uint53(42)).toBeTruthy(); + expect(new Uint53(1000000000)).toBeTruthy(); + expect(new Uint53(2147483647)).toBeTruthy(); + expect(new Uint53(2147483648)).toBeTruthy(); + expect(new Uint53(4294967295)).toBeTruthy(); + expect(new Uint53(9007199254740991)).toBeTruthy(); + }); + + it("throws for invald numbers", () => { + expect(() => new Uint53(NaN)).toThrowError(/not a number/); + + expect(() => new Uint53(1.1)).toThrowError(/not an integer/i); + expect(() => new Uint53(Number.NEGATIVE_INFINITY)).toThrowError(/not an integer/i); + expect(() => new Uint53(Number.POSITIVE_INFINITY)).toThrowError(/not an integer/i); + }); + + it("throws for values out of range", () => { + expect(() => new Uint53(Number.MIN_SAFE_INTEGER - 1)).toThrowError(/not in int53 range/); + expect(() => new Uint53(Number.MAX_SAFE_INTEGER + 1)).toThrowError(/not in int53 range/); + }); + + it("throws for negative inputs", () => { + expect(() => new Uint53(-1)).toThrowError(/is negative/); + expect(() => new Uint53(-42)).toThrowError(/is negative/); + expect(() => new Uint53(-2147483648)).toThrowError(/is negative/); + expect(() => new Uint53(-2147483649)).toThrowError(/is negative/); + expect(() => new Uint53(-9007199254740991)).toThrowError(/is negative/); + }); + + it("can convert to number", () => { + expect(new Uint53(0).toNumber()).toEqual(0); + expect(new Uint53(1).toNumber()).toEqual(1); + expect(new Uint53(42).toNumber()).toEqual(42); + expect(new Uint53(1000000000).toNumber()).toEqual(1000000000); + expect(new Uint53(2147483647).toNumber()).toEqual(2147483647); + expect(new Uint53(2147483648).toNumber()).toEqual(2147483648); + expect(new Uint53(4294967295).toNumber()).toEqual(4294967295); + expect(new Uint53(9007199254740991).toNumber()).toEqual(9007199254740991); + }); + + it("can convert to string", () => { + expect(new Uint53(0).toString()).toEqual("0"); + expect(new Uint53(1).toString()).toEqual("1"); + expect(new Uint53(42).toString()).toEqual("42"); + expect(new Uint53(1000000000).toString()).toEqual("1000000000"); + expect(new Uint53(2147483647).toString()).toEqual("2147483647"); + expect(new Uint53(2147483648).toString()).toEqual("2147483648"); + expect(new Uint53(4294967295).toString()).toEqual("4294967295"); + expect(new Uint53(9007199254740991).toString()).toEqual("9007199254740991"); + }); + + it("can be constructed from string", () => { + expect(Uint53.fromString("0").toString()).toEqual("0"); + expect(Uint53.fromString("1").toString()).toEqual("1"); + expect(Uint53.fromString("9007199254740991").toString()).toEqual("9007199254740991"); + }); + + it("throws for invalid string format", () => { + expect(() => Uint53.fromString(" 0")).toThrowError(/invalid string format/i); + expect(() => Uint53.fromString("+0")).toThrowError(/invalid string format/i); + expect(() => Uint53.fromString("1e6")).toThrowError(/invalid string format/i); + + expect(() => Uint53.fromString("-9007199254740992")).toThrowError(/input not in int53 range/i); + expect(() => Uint53.fromString("9007199254740992")).toThrowError(/input not in int53 range/i); + + expect(() => Uint53.fromString("-1")).toThrowError(/input is negative/i); + }); + }); + + describe("Uint64", () => { + describe("fromBigEndianBytes", () => { + it("can be constructed from bytes", () => { + Uint64.fromBytesBigEndian([0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]); + Uint64.fromBytesBigEndian([0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01]); + Uint64.fromBytesBigEndian([0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff]); + }); + + it("can be constructed from Uint8Array", () => { + Uint64.fromBytesBigEndian(new Uint8Array([0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00])); + }); + + it("throws for wrong number of bytes", () => { + expect(() => Uint64.fromBytesBigEndian([])).toThrowError(/invalid input length/i); + expect(() => Uint64.fromBytesBigEndian([0x00])).toThrowError(/invalid input length/i); + expect(() => Uint64.fromBytesBigEndian([0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00])).toThrowError( + /invalid input length/i, + ); + expect(() => + Uint64.fromBytesBigEndian([0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]), + ).toThrowError(/invalid input length/i); + }); + + it("throws for wrong byte value", () => { + expect(() => Uint64.fromBytesBigEndian([0, 0, 0, 0, 0, 0, 0, 256])).toThrowError( + /invalid value in byte/i, + ); + expect(() => Uint64.fromBytesBigEndian([0, 0, 0, 0, 0, 0, 0, -1])).toThrowError( + /invalid value in byte/i, + ); + expect(() => Uint64.fromBytesBigEndian([0, 0, 0, 0, 0, 0, 0, 1.5])).toThrowError( + /invalid value in byte/i, + ); + expect(() => Uint64.fromBytesBigEndian([0, 0, 0, 0, 0, 0, 0, Number.NEGATIVE_INFINITY])).toThrowError( + /invalid value in byte/i, + ); + expect(() => Uint64.fromBytesBigEndian([0, 0, 0, 0, 0, 0, 0, Number.POSITIVE_INFINITY])).toThrowError( + /invalid value in byte/i, + ); + expect(() => Uint64.fromBytesBigEndian([0, 0, 0, 0, 0, 0, 0, Number.NaN])).toThrowError( + /invalid value in byte/i, + ); + }); + }); + + describe("fromString", () => { + it("can be constructed from string", () => { + { + const a = Uint64.fromString("0"); + expect(a).toBeTruthy(); + } + { + const a = Uint64.fromString("1"); + expect(a).toBeTruthy(); + } + { + const a = Uint64.fromString("01"); + expect(a).toBeTruthy(); + } + { + const a = Uint64.fromString("9999999999999999999"); + expect(a).toBeTruthy(); + } + { + const a = Uint64.fromString("18446744073709551615"); + expect(a).toBeTruthy(); + } + }); + + it("throws for invalid string values", () => { + expect(() => Uint64.fromString(" 1")).toThrowError(/invalid string format/i); + expect(() => Uint64.fromString("-1")).toThrowError(/invalid string format/i); + expect(() => Uint64.fromString("+1")).toThrowError(/invalid string format/i); + expect(() => Uint64.fromString("1e6")).toThrowError(/invalid string format/i); + }); + + it("throws for string values exceeding uint64", () => { + expect(() => Uint64.fromString("18446744073709551616")).toThrowError(/input exceeds uint64 range/i); + expect(() => Uint64.fromString("99999999999999999999")).toThrowError(/input exceeds uint64 range/i); + }); + }); + + describe("fromNumber", () => { + it("can be constructed from number", () => { + const a = Uint64.fromNumber(0); + expect(a.toNumber()).toEqual(0); + const b = Uint64.fromNumber(1); + expect(b.toNumber()).toEqual(1); + const c = Uint64.fromNumber(Number.MAX_SAFE_INTEGER); + expect(c.toNumber()).toEqual(Number.MAX_SAFE_INTEGER); + }); + + it("throws when constructed from wrong numbers", () => { + // not a number + expect(() => Uint64.fromNumber(Number.NaN)).toThrowError(/input is not a number/i); + + // not an integer + expect(() => Uint64.fromNumber(Number.NEGATIVE_INFINITY)).toThrowError( + /input is not a safe integer/i, + ); + expect(() => Uint64.fromNumber(Number.POSITIVE_INFINITY)).toThrowError( + /input is not a safe integer/i, + ); + expect(() => Uint64.fromNumber(Number.MAX_SAFE_INTEGER + 1)).toThrowError( + /input is not a safe integer/i, + ); + + // negative integer + expect(() => Uint64.fromNumber(-1)).toThrowError(/input is negative/i); + expect(() => Uint64.fromNumber(Number.MIN_SAFE_INTEGER)).toThrowError(/input is negative/i); + }); + }); + + it("can export bytes (big endian)", () => { + expect( + Uint64.fromBytesBigEndian([0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]).toBytesBigEndian(), + ).toEqual(new Uint8Array([0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00])); + expect( + Uint64.fromBytesBigEndian([0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01]).toBytesBigEndian(), + ).toEqual(new Uint8Array([0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01])); + expect( + Uint64.fromBytesBigEndian([0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]).toBytesBigEndian(), + ).toEqual(new Uint8Array([0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00])); + expect( + Uint64.fromBytesBigEndian([0xab, 0x22, 0xbc, 0x5f, 0xa9, 0x20, 0x4e, 0x0d]).toBytesBigEndian(), + ).toEqual(new Uint8Array([0xab, 0x22, 0xbc, 0x5f, 0xa9, 0x20, 0x4e, 0x0d])); + expect( + Uint64.fromBytesBigEndian([0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff]).toBytesBigEndian(), + ).toEqual(new Uint8Array([0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff])); + }); + + it("can export bytes (little endian)", () => { + expect( + Uint64.fromBytesBigEndian([0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]).toBytesLittleEndian(), + ).toEqual(new Uint8Array([0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00])); + expect( + Uint64.fromBytesBigEndian([0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01]).toBytesLittleEndian(), + ).toEqual(new Uint8Array([0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00])); + expect( + Uint64.fromBytesBigEndian([0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]).toBytesLittleEndian(), + ).toEqual(new Uint8Array([0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01])); + expect( + Uint64.fromBytesBigEndian([0xab, 0x22, 0xbc, 0x5f, 0xa9, 0x20, 0x4e, 0x0d]).toBytesLittleEndian(), + ).toEqual(new Uint8Array([0x0d, 0x4e, 0x20, 0xa9, 0x5f, 0xbc, 0x22, 0xab])); + expect( + Uint64.fromBytesBigEndian([0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff]).toBytesLittleEndian(), + ).toEqual(new Uint8Array([0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff])); + }); + + it("can export strings", () => { + { + const a = Uint64.fromBytesBigEndian([0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]); + expect(a.toString()).toEqual("0"); + } + { + const a = Uint64.fromBytesBigEndian([0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01]); + expect(a.toString()).toEqual("1"); + } + { + const a = Uint64.fromBytesBigEndian([0x8a, 0xc7, 0x23, 0x04, 0x89, 0xe7, 0xff, 0xff]); + expect(a.toString()).toEqual("9999999999999999999"); + } + { + const a = Uint64.fromBytesBigEndian([0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff]); + expect(a.toString()).toEqual("18446744073709551615"); + } + }); + + it("can export numbers", () => { + { + const a = Uint64.fromBytesBigEndian([0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]); + expect(a.toNumber()).toEqual(0); + } + { + const a = Uint64.fromBytesBigEndian([0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01]); + expect(a.toNumber()).toEqual(1); + } + { + // value too large for 53 bit integer + const a = Uint64.fromBytesBigEndian([0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff]); + expect(() => a.toNumber()).toThrowError(/number can only safely store up to 53 bits/i); + } + { + // Number.MAX_SAFE_INTEGER + 1 + const a = Uint64.fromBytesBigEndian([0x00, 0x20, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]); + expect(() => a.toNumber()).toThrowError(/number can only safely store up to 53 bits/i); + } + }); + }); +}); diff --git a/packages/math/src/integers.ts b/packages/math/src/integers.ts new file mode 100644 index 0000000000..b63813d043 --- /dev/null +++ b/packages/math/src/integers.ts @@ -0,0 +1,212 @@ +/* eslint-disable no-bitwise */ +import BN from "bn.js"; + +const uint64MaxValue = new BN("18446744073709551615", 10, "be"); + +/** Internal interface to ensure all integer types can be used equally */ +interface Integer { + readonly toNumber: () => number; + readonly toString: () => string; +} + +interface WithByteConverters { + readonly toBytesBigEndian: () => Uint8Array; + readonly toBytesLittleEndian: () => Uint8Array; +} + +export class Uint32 implements Integer, WithByteConverters { + public static fromBigEndianBytes(bytes: ArrayLike): Uint32 { + if (bytes.length !== 4) { + throw new Error("Invalid input length. Expected 4 bytes."); + } + + for (let i = 0; i < bytes.length; ++i) { + if (!Number.isInteger(bytes[i]) || bytes[i] > 255 || bytes[i] < 0) { + throw new Error("Invalid value in byte. Found: " + bytes[i]); + } + } + + // Use mulitiplication instead of shifting since bitwise operators are defined + // on SIGNED int32 in JavaScript and we don't want to risk surprises + return new Uint32(bytes[0] * 2 ** 24 + bytes[1] * 2 ** 16 + bytes[2] * 2 ** 8 + bytes[3]); + } + + protected readonly data: number; + + public constructor(input: number) { + if (Number.isNaN(input)) { + throw new Error("Input is not a number"); + } + + if (!Number.isInteger(input)) { + throw new Error("Input is not an integer"); + } + + if (input < 0 || input > 4294967295) { + throw new Error("Input not in uint32 range: " + input.toString()); + } + + this.data = input; + } + + public toBytesBigEndian(): Uint8Array { + // Use division instead of shifting since bitwise operators are defined + // on SIGNED int32 in JavaScript and we don't want to risk surprises + return new Uint8Array([ + Math.floor(this.data / 2 ** 24) & 0xff, + Math.floor(this.data / 2 ** 16) & 0xff, + Math.floor(this.data / 2 ** 8) & 0xff, + Math.floor(this.data / 2 ** 0) & 0xff, + ]); + } + + public toBytesLittleEndian(): Uint8Array { + // Use division instead of shifting since bitwise operators are defined + // on SIGNED int32 in JavaScript and we don't want to risk surprises + return new Uint8Array([ + Math.floor(this.data / 2 ** 0) & 0xff, + Math.floor(this.data / 2 ** 8) & 0xff, + Math.floor(this.data / 2 ** 16) & 0xff, + Math.floor(this.data / 2 ** 24) & 0xff, + ]); + } + + public toNumber(): number { + return this.data; + } + + public toString(): string { + return this.data.toString(); + } +} + +export class Int53 implements Integer { + public static fromString(str: string): Int53 { + if (!str.match(/^-?[0-9]+$/)) { + throw new Error("Invalid string format"); + } + + return new Int53(Number.parseInt(str, 10)); + } + + protected readonly data: number; + + public constructor(input: number) { + if (Number.isNaN(input)) { + throw new Error("Input is not a number"); + } + + if (!Number.isInteger(input)) { + throw new Error("Input is not an integer"); + } + + if (input < Number.MIN_SAFE_INTEGER || input > Number.MAX_SAFE_INTEGER) { + throw new Error("Input not in int53 range: " + input.toString()); + } + + this.data = input; + } + + public toNumber(): number { + return this.data; + } + + public toString(): string { + return this.data.toString(); + } +} + +export class Uint53 implements Integer { + public static fromString(str: string): Uint53 { + const signed = Int53.fromString(str); + return new Uint53(signed.toNumber()); + } + + protected readonly data: Int53; + + public constructor(input: number) { + const signed = new Int53(input); + if (signed.toNumber() < 0) { + throw new Error("Input is negative"); + } + this.data = signed; + } + + public toNumber(): number { + return this.data.toNumber(); + } + + public toString(): string { + return this.data.toString(); + } +} + +export class Uint64 implements Integer, WithByteConverters { + public static fromBytesBigEndian(bytes: ArrayLike): Uint64 { + if (bytes.length !== 8) { + throw new Error("Invalid input length. Expected 8 bytes."); + } + + for (let i = 0; i < bytes.length; ++i) { + if (!Number.isInteger(bytes[i]) || bytes[i] > 255 || bytes[i] < 0) { + throw new Error("Invalid value in byte. Found: " + bytes[i]); + } + } + + const asArray: number[] = []; + for (let i = 0; i < bytes.length; ++i) { + asArray.push(bytes[i]); + } + + return new Uint64(new BN([...asArray])); + } + + public static fromString(str: string): Uint64 { + if (!str.match(/^[0-9]+$/)) { + throw new Error("Invalid string format"); + } + return new Uint64(new BN(str, 10, "be")); + } + + public static fromNumber(input: number): Uint64 { + if (Number.isNaN(input)) { + throw new Error("Input is not a number"); + } + + let bigint: BN; + try { + bigint = new BN(input); + } catch { + throw new Error("Input is not a safe integer"); + } + return new Uint64(bigint); + } + + private readonly data: BN; + + private constructor(data: BN) { + if (data.isNeg()) { + throw new Error("Input is negative"); + } + if (data.gt(uint64MaxValue)) { + throw new Error("Input exceeds uint64 range"); + } + this.data = data; + } + + public toBytesBigEndian(): Uint8Array { + return Uint8Array.from(this.data.toArray("be", 8)); + } + + public toBytesLittleEndian(): Uint8Array { + return Uint8Array.from(this.data.toArray("le", 8)); + } + + public toString(): string { + return this.data.toString(10); + } + + public toNumber(): number { + return this.data.toNumber(); + } +} diff --git a/packages/math/tsconfig.json b/packages/math/tsconfig.json new file mode 100644 index 0000000000..167e8c0226 --- /dev/null +++ b/packages/math/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "baseUrl": ".", + "outDir": "build", + "declarationDir": "build/types", + "rootDir": "src" + }, + "include": [ + "src/**/*" + ] +} diff --git a/packages/math/typedoc.js b/packages/math/typedoc.js new file mode 100644 index 0000000000..e2387c7de4 --- /dev/null +++ b/packages/math/typedoc.js @@ -0,0 +1,14 @@ +const packageJson = require("./package.json"); + +module.exports = { + src: ["./src"], + out: "docs", + exclude: "**/*.spec.ts", + target: "es6", + name: `${packageJson.name} Documentation`, + readme: "README.md", + mode: "file", + excludeExternals: true, + excludeNotExported: true, + excludePrivate: true, +}; diff --git a/packages/math/types/decimal.d.ts b/packages/math/types/decimal.d.ts new file mode 100644 index 0000000000..9d65be9d1d --- /dev/null +++ b/packages/math/types/decimal.d.ts @@ -0,0 +1,26 @@ +/** + * A type for arbitrary precision, non-negative decimals. + * + * Instances of this class are immutable. + */ +export declare class Decimal { + static fromUserInput(input: string, fractionalDigits: number): Decimal; + static fromAtomics(atomics: string, fractionalDigits: number): Decimal; + private static verifyFractionalDigits; + get atomics(): string; + get fractionalDigits(): number; + private readonly data; + private constructor(); + toString(): string; + /** + * Returns an approximation as a float type. Only use this if no + * exact calculation is required. + */ + toFloatApproximation(): number; + /** + * a.plus(b) returns a+b. + * + * Both values need to have the same fractional digits. + */ + plus(b: Decimal): Decimal; +} diff --git a/packages/math/types/index.d.ts b/packages/math/types/index.d.ts new file mode 100644 index 0000000000..b888136fd9 --- /dev/null +++ b/packages/math/types/index.d.ts @@ -0,0 +1,2 @@ +export { Decimal } from "./decimal"; +export { Int53, Uint32, Uint53, Uint64 } from "./integers"; diff --git a/packages/math/types/integers.d.ts b/packages/math/types/integers.d.ts new file mode 100644 index 0000000000..39324e9638 --- /dev/null +++ b/packages/math/types/integers.d.ts @@ -0,0 +1,44 @@ +/** Internal interface to ensure all integer types can be used equally */ +interface Integer { + readonly toNumber: () => number; + readonly toString: () => string; +} +interface WithByteConverters { + readonly toBytesBigEndian: () => Uint8Array; + readonly toBytesLittleEndian: () => Uint8Array; +} +export declare class Uint32 implements Integer, WithByteConverters { + static fromBigEndianBytes(bytes: ArrayLike): Uint32; + protected readonly data: number; + constructor(input: number); + toBytesBigEndian(): Uint8Array; + toBytesLittleEndian(): Uint8Array; + toNumber(): number; + toString(): string; +} +export declare class Int53 implements Integer { + static fromString(str: string): Int53; + protected readonly data: number; + constructor(input: number); + toNumber(): number; + toString(): string; +} +export declare class Uint53 implements Integer { + static fromString(str: string): Uint53; + protected readonly data: Int53; + constructor(input: number); + toNumber(): number; + toString(): string; +} +export declare class Uint64 implements Integer, WithByteConverters { + static fromBytesBigEndian(bytes: ArrayLike): Uint64; + static fromString(str: string): Uint64; + static fromNumber(input: number): Uint64; + private readonly data; + private constructor(); + toBytesBigEndian(): Uint8Array; + toBytesLittleEndian(): Uint8Array; + toString(): string; + toNumber(): number; +} +export {}; diff --git a/packages/math/webpack.web.config.js b/packages/math/webpack.web.config.js new file mode 100644 index 0000000000..9d5836a8fb --- /dev/null +++ b/packages/math/webpack.web.config.js @@ -0,0 +1,17 @@ +const glob = require("glob"); +const path = require("path"); + +const target = "web"; +const distdir = path.join(__dirname, "dist", "web"); + +module.exports = [ + { + // bundle used for Karma tests + target: target, + entry: glob.sync("./build/**/*.spec.js"), + output: { + path: distdir, + filename: "tests.js", + }, + }, +]; diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 3e156674f3..ea0963014e 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -1,2 +1,3 @@ export { assert } from "./assert"; export { sleep } from "./sleep"; +export { isNonNullObject, isUint8Array } from "./typechecks"; diff --git a/packages/utils/src/typechecks.spec.ts b/packages/utils/src/typechecks.spec.ts new file mode 100644 index 0000000000..5abbac112b --- /dev/null +++ b/packages/utils/src/typechecks.spec.ts @@ -0,0 +1,58 @@ +import { isNonNullObject, isUint8Array } from "./typechecks"; + +describe("typechecks", () => { + describe("isNonNullObject", () => { + it("returns true for objects", () => { + expect(isNonNullObject({})).toEqual(true); + expect(isNonNullObject({ foo: 123 })).toEqual(true); + expect(isNonNullObject(new Uint8Array([]))).toEqual(true); + }); + + it("returns true for arrays", () => { + // > object is a type that represents the non-primitive type, i.e. + // > anything that is not number, string, boolean, symbol, null, or undefined. + // https://www.typescriptlang.org/docs/handbook/basic-types.html#object + expect(isNonNullObject([])).toEqual(true); + }); + + it("returns false for null", () => { + expect(isNonNullObject(null)).toEqual(false); + }); + + it("returns false for other kind of data", () => { + expect(isNonNullObject(undefined)).toEqual(false); + expect(isNonNullObject("abc")).toEqual(false); + expect(isNonNullObject(123)).toEqual(false); + expect(isNonNullObject(true)).toEqual(false); + }); + }); + + describe("isUint8Array", () => { + it("returns true for Uint8Arrays", () => { + expect(isUint8Array(new Uint8Array())).toEqual(true); + expect(isUint8Array(new Uint8Array([1, 2, 3]))).toEqual(true); + }); + + it("returns false for Buffer", () => { + // One could start a big debate about whether or not a Buffer is a Uint8Array, which + // required a definition of "is a" in a languages that has no proper object oriented + // programming support. + // + // In all our software we use Uint8Array for storing binary data and copy Buffers into + // new Uint8Array to make deep equality checks work and to ensure our code works the same + // way in browsers and Node.js. So our expectation is: _a Buffer is not an Uint8Array_. + expect(isUint8Array(Buffer.from(""))).toEqual(false); + }); + + it("returns false for other kind of data", () => { + expect(isUint8Array(undefined)).toEqual(false); + expect(isUint8Array("abc")).toEqual(false); + expect(isUint8Array(123)).toEqual(false); + expect(isUint8Array(true)).toEqual(false); + + expect(isUint8Array([])).toEqual(false); + expect(isUint8Array(new Int8Array())).toEqual(false); + expect(isUint8Array(new Uint16Array())).toEqual(false); + }); + }); +}); diff --git a/packages/utils/src/typechecks.ts b/packages/utils/src/typechecks.ts new file mode 100644 index 0000000000..664fcc5353 --- /dev/null +++ b/packages/utils/src/typechecks.ts @@ -0,0 +1,26 @@ +/** + * Checks if data is a non-null object (i.e. matches the TypeScript object type) + */ +export function isNonNullObject(data: unknown): data is object { + return typeof data === "object" && data !== null; +} + +/** + * Checks if data is an Uint8Array. Note: Buffer is treated as not a Uint8Array + */ +export function isUint8Array(data: unknown): data is Uint8Array { + if (!isNonNullObject(data)) return false; + + // Avoid instanceof check which is unreliable in some JS environments + // https://medium.com/@simonwarta/limitations-of-the-instanceof-operator-f4bcdbe7a400 + + // Use check that was discussed in https://github.com/crypto-browserify/pbkdf2/pull/81 + if (Object.prototype.toString.call(data) !== "[object Uint8Array]") return false; + + if (typeof Buffer !== "undefined" && typeof Buffer.isBuffer !== "undefined") { + // Buffer.isBuffer is available at runtime + if (Buffer.isBuffer(data)) return false; + } + + return true; +} diff --git a/yarn.lock b/yarn.lock index 3c9a67f493..ded15fabf4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -979,6 +979,11 @@ dependencies: "@types/babel-types" "*" +"@types/base64-js@^1.2.5": + version "1.3.0" + resolved "https://registry.yarnpkg.com/@types/base64-js/-/base64-js-1.3.0.tgz#c939fdba49846861caf5a246b165dbf5698a317c" + integrity sha512-ZmI0sZGAUNXUfMWboWwi4LcfpoVUYldyN6Oe0oJ5cCsHDU/LlRq8nQKPXhYLOx36QYSW9bNIb1vvRrD6K7Llgw== + "@types/bn.js@*", "@types/bn.js@^4.11.6": version "4.11.6" resolved "https://registry.yarnpkg.com/@types/bn.js/-/bn.js-4.11.6.tgz#c306c70d9358aaea33cd4eda092a742b9505967c"