diff --git a/package-lock.json b/package-lock.json index 1a6cf71..f2fe300 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5705,6 +5705,29 @@ } } }, + "node_modules/vite/node_modules/@emnapi/core": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz", + "integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/vite/node_modules/@emnapi/runtime": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz", + "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/vite/node_modules/@oxc-project/types": { "version": "0.124.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.124.0.tgz", diff --git a/src/core/coin.ts b/src/core/coin.ts index 065c9f7..bed85da 100644 --- a/src/core/coin.ts +++ b/src/core/coin.ts @@ -14,6 +14,10 @@ import { } from '@buf/cosmos_cosmos-sdk.bufbuild_es/cosmos/base/v1beta1/coin_pb' import { ValidationError, ParseError } from '../errors' +const DENOM_REGEX_FRAGMENT = '[a-zA-Z][a-zA-Z0-9/:._-]{2,127}' +const COIN_STRING_REGEX = new RegExp(`^(\\d+)(${DENOM_REGEX_FRAGMENT})$`) +const DEC_COIN_STRING_REGEX = new RegExp(`^(-?\\d+(?:\\.\\d+)?)(${DENOM_REGEX_FRAGMENT})$`) + /** * Object with denom and amount fields (compatible with protobuf Coin). */ @@ -364,12 +368,17 @@ export class DecCoin { constructor(denom: string, amount: string | bigint | number) { let amountStr: string switch (typeof amount) { - case 'string': amountStr = amount; break - case 'bigint': amountStr = amount.toString(); break + case 'string': + amountStr = amount + break + case 'bigint': + amountStr = amount.toString() + break case 'number': if (!Number.isSafeInteger(amount)) throw new ValidationError('amount', `Amount must be a safe integer, got: ${amount}`) - amountStr = amount.toString(); break + amountStr = amount.toString() + break } if (!/^-?\d+(\.\d+)?$/.test(amountStr)) { @@ -589,8 +598,7 @@ export function parseCoin(str: string): Coin { throw new ParseError('coin', 'Empty string') } - // Match amount (digits) followed by denom (non-digits) - const match = str.match(/^(\d+)([a-zA-Z][a-zA-Z0-9/]*)$/) + const match = str.match(COIN_STRING_REGEX) if (!match) { throw new ParseError('coin', `Invalid format: ${str}`) } @@ -632,7 +640,7 @@ export function parseDecCoin(str: string): DecCoin { throw new ParseError('decCoin', 'Empty string') } - const match = str.match(/^(-?\d+(?:\.\d+)?)([a-zA-Z][a-zA-Z0-9/]*)$/) + const match = str.match(DEC_COIN_STRING_REGEX) if (!match) { throw new ParseError('decCoin', `Invalid format: ${str}`) } diff --git a/test/unit/core/coin.spec.ts b/test/unit/core/coin.spec.ts index 4ff3ef1..4a53e6e 100644 --- a/test/unit/core/coin.spec.ts +++ b/test/unit/core/coin.spec.ts @@ -3,7 +3,7 @@ */ import { describe, it, expect } from 'vitest' -import { Coin, coin, coins, parseCoin, DecCoin } from '../../../src/core/coin' +import { Coin, coin, coins, parseCoin, parseDecCoin, DecCoin } from '../../../src/core/coin' import { ValidationError } from '../../../src/errors' describe('Coin', () => { @@ -260,6 +260,26 @@ describe('parseCoin', () => { expect(c.amount).toBe('1000') }) + it('should parse denoms containing ":", "-", "." and "_"', () => { + const c = parseCoin('1001factory/init1abc/my-token_v1.5') + expect(c.denom).toBe('factory/init1abc/my-token_v1.5') + expect(c.amount).toBe('1001') + + expect(parseCoin('7move/module:coin')).toEqual(new Coin('move/module:coin', '7')) + }) + + it('should enforce Cosmos denom length bounds', () => { + const minDenom = 'abc' + const maxDenom = `a${'b'.repeat(127)}` + const tooShortDenom = 'ab' + const tooLongDenom = `a${'b'.repeat(128)}` + + expect(parseCoin(`1${minDenom}`).denom).toBe(minDenom) + expect(parseCoin(`1${maxDenom}`).denom).toBe(maxDenom) + expect(() => parseCoin(`1${tooShortDenom}`)).toThrow('Invalid format') + expect(() => parseCoin(`1${tooLongDenom}`)).toThrow('Invalid format') + }) + it('should throw for empty string', () => { expect(() => parseCoin('')).toThrow('Empty string') }) @@ -275,6 +295,28 @@ describe('parseCoin', () => { }) }) +describe('parseDecCoin', () => { + it('should parse denoms containing ":", "-", "." and "_"', () => { + const c = parseDecCoin('1001.5factory/init1abc/my-token_v1.5') + expect(c.denom).toBe('factory/init1abc/my-token_v1.5') + expect(c.amount).toBe('1001.500000000000000000') + + expect(parseDecCoin('-1move/module:coin')).toEqual(new DecCoin('move/module:coin', '-1')) + }) + + it('should enforce Cosmos denom length bounds', () => { + const minDenom = 'abc' + const maxDenom = `a${'b'.repeat(127)}` + const tooShortDenom = 'ab' + const tooLongDenom = `a${'b'.repeat(128)}` + + expect(parseDecCoin(`1.5${minDenom}`).denom).toBe(minDenom) + expect(parseDecCoin(`1.5${maxDenom}`).denom).toBe(maxDenom) + expect(() => parseDecCoin(`1.5${tooShortDenom}`)).toThrow('Invalid format') + expect(() => parseDecCoin(`1.5${tooLongDenom}`)).toThrow('Invalid format') + }) +}) + // ============================================================================= // Static Utilities // =============================================================================