From 38905dd25f91fdc20991a5c8c43330a14e478a11 Mon Sep 17 00:00:00 2001 From: Sergii Date: Thu, 27 Feb 2025 05:48:04 +0200 Subject: [PATCH 1/2] fix: ensures that DecCoin doesn't loose precision for very long numbers --- ts/src/patch/cosmos/base/v1beta1/coin.spec.ts | 117 +++++++++++++++--- ts/src/patch/cosmos/base/v1beta1/coin.ts | 62 +++++++++- 2 files changed, 156 insertions(+), 23 deletions(-) diff --git a/ts/src/patch/cosmos/base/v1beta1/coin.spec.ts b/ts/src/patch/cosmos/base/v1beta1/coin.spec.ts index 7de851e7..a6fadce0 100644 --- a/ts/src/patch/cosmos/base/v1beta1/coin.spec.ts +++ b/ts/src/patch/cosmos/base/v1beta1/coin.spec.ts @@ -3,29 +3,112 @@ import { Reader } from "protobufjs/minimal"; import * as coin from "./coin"; describe("DecCoin", () => { - describe("prototype.decode", () => { - it("should properly decode whole amount", () => { - const encodedCoin = coin.DecCoin.encode({ + // @see https://github.com/cosmos/cosmos-sdk/blob/main/math/testdata/decimals.json + it.each([ + ["0", "0"], + ["1", "1"], + ["12", "12"], + ["123", "123"], + ["1234", "1'234"], + ["01234", "1234"], + [".1234", "0.1234"], + ["-.1234", "-0.1234"], + ["123.", "123"], + ["-123.", "-123"], + ["0.1", "0.1"], + ["0.01", "0.01"], + ["0.001", "0.001"], + ["0.0001", "0.0001"], + ["0.00001", "0.00001"], + ["0.000001", "0.000001"], + ["0.0000001", "0.0000001"], + ["0.00000001", "0.00000001"], + ["0.000000001", "0.000000001"], + ["0.0000000001", "0.0000000001"], + ["0.00000000001", "0.00000000001"], + ["0.000000000001", "0.000000000001"], + ["0.0000000000001", "0.0000000000001"], + ["0.00000000000001", "0.00000000000001"], + ["0.000000000000001", "0.000000000000001"], + ["0.0000000000000001", "0.0000000000000001"], + ["0.00000000000000001", "0.00000000000000001"], + ["0.000000000000000001", "0.000000000000000001"], + ["0.100000000000000000", "0.1"], + ["0.010000000000000000", "0.01"], + ["0.001000000000000000", "0.001"], + ["0.000100000000000000", "0.0001"], + ["0.000010000000000000", "0.00001"], + ["0.000001000000000000", "0.000001"], + ["0.000000100000000000", "0.0000001"], + ["0.000000010000000000", "0.00000001"], + ["0.000000001000000000", "0.000000001"], + ["0.000000000100000000", "0.0000000001"], + ["0.000000000010000000", "0.00000000001"], + ["0.000000000001000000", "0.000000000001"], + ["0.000000000000100000", "0.0000000000001"], + ["0.000000000000010000", "0.00000000000001"], + ["0.000000000000001000", "0.000000000000001"], + ["0.000000000000000100", "0.0000000000000001"], + ["0.000000000000000010", "0.00000000000000001"], + ["0.000000000000000001", "0.000000000000000001"], + ["-10.0", "-10"], + ["-10000", "-10'000"], + ["-9999", "-9'999"], + ["-999999999999", "-999'999'999'999"], + [Number.MAX_SAFE_INTEGER.toString(), Number.MAX_SAFE_INTEGER.toString()], + ])("should properly decode %s", (amount, expected) => { + const encodedCoin = coin.DecCoin.encode({ + $type: "cosmos.base.v1beta1.DecCoin", + denom: "", + amount, + }).finish(); + const reader = new Reader(encodedCoin); + const result = coin.DecCoin.decode(reader); + + expect(result.amount).toEqual(expected.replace(/'/g, "")); + }); + + it("throws when amount is too big or too small", () => { + expect(() => + coin.DecCoin.encode({ $type: "cosmos.base.v1beta1.DecCoin", denom: "", - amount: "1000", - }).finish(); - const reader = new Reader(encodedCoin); - const result = coin.DecCoin.decode(reader); + amount: `${"9".repeat(100_0000)}`, + }), + ).toThrow(); - expect(result.amount).toEqual("1000.00000000000000"); - }); + expect(() => + coin.DecCoin.encode({ + $type: "cosmos.base.v1beta1.DecCoin", + denom: "", + amount: `-${"9".repeat(100_0000)}`, + }), + ).toThrow(); + }); - it("should properly decode amount with a floating point", () => { - const encodedCoin = coin.DecCoin.encode({ + it("throws when Infinity or NaN or random string is provided", () => { + expect(() => + coin.DecCoin.encode({ $type: "cosmos.base.v1beta1.DecCoin", denom: "", - amount: "1000.5", - }).finish(); - const reader = new Reader(encodedCoin); - const result = coin.DecCoin.decode(reader); + amount: Infinity.toString(), + }), + ).toThrow(); - expect(result.amount).toEqual("1000.50000000000000"); - }); + expect(() => + coin.DecCoin.encode({ + $type: "cosmos.base.v1beta1.DecCoin", + denom: "", + amount: "NaN", + }), + ).toThrow(); + + expect(() => + coin.DecCoin.encode({ + $type: "cosmos.base.v1beta1.DecCoin", + denom: "", + amount: "1foo", + }), + ).toThrow(); }); }); diff --git a/ts/src/patch/cosmos/base/v1beta1/coin.ts b/ts/src/patch/cosmos/base/v1beta1/coin.ts index edf3cca6..f9315660 100644 --- a/ts/src/patch/cosmos/base/v1beta1/coin.ts +++ b/ts/src/patch/cosmos/base/v1beta1/coin.ts @@ -5,28 +5,78 @@ import * as coin from "../../../../generated/cosmos/base/v1beta1/coin.original"; import { DecCoin } from "../../../../generated/cosmos/base/v1beta1/coin.original"; const originalEncode = coin.DecCoin.encode; +const PRECISION = 18; +/** + * @see https://github.com/cosmos/cosmos-sdk/blob/main/math/dec_test.go#L40 + */ +const MAX_SUPPORTED_DECIMAL_LENGTH = 34; coin.DecCoin.encode = function encode( message: DecCoin, writer: minimal.Writer = minimal.Writer.create(), ): minimal.Writer { - const { amount } = message; - const parts = amount.includes(".") - ? message.amount.split(".") - : [message.amount, ""]; - message.amount = `${parts[0]}${parts[1].padEnd(18, "0")}`; + const floatingPointIndex = message.amount.indexOf("."); + let integerPart: string; + let fractionalPart: string; + + if (floatingPointIndex === -1) { + integerPart = message.amount; + fractionalPart = "0"; + } else { + integerPart = message.amount.slice(0, floatingPointIndex) || "0"; + fractionalPart = message.amount.slice(floatingPointIndex + 1); + } + + let amount: string; + try { + amount = BigInt( + integerPart + fractionalPart.padEnd(PRECISION, "0"), + ).toString(); + } catch (error) { + throw new Error(`Cannot encode invalid DecCoin amount: ${message.amount}`); + } + + const maxDigits = + amount[0] === "-" + ? MAX_SUPPORTED_DECIMAL_LENGTH + 1 + : MAX_SUPPORTED_DECIMAL_LENGTH; + if (amount.length > maxDigits) { + throw new Error( + `Cannot encode DecCoin amount over ${MAX_SUPPORTED_DECIMAL_LENGTH} digits`, + ); + } + + message.amount = amount; return originalEncode.apply(this, [message, writer]); }; const originalDecode = coin.DecCoin.decode; +const TRAILING_ZEROES_REGEX = /0+$/; coin.DecCoin.decode = function decode( input: Reader | Uint8Array, length?: number, ): coin.DecCoin { const message = originalDecode.apply(this, [input, length]); - message.amount = (parseInt(message.amount) / 10 ** 18).toPrecision(18); + let integerPart: string; + let fractionalPart: string; + const amount = BigInt(message.amount); + const isNegative = amount < BigInt(0); + const absAmount = isNegative ? -amount : amount; + + if (absAmount.toString().length <= PRECISION) { + integerPart = isNegative ? "-0" : "0"; + fractionalPart = absAmount.toString().padStart(PRECISION, "0"); + } else { + integerPart = message.amount.slice(0, message.amount.length - PRECISION); + fractionalPart = message.amount.slice(-PRECISION); + } + + message.amount = + BigInt(fractionalPart) === BigInt(0) + ? integerPart + : `${integerPart}.${fractionalPart.replace(TRAILING_ZEROES_REGEX, "")}`; return message; }; From 973fe01e86d1820277ff89c3511d3db6d99f86eb Mon Sep 17 00:00:00 2001 From: Sergii Date: Thu, 27 Feb 2025 14:05:32 +0200 Subject: [PATCH 2/2] fix: uses @cosmjs/math to encode/decode DecCoin --- ts/package-lock.json | 16 +++++ ts/package.json | 1 + ts/src/patch/cosmos/base/v1beta1/coin.spec.ts | 52 +--------------- ts/src/patch/cosmos/base/v1beta1/coin.ts | 60 ++----------------- 4 files changed, 24 insertions(+), 105 deletions(-) diff --git a/ts/package-lock.json b/ts/package-lock.json index 8dc8b2e0..4cb479d6 100644 --- a/ts/package-lock.json +++ b/ts/package-lock.json @@ -9,6 +9,7 @@ "version": "0.0.0", "license": "Apache-2.0", "dependencies": { + "@cosmjs/math": "^0.33.0", "rxjs": "^7.8.1" }, "devDependencies": { @@ -662,6 +663,15 @@ "node": ">=0.1.90" } }, + "node_modules/@cosmjs/math": { + "version": "0.33.0", + "resolved": "https://registry.npmjs.org/@cosmjs/math/-/math-0.33.0.tgz", + "integrity": "sha512-B2uOgM12iuIhJWzGuAxGwO6zO+cI8Q4z7mVu7HgFrGJJTM1HtPTYgb55oMOuUN0OZ352MEEm5uAt8sA9jZQqbA==", + "license": "Apache-2.0", + "dependencies": { + "bn.js": "^5.2.0" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz", @@ -2749,6 +2759,12 @@ "integrity": "sha512-Nik3Sc0ncrMK4UUdXQmAnRtzmNQTAAXmXIopizwZ1W1t8QmfJj+zL4OA2I7XPTPW5z5TDqv4hRo/JzouDJnX3A==", "dev": true }, + "node_modules/bn.js": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.1.tgz", + "integrity": "sha512-eXRvHzWyYPBuB4NBy0cmYQjGitUrtqwbvlzP3G6VFnNRbsZQIxQ10PbKKHt8gZ/HW/D/747aDl+QkDqg3KQLMQ==", + "license": "MIT" + }, "node_modules/bottleneck": { "version": "2.19.5", "resolved": "https://registry.npmjs.org/bottleneck/-/bottleneck-2.19.5.tgz", diff --git a/ts/package.json b/ts/package.json index 565f94f3..4f09ffde 100644 --- a/ts/package.json +++ b/ts/package.json @@ -41,6 +41,7 @@ ] }, "dependencies": { + "@cosmjs/math": "^0.33.0", "rxjs": "^7.8.1" }, "devDependencies": { diff --git a/ts/src/patch/cosmos/base/v1beta1/coin.spec.ts b/ts/src/patch/cosmos/base/v1beta1/coin.spec.ts index a6fadce0..f36c7f42 100644 --- a/ts/src/patch/cosmos/base/v1beta1/coin.spec.ts +++ b/ts/src/patch/cosmos/base/v1beta1/coin.spec.ts @@ -4,6 +4,7 @@ import * as coin from "./coin"; describe("DecCoin", () => { // @see https://github.com/cosmos/cosmos-sdk/blob/main/math/testdata/decimals.json + // import('@cosmjs/math').Decimal supports only non-negative decimals it.each([ ["0", "0"], ["1", "1"], @@ -12,9 +13,6 @@ describe("DecCoin", () => { ["1234", "1'234"], ["01234", "1234"], [".1234", "0.1234"], - ["-.1234", "-0.1234"], - ["123.", "123"], - ["-123.", "-123"], ["0.1", "0.1"], ["0.01", "0.01"], ["0.001", "0.001"], @@ -51,10 +49,6 @@ describe("DecCoin", () => { ["0.000000000000000100", "0.0000000000000001"], ["0.000000000000000010", "0.00000000000000001"], ["0.000000000000000001", "0.000000000000000001"], - ["-10.0", "-10"], - ["-10000", "-10'000"], - ["-9999", "-9'999"], - ["-999999999999", "-999'999'999'999"], [Number.MAX_SAFE_INTEGER.toString(), Number.MAX_SAFE_INTEGER.toString()], ])("should properly decode %s", (amount, expected) => { const encodedCoin = coin.DecCoin.encode({ @@ -67,48 +61,4 @@ describe("DecCoin", () => { expect(result.amount).toEqual(expected.replace(/'/g, "")); }); - - it("throws when amount is too big or too small", () => { - expect(() => - coin.DecCoin.encode({ - $type: "cosmos.base.v1beta1.DecCoin", - denom: "", - amount: `${"9".repeat(100_0000)}`, - }), - ).toThrow(); - - expect(() => - coin.DecCoin.encode({ - $type: "cosmos.base.v1beta1.DecCoin", - denom: "", - amount: `-${"9".repeat(100_0000)}`, - }), - ).toThrow(); - }); - - it("throws when Infinity or NaN or random string is provided", () => { - expect(() => - coin.DecCoin.encode({ - $type: "cosmos.base.v1beta1.DecCoin", - denom: "", - amount: Infinity.toString(), - }), - ).toThrow(); - - expect(() => - coin.DecCoin.encode({ - $type: "cosmos.base.v1beta1.DecCoin", - denom: "", - amount: "NaN", - }), - ).toThrow(); - - expect(() => - coin.DecCoin.encode({ - $type: "cosmos.base.v1beta1.DecCoin", - denom: "", - amount: "1foo", - }), - ).toThrow(); - }); }); diff --git a/ts/src/patch/cosmos/base/v1beta1/coin.ts b/ts/src/patch/cosmos/base/v1beta1/coin.ts index f9315660..54e24e25 100644 --- a/ts/src/patch/cosmos/base/v1beta1/coin.ts +++ b/ts/src/patch/cosmos/base/v1beta1/coin.ts @@ -1,3 +1,4 @@ +import { Decimal } from "@cosmjs/math"; import * as minimal from "protobufjs/minimal"; import { Reader } from "protobufjs/minimal"; @@ -5,78 +6,29 @@ import * as coin from "../../../../generated/cosmos/base/v1beta1/coin.original"; import { DecCoin } from "../../../../generated/cosmos/base/v1beta1/coin.original"; const originalEncode = coin.DecCoin.encode; -const PRECISION = 18; /** - * @see https://github.com/cosmos/cosmos-sdk/blob/main/math/dec_test.go#L40 + * Taken from cosmos-sdk + * @see https://github.com/cosmos/cosmos-sdk/blob/25b14c3caa2ecdc99840dbb88fdb3a2d8ac02158/math/dec.go#L21 */ -const MAX_SUPPORTED_DECIMAL_LENGTH = 34; +const PRECISION = 18; coin.DecCoin.encode = function encode( message: DecCoin, writer: minimal.Writer = minimal.Writer.create(), ): minimal.Writer { - const floatingPointIndex = message.amount.indexOf("."); - let integerPart: string; - let fractionalPart: string; - - if (floatingPointIndex === -1) { - integerPart = message.amount; - fractionalPart = "0"; - } else { - integerPart = message.amount.slice(0, floatingPointIndex) || "0"; - fractionalPart = message.amount.slice(floatingPointIndex + 1); - } - - let amount: string; - try { - amount = BigInt( - integerPart + fractionalPart.padEnd(PRECISION, "0"), - ).toString(); - } catch (error) { - throw new Error(`Cannot encode invalid DecCoin amount: ${message.amount}`); - } - - const maxDigits = - amount[0] === "-" - ? MAX_SUPPORTED_DECIMAL_LENGTH + 1 - : MAX_SUPPORTED_DECIMAL_LENGTH; - if (amount.length > maxDigits) { - throw new Error( - `Cannot encode DecCoin amount over ${MAX_SUPPORTED_DECIMAL_LENGTH} digits`, - ); - } - - message.amount = amount; + message.amount = Decimal.fromUserInput(message.amount, PRECISION).atomics; return originalEncode.apply(this, [message, writer]); }; const originalDecode = coin.DecCoin.decode; -const TRAILING_ZEROES_REGEX = /0+$/; coin.DecCoin.decode = function decode( input: Reader | Uint8Array, length?: number, ): coin.DecCoin { const message = originalDecode.apply(this, [input, length]); - let integerPart: string; - let fractionalPart: string; - const amount = BigInt(message.amount); - const isNegative = amount < BigInt(0); - const absAmount = isNegative ? -amount : amount; - - if (absAmount.toString().length <= PRECISION) { - integerPart = isNegative ? "-0" : "0"; - fractionalPart = absAmount.toString().padStart(PRECISION, "0"); - } else { - integerPart = message.amount.slice(0, message.amount.length - PRECISION); - fractionalPart = message.amount.slice(-PRECISION); - } - - message.amount = - BigInt(fractionalPart) === BigInt(0) - ? integerPart - : `${integerPart}.${fractionalPart.replace(TRAILING_ZEROES_REGEX, "")}`; + message.amount = Decimal.fromAtomics(message.amount, PRECISION).toString(); return message; };