Skip to content

Commit

Permalink
fix: ensures that DecCoin doesn't loose precision for very long numbers
Browse files Browse the repository at this point in the history
  • Loading branch information
stalniy committed Feb 27, 2025
1 parent a676177 commit c87b9f1
Show file tree
Hide file tree
Showing 2 changed files with 143 additions and 30 deletions.
121 changes: 97 additions & 24 deletions ts/src/patch/cosmos/base/v1beta1/coin.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,29 +3,102 @@ 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({
$type: "cosmos.base.v1beta1.DecCoin",
denom: "",
amount: "1000",
}).finish();
const reader = new Reader(encodedCoin);
const result = coin.DecCoin.decode(reader);

expect(result.amount).toEqual("1000.00000000000000");
});

it("should properly decode amount with a floating point", () => {
const encodedCoin = coin.DecCoin.encode({
$type: "cosmos.base.v1beta1.DecCoin",
denom: "",
amount: "1000.5",
}).finish();
const reader = new Reader(encodedCoin);
const result = coin.DecCoin.decode(reader);

expect(result.amount).toEqual("1000.50000000000000");
});
// @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: `${'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();
});
});
52 changes: 46 additions & 6 deletions ts/src/patch/cosmos/base/v1beta1/coin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,28 +5,68 @@ 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;
};
Expand Down

0 comments on commit c87b9f1

Please sign in to comment.