From 23fd210a5457c2f1ccda7abefd9fee90a8849b8a Mon Sep 17 00:00:00 2001 From: pastaghost Date: Wed, 22 Feb 2023 01:43:32 -0700 Subject: [PATCH 1/5] feat(asset-value): initial commit --- packages/asset-value/CHANGELOG.md | 0 packages/asset-value/README.md | 145 +++ packages/asset-value/package.json | 36 + packages/asset-value/src/assetValue.test.ts | 1117 +++++++++++++++++++ packages/asset-value/src/assetValue.ts | 212 ++++ packages/asset-value/src/constants.ts | 2 + packages/asset-value/src/index.ts | 2 + packages/asset-value/src/types.ts | 38 + packages/asset-value/src/utils.ts | 12 + packages/asset-value/tsconfig.build.json | 14 + packages/asset-value/tsconfig.json | 7 + tsconfig.json | 1 + yarn.lock | 10 + 13 files changed, 1596 insertions(+) create mode 100644 packages/asset-value/CHANGELOG.md create mode 100644 packages/asset-value/README.md create mode 100644 packages/asset-value/package.json create mode 100644 packages/asset-value/src/assetValue.test.ts create mode 100644 packages/asset-value/src/assetValue.ts create mode 100644 packages/asset-value/src/constants.ts create mode 100644 packages/asset-value/src/index.ts create mode 100644 packages/asset-value/src/types.ts create mode 100644 packages/asset-value/src/utils.ts create mode 100644 packages/asset-value/tsconfig.build.json create mode 100644 packages/asset-value/tsconfig.json diff --git a/packages/asset-value/CHANGELOG.md b/packages/asset-value/CHANGELOG.md new file mode 100644 index 000000000..e69de29bb diff --git a/packages/asset-value/README.md b/packages/asset-value/README.md new file mode 100644 index 000000000..38c7d419d --- /dev/null +++ b/packages/asset-value/README.md @@ -0,0 +1,145 @@ +# @shapeshiftoss/asset-value + +This package provides an arithmetic and formatting class used to store the numerical value of an [Asset](https://github.com/shapeshift/lib/blob/9286cf2c27835d766719298ae688e0805448b00f/packages/asset-service/src/service/AssetService.ts#L10). + +## Getting Started + +```shell +yarn add @shapeshiftoss/asset-value +``` + +## Usage + +### Format Conversion + +Initialize an `AssetValue` instance with an `Asset` or `AssetId` and value, and specify the initial representation of the value. + +```typescript +// Initialization using Asset +const asset: Asset = { + assetId: 'cosmos:osmosis-1/ibc:gamm/pool/1', + chainId: 'cosmos:osmosis-1', + symbol: 'gamm/pool/1', + name: 'Osmosis OSMO/ATOM LP Token', + precision: 6, + color: '#750BBB', + icon: 'https://rawcdn.githack.com/cosmos/chain-registry/master/osmosis/images/osmo.png', + explorer: 'https://www.mintscan.io/osmosis', + explorerAddressLink: 'https://www.mintscan.io/osmosis/account/', + explorerTxLink: 'https://www.mintscan.io/osmosis/txs/', +} +const av1 = new AssetValue({ value: '42', asset: asset, format: AssetValueFormat.BASE_UNIT }) + +// Initialization using AssetId +const av2 = new AssetValue({ + value: '420', + assetId: 'cosmos:osmosis-1/ibc:118', + precision: 6, + format: AssetValueFormat.PRECISION, +}) +``` + +
+Converting a string containing numerical value in baseunit representation to precision representation is cumbersome and error-prone. + +```typescript +import { AssetValue } from '@shapeshiftoss/asset-value' + +// Converting a string in baseunit representation to precision representation +const underlyingAsset0AmountPrecision = bnOrZero(asset0AmountBaseUnit) + .dividedBy(bn(10).pow(lpAsset.precision ?? '0')) + .toString() +``` + +With AssetValue instances, this is much simpler. Just call the `toPrecision()` formatting method of the AssetValue. + +```typescript +import { AssetValue } from '@shapeshiftoss/asset-value' + +const underlyingAsset0Amount: string = asset0Amount.toPrecision() +``` + +Likewise, for a string contining the numerical value in baseunit representation, call the `toBaseUnit()` formatting method of the AssetValue. + +```typescript +import { AssetValue } from '@shapeshiftoss/asset-value' + +const underlyingAsset0Amount: string = asset0Amount.toBaseUnit() +``` + +### Math + +The AssetValue class keeps track of the appropriate precision for the value based on the asset used to initialize the instance. This way, you don't need to keep track of which set of units the value is in or what the correct precision for the asset is. In fact, there is no longer any concept of precision or baseunit representations for values. You can perform arbitrary arithmetic operations on AssetValues as needed and then call whichever formatting method is appropriate when you need a string representation of the value. + +
+ +Math operations are performed on AssetValue instances as if they were [bignumbers](https://mikemcl.github.io/bignumber.js/). + +```typescript +import { AssetValue } from '@shapeshiftoss/asset-value' + +// Arithmetic operations return a new AssetValue instance +const result: AssetValue = av1.plus(av2) +const negativeValue: AssetValue = av1.negated() + +// Scalar operations like multiplication and division take a string or number as the argument +const product: AssetValue = av1.multipliedBy('100') +const percent: AssetValue = av1.dividedBy(100) +``` + +### Redux + +For Redux-compatibility, AssetValues are serializable. +Call the `toSerialized()` method of any `AssetValue` to get a `SerializedAssetValue` containing the state of the `AssetValue` + +```typescript +import { AssetValue, SerializedAssetValue } from '@shapeshiftoss/asset-value' + +const state: SerializedAssetValue = av.toSerialized() +``` + +
+ +The `SerializedAssetValue` objects can be directly inserted into a Redux store, or used as an intermediate representation to be passed between React components and Redux via selectors and reducers. + +```typescript +// selector.ts +export const selectAccountBalancesByAccountId(state, id: AccountId){ + const balances = state.portfolio.accountBalances.ById[id] + return Object.entries(balances).map((assetId: AssetId, balance: string) => { + const asset = state.assets[id] + return new AssetValue(asset, value: balance, format: AssetValueFormat.BASE_UNIT).toSerialized() + }) +} + +// Component.tsx +const serializedBalanceData = useAppSelector(state => selectAccountBalancesByAccountId(state, accountId)) +const doubledBalances: AssetValue[] = serializedBalanceData.map(data: SerializedAssetValue => { + return (new AssetValue(data)).multipliedBy(2) +}) + +const serializedDoubledBalanceData: SerializedAssetValue[] = doubledBalances.map(balance => balance.toSerialized()) + +dispatch({ + type: ComponentActionType.SET_BALANCES_FOR_ACCOUNT_ID, + payload: { + accountId, + balances: serializedDoubledBalanceData + } +}) + +// reducer.ts +const reducer = { + state: ComponentState, + action: ComponentActionType +}: ComponentState => { + switch(action.type){ + case SET_BALANCES_FOR_ACCOUNT_ID: + const accountBalances = {[action.payload.id]: action.payload.balances} + return {...state, balances: {...state.balances, accountBalances}} + break; + default: + return state + } +} +``` diff --git a/packages/asset-value/package.json b/packages/asset-value/package.json new file mode 100644 index 000000000..ddceaf4a1 --- /dev/null +++ b/packages/asset-value/package.json @@ -0,0 +1,36 @@ +{ + "name": "@shapeshiftoss/asset-value", + "version": "1.0.0", + "description": "Math and formatting utility class for asset data", + "homepage": "https://github.com/shapeshift/lib/tree/main/packages/asset-value", + "license": "MIT", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "files": [ + "dist/*.js", + "dist/*.d.ts", + "dist/service" + ], + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "https://github.com/pastaghost/asset-value" + }, + "scripts": { + "build": "yarn clean && yarn compile", + "clean": "rm -rf dist && rm -rf tsconfig.build.tsbuildinfo", + "compile": "tsc -p tsconfig.build.json", + "dev": "tsc --watch", + "prepare": "yarn build", + "test": "jest test", + "type-check": "tsc --project ./tsconfig.build.json --noEmit" + }, + "dependencies": { + "@shapeshiftoss/asset-service": "^8.8.1", + "@shapeshiftoss/caip": "^8.13.0", + "bignumber.js": "^9.1.1", + "ts-md5": "^1.3.1" + } +} diff --git a/packages/asset-value/src/assetValue.test.ts b/packages/asset-value/src/assetValue.test.ts new file mode 100644 index 000000000..7afb480cd --- /dev/null +++ b/packages/asset-value/src/assetValue.test.ts @@ -0,0 +1,1117 @@ +import { Asset } from '@shapeshiftoss/asset-service' + +import { AssetValue, AV } from './assetValue' +import { AssetValueFormat, SerializedAssetValue } from './types' + +describe('AssetValue', () => { + describe('Initialization', () => { + it('should return a new AssetValue instance when initialized with valid AssetValueParams', () => { + const asset: Asset = { + assetId: 'cosmos:osmosis-1/ibc:gamm/pool/1', + chainId: 'cosmos:osmosis-1', + symbol: 'gamm/pool/1', + name: 'Osmosis OSMO/ATOM LP Token', + precision: 6, + color: '#750BBB', + icon: 'https://rawcdn.githack.com/cosmos/chain-registry/master/osmosis/images/osmo.png', + explorer: 'https://www.mintscan.io/osmosis', + explorerAddressLink: 'https://www.mintscan.io/osmosis/account/', + explorerTxLink: 'https://www.mintscan.io/osmosis/txs/', + } + const av1 = new AssetValue({ value: '42', asset, format: AssetValueFormat.BASE_UNIT }) + expect(av1).toBeInstanceOf(AssetValue) + + const av2 = new AssetValue({ + value: '42', + assetId: asset.assetId, + precision: asset.precision, + format: AssetValueFormat.BASE_UNIT, + }) + expect(av2).toBeInstanceOf(AssetValue) + }) + + it('should throw an error when initialized with invalid AssetValueParams', () => { + const asset: Asset = { + assetId: 'cosmos:osmosis-1/ibc:gamm/pool/1', + chainId: 'cosmos:osmosis-1', + symbol: 'gamm/pool/1', + name: 'Osmosis OSMO/ATOM LP Token', + precision: -1, + color: '#750BBB', + icon: 'https://rawcdn.githack.com/cosmos/chain-registry/master/osmosis/images/osmo.png', + explorer: 'https://www.mintscan.io/osmosis', + explorerAddressLink: 'https://www.mintscan.io/osmosis/account/', + explorerTxLink: 'https://www.mintscan.io/osmosis/txs/', + } + expect(() => { + new AssetValue({ value: '42', asset, format: AssetValueFormat.BASE_UNIT }) + }).toThrow(new Error('Cannot initialize AssetValue with invalid asset')) + + expect(() => { + new AssetValue({ + value: '42', + asset: { ...asset, assetId: '' }, + format: AssetValueFormat.BASE_UNIT, + }) + }).toThrow(new Error('Cannot initialize AssetValue with invalid asset')) + + expect(() => { + new AssetValue({ + value: '42', + assetId: '', + precision: asset.precision, + format: AssetValueFormat.BASE_UNIT, + }) + }).toThrow(new Error('Cannot initialize AssetValue with invalid asset')) + + expect(() => { + new AssetValue({ + value: '42', + assetId: 'cosmos:osmosis-1/ibc:gamm/pool/1', + precision: -1, + format: AssetValueFormat.BASE_UNIT, + }) + }).toThrow(new Error('Cannot initialize AssetValue with invalid asset')) + }) + + it('should return a new AssetValue instance when initialized with a valid SerializedAssetValue', () => { + const serialized: SerializedAssetValue = + '{"a":"cosmos:osmosis-1/ibc:gamm/pool/1","p":18,"v":"42"}|44def74b' + const av = new AssetValue(serialized) + expect(av).toBeInstanceOf(AssetValue) + }) + + it('should throw an error when initialized with an invalid SerializedAssetValue', () => { + /* Modify data after serialization to invalidate the checksum */ + const serialized: SerializedAssetValue = + '{"a":"cosmos:jankchain-69/ibc:gamm/pool/1","p":18,"v":"42"}|44def74b' + expect(() => { + new AssetValue(serialized) + }).toThrow(new Error('Invalid checksum for SerializedAssetValue. Expected aef3ca32.')) + + /* No delimiter in serializedAssetValue */ + const serialized2: SerializedAssetValue = + '{"a":"cosmos:osmosis-1/ibc:gamm/pool/1","p":18,"v":"42"}44def74b' + expect(() => { + new AssetValue(serialized2) + }).toThrow( + new Error('Cannot initialize AssetValue from improperly-formatted SerializedAssetValue'), + ) + + /* Missing field in serializedAssetValue */ + const serialized3: SerializedAssetValue = + '{"a":"cosmos:osmosis-1/ibc:gamm/pool/1","v":"42"}|44def74b' + expect(() => { + new AssetValue(serialized3) + }).toThrow(new Error('Cannot initialize AssetValue from underspecified SerializedAssetValue')) + }) + + it('should return an AssetValue class instance when the AV class alias is used', () => { + const av = new AV({ + value: '42', + assetId: 'cosmos:osmosis-1/ibc:gamm/pool/1', + precision: 6, + format: AssetValueFormat.BASE_UNIT, + }) + expect(av).toBeInstanceOf(AssetValue) + }) + }) + + describe('Arithmetic Operations', () => { + describe('Addition', () => { + it('should return a new AssetValue instance when adding two AssetValues of the same type', () => { + const term1 = new AssetValue({ + value: '42', + assetId: 'cosmos:osmosis-1/ibc:gamm/pool/1', + precision: 6, + format: AssetValueFormat.BASE_UNIT, + }) + const term2 = new AssetValue({ + value: '69.420', + assetId: 'cosmos:osmosis-1/ibc:gamm/pool/1', + precision: 6, + format: AssetValueFormat.PRECISION, + }) + expect(term1.plus(term2)).toBeInstanceOf(AssetValue) + }) + + it('should return a new AssetValue instance when adding two AssetValues with the same assetId and precision', () => { + const term1 = new AssetValue({ + value: '42', + assetId: 'cosmos:osmosis-1/ibc:gamm/pool/1', + precision: 6, + format: AssetValueFormat.BASE_UNIT, + }) + const term2 = new AssetValue({ + value: '69.420', + assetId: 'cosmos:osmosis-1/ibc:gamm/pool/1', + precision: 6, + format: AssetValueFormat.PRECISION, + }) + expect(term1.plus(term2)).toBeInstanceOf(AssetValue) + }) + + it('should throw an error when adding two AssetValues with different assetIds and the same precision', () => { + const term1 = new AssetValue({ + value: '42', + assetId: 'cosmos:evmos-1/ibc:118', + precision: 6, + format: AssetValueFormat.BASE_UNIT, + }) + const term2 = new AssetValue({ + value: '69.420', + assetId: 'cosmos:osmosis-1/ibc:118', + precision: 6, + format: AssetValueFormat.PRECISION, + }) + expect(() => { + term1.plus(term2) + }).toThrowError( + new Error( + 'Cannot add assets of different type (cosmos:osmosis-1/ibc:118 and cosmos:evmos-1/ibc:118)', + ), + ) + }) + + it('should should add two positive assetValues properly', () => { + const term1 = new AssetValue({ + value: '420', + assetId: 'cosmos:osmosis-1/ibc:118', + precision: 6, + format: AssetValueFormat.BASE_UNIT, + }) + const term2 = new AssetValue({ + value: '69', + assetId: 'cosmos:osmosis-1/ibc:118', + precision: 6, + format: AssetValueFormat.BASE_UNIT, + }) + const term3 = new AssetValue({ + value: '42', + assetId: 'cosmos:osmosis-1/ibc:118', + precision: 6, + format: AssetValueFormat.PRECISION, + }) + + expect(term1.plus(term2).toBaseUnit()).toEqual('489') + expect(term1.plus(term2).toPrecision()).toEqual('0.000489') + expect(term1.plus(term3).toBaseUnit()).toEqual('42000420') + expect(term1.plus(term3).toPrecision()).toEqual('42.000420') + }) + + it('should should add two negative assetValues properly', () => { + const term1 = new AssetValue({ + value: '-420', + assetId: 'cosmos:osmosis-1/ibc:118', + precision: 6, + format: AssetValueFormat.BASE_UNIT, + }) + const term2 = new AssetValue({ + value: '-69', + assetId: 'cosmos:osmosis-1/ibc:118', + precision: 6, + format: AssetValueFormat.BASE_UNIT, + }) + const term3 = new AssetValue({ + value: '-42', + assetId: 'cosmos:osmosis-1/ibc:118', + precision: 6, + format: AssetValueFormat.PRECISION, + }) + + expect(term1.plus(term2).toBaseUnit()).toEqual('-489') + expect(term1.plus(term2).toPrecision()).toEqual('-0.000489') + expect(term1.plus(term3).toBaseUnit()).toEqual('-42000420') + expect(term1.plus(term3).toPrecision()).toEqual('-42.000420') + }) + + it('should should add one positive and one negative assetValue properly', () => { + const term1 = new AssetValue({ + value: '-420', + assetId: 'cosmos:osmosis-1/ibc:118', + precision: 6, + format: AssetValueFormat.BASE_UNIT, + }) + const term2 = new AssetValue({ + value: '69', + assetId: 'cosmos:osmosis-1/ibc:118', + precision: 6, + format: AssetValueFormat.BASE_UNIT, + }) + const term3 = new AssetValue({ + value: '42', + assetId: 'cosmos:osmosis-1/ibc:118', + precision: 6, + format: AssetValueFormat.PRECISION, + }) + + expect(term1.plus(term2).toBaseUnit()).toEqual('-351') + expect(term1.plus(term2).toPrecision()).toEqual('-0.000351') + expect(term1.plus(term3).toBaseUnit()).toEqual('41999580') + expect(term1.plus(term3).toPrecision()).toEqual('41.999580') + }) + + it('should throw an error when adding two AssetValues of different types', () => { + const term1 = new AssetValue({ + value: '-420', + assetId: 'cosmos:osmosis-1/ibc:118', + precision: 6, + format: AssetValueFormat.BASE_UNIT, + }) + const term2 = new AssetValue({ + value: '69', + assetId: 'cosmos:evmos-1/ibc:42069', + precision: 6, + format: AssetValueFormat.BASE_UNIT, + }) + + expect(() => { + term1.plus(term2) + }).toThrow( + new Error( + 'Cannot add assets of different type (cosmos:evmos-1/ibc:42069 and cosmos:osmosis-1/ibc:118)', + ), + ) + }) + }) + describe('Subtraction', () => { + it('should return a new AssetValue instance when subtracting two AssetValues of the same type', () => { + const term1 = new AssetValue({ + value: '42', + assetId: 'cosmos:osmosis-1/ibc:gamm/pool/1', + precision: 6, + format: AssetValueFormat.BASE_UNIT, + }) + const term2 = new AssetValue({ + value: '69.420', + assetId: 'cosmos:osmosis-1/ibc:gamm/pool/1', + precision: 6, + format: AssetValueFormat.PRECISION, + }) + expect(term1.minus(term2)).toBeInstanceOf(AssetValue) + }) + + it('should return a new AssetValue instance when subtracting two AssetValues with the same assetId and precision', () => { + const term1 = new AssetValue({ + value: '42', + assetId: 'cosmos:osmosis-1/ibc:gamm/pool/1', + precision: 6, + format: AssetValueFormat.BASE_UNIT, + }) + const term2 = new AssetValue({ + value: '69.420', + assetId: 'cosmos:osmosis-1/ibc:gamm/pool/1', + precision: 6, + format: AssetValueFormat.PRECISION, + }) + expect(term1.minus(term2)).toBeInstanceOf(AssetValue) + }) + + it('should throw an error when subtracting two AssetValues with different assetIds and the same precision', () => { + const term1 = new AssetValue({ + value: '42', + assetId: 'cosmos:evmos-1/ibc:118', + precision: 6, + format: AssetValueFormat.BASE_UNIT, + }) + const term2 = new AssetValue({ + value: '69.420', + assetId: 'cosmos:osmosis-1/ibc:118', + precision: 6, + format: AssetValueFormat.PRECISION, + }) + expect(() => { + term1.minus(term2) + }).toThrowError( + new Error( + 'Cannot subtract assets of different type (cosmos:osmosis-1/ibc:118 and cosmos:evmos-1/ibc:118)', + ), + ) + }) + + it('should should subtract two positive assetValues properly', () => { + const term1 = new AssetValue({ + value: '420', + assetId: 'cosmos:osmosis-1/ibc:118', + precision: 6, + format: AssetValueFormat.BASE_UNIT, + }) + const term2 = new AssetValue({ + value: '69', + assetId: 'cosmos:osmosis-1/ibc:118', + precision: 6, + format: AssetValueFormat.BASE_UNIT, + }) + const term3 = new AssetValue({ + value: '42', + assetId: 'cosmos:osmosis-1/ibc:118', + precision: 6, + format: AssetValueFormat.PRECISION, + }) + + expect(term1.minus(term2).toBaseUnit()).toEqual('351') + expect(term1.minus(term2).toPrecision()).toEqual('0.000351') + expect(term1.minus(term3).toBaseUnit()).toEqual('-41999580') + expect(term1.minus(term3).toPrecision()).toEqual('-41.999580') + }) + + it('should should subtract two negative assetValues properly', () => { + const term1 = new AssetValue({ + value: '-420', + assetId: 'cosmos:osmosis-1/ibc:118', + precision: 6, + format: AssetValueFormat.BASE_UNIT, + }) + const term2 = new AssetValue({ + value: '-69', + assetId: 'cosmos:osmosis-1/ibc:118', + precision: 6, + format: AssetValueFormat.BASE_UNIT, + }) + const term3 = new AssetValue({ + value: '-42', + assetId: 'cosmos:osmosis-1/ibc:118', + precision: 6, + format: AssetValueFormat.PRECISION, + }) + + expect(term1.minus(term2).toBaseUnit()).toEqual('-351') + expect(term1.minus(term2).toPrecision()).toEqual('-0.000351') + expect(term1.minus(term3).toBaseUnit()).toEqual('41999580') + expect(term1.minus(term3).toPrecision()).toEqual('41.999580') + }) + + it('should should subtract one positive and one negative assetValue properly', () => { + const term1 = new AssetValue({ + value: '-420', + assetId: 'cosmos:osmosis-1/ibc:118', + precision: 6, + format: AssetValueFormat.BASE_UNIT, + }) + const term2 = new AssetValue({ + value: '69', + assetId: 'cosmos:osmosis-1/ibc:118', + precision: 6, + format: AssetValueFormat.BASE_UNIT, + }) + const term3 = new AssetValue({ + value: '42', + assetId: 'cosmos:osmosis-1/ibc:118', + precision: 6, + format: AssetValueFormat.PRECISION, + }) + + expect(term1.minus(term2).toBaseUnit()).toEqual('-489') + expect(term1.minus(term2).toPrecision()).toEqual('-0.000489') + expect(term1.minus(term3).toBaseUnit()).toEqual('-42000420') + expect(term1.minus(term3).toPrecision()).toEqual('-42.000420') + }) + + it('should throw an error when subtracting two AssetValues of different types', () => { + const term1 = new AssetValue({ + value: '-420', + assetId: 'cosmos:osmosis-1/ibc:118', + precision: 6, + format: AssetValueFormat.BASE_UNIT, + }) + const term2 = new AssetValue({ + value: '69', + assetId: 'cosmos:evmos-1/ibc:42069', + precision: 6, + format: AssetValueFormat.BASE_UNIT, + }) + + expect(() => { + term1.minus(term2) + }).toThrow( + new Error( + 'Cannot subtract assets of different type (cosmos:evmos-1/ibc:42069 and cosmos:osmosis-1/ibc:118)', + ), + ) + }) + }) + describe('Multiplication', () => { + it('should return a new AssetValue instance when multiplying two AssetValues of the same type', () => { + const term1 = new AssetValue({ + value: '42', + assetId: 'cosmos:osmosis-1/ibc:gamm/pool/1', + precision: 6, + format: AssetValueFormat.BASE_UNIT, + }) + const term2 = 69 + + expect(term1.multipliedBy(term2)).toBeInstanceOf(AssetValue) + }) + + it('should return a new AssetValue instance when multiplying two AssetValues with the same assetId and precision', () => { + const term1 = new AssetValue({ + value: '42', + assetId: 'cosmos:osmosis-1/ibc:gamm/pool/1', + precision: 6, + format: AssetValueFormat.BASE_UNIT, + }) + const term2 = 69 + + expect(term1.multipliedBy(term2)).toBeInstanceOf(AssetValue) + }) + + it('should should multiply two positive assetValues properly', () => { + const term1 = new AssetValue({ + value: '420', + assetId: 'cosmos:osmosis-1/ibc:118', + precision: 6, + format: AssetValueFormat.BASE_UNIT, + }) + const term2 = 69 + const term3 = '69' + + expect(term1.multipliedBy(term2).toBaseUnit()).toEqual('28980') + expect(term1.multipliedBy(term2).toPrecision()).toEqual('0.028980') + expect(term1.multipliedBy(term3).toBaseUnit()).toEqual('28980') + expect(term1.multipliedBy(term3).toPrecision()).toEqual('0.028980') + }) + + it('should should multiply two negative assetValues properly', () => { + const term1 = new AssetValue({ + value: '-420', + assetId: 'cosmos:osmosis-1/ibc:118', + precision: 6, + format: AssetValueFormat.BASE_UNIT, + }) + const term2 = -69 + const term3 = '-69' + + expect(term1.multipliedBy(term2).toBaseUnit()).toEqual('28980') + expect(term1.multipliedBy(term2).toPrecision()).toEqual('0.028980') + expect(term1.multipliedBy(term3).toBaseUnit()).toEqual('28980') + expect(term1.multipliedBy(term3).toPrecision()).toEqual('0.028980') + }) + + it('should should multiply one positive and one negative assetValue properly', () => { + const term1 = new AssetValue({ + value: '-420', + assetId: 'cosmos:osmosis-1/ibc:118', + precision: 6, + format: AssetValueFormat.BASE_UNIT, + }) + const term2 = 69 + const term3 = '69' + + expect(term1.multipliedBy(term2).toBaseUnit()).toEqual('-28980') + expect(term1.multipliedBy(term2).toPrecision()).toEqual('-0.028980') + expect(term1.multipliedBy(term3).toBaseUnit()).toEqual('-28980') + expect(term1.multipliedBy(term3).toPrecision()).toEqual('-0.028980') + }) + }) + describe('Division', () => { + it('should return a new AssetValue instance when dividing two AssetValues of the same type', () => { + const term1 = new AssetValue({ + value: '42', + assetId: 'cosmos:osmosis-1/ibc:gamm/pool/1', + precision: 6, + format: AssetValueFormat.BASE_UNIT, + }) + const term2 = 69 + + expect(term1.dividedBy(term2)).toBeInstanceOf(AssetValue) + }) + + it('should return a new AssetValue instance when dividing two AssetValues with the same assetId and precision', () => { + const term1 = new AssetValue({ + value: '42', + assetId: 'cosmos:osmosis-1/ibc:gamm/pool/1', + precision: 6, + format: AssetValueFormat.BASE_UNIT, + }) + const term2 = 69 + + expect(term1.dividedBy(term2)).toBeInstanceOf(AssetValue) + }) + + it('should should divide two positive assetValues properly', () => { + const term1 = new AssetValue({ + value: '420', + assetId: 'cosmos:osmosis-1/ibc:118', + precision: 6, + format: AssetValueFormat.BASE_UNIT, + }) + const term2 = 69 + const term3 = '69' + + expect(term1.dividedBy(term2).toBaseUnit()).toEqual('6') + expect(term1.dividedBy(term2).toPrecision()).toEqual('0.000006') + expect(term1.dividedBy(term3).toBaseUnit()).toEqual('6') + expect(term1.dividedBy(term3).toPrecision()).toEqual('0.000006') + }) + + it('should should divide two negative assetValues properly', () => { + const term1 = new AssetValue({ + value: '-420', + assetId: 'cosmos:osmosis-1/ibc:118', + precision: 6, + format: AssetValueFormat.BASE_UNIT, + }) + const term2 = -69 + const term3 = '-69' + + expect(term1.dividedBy(term2).toBaseUnit()).toEqual('6') + expect(term1.dividedBy(term2).toPrecision()).toEqual('0.000006') + expect(term1.dividedBy(term3).toBaseUnit()).toEqual('6') + expect(term1.dividedBy(term3).toPrecision()).toEqual('0.000006') + }) + + it('should should divide one positive and one negative assetValue properly', () => { + const term1 = new AssetValue({ + value: '-420', + assetId: 'cosmos:osmosis-1/ibc:118', + precision: 6, + format: AssetValueFormat.BASE_UNIT, + }) + const term2 = 69 + const term3 = '69' + + expect(term1.dividedBy(term2).toBaseUnit()).toEqual('-6') + expect(term1.dividedBy(term2).toPrecision()).toEqual('-0.000006') + expect(term1.dividedBy(term3).toBaseUnit()).toEqual('-6') + expect(term1.dividedBy(term3).toPrecision()).toEqual('-0.000006') + }) + }) + }) + + describe('Comparison Operations', () => { + describe('Greater Than', () => { + it('should return true when term 1 is greater than term 2', () => { + const term1 = new AssetValue({ + value: '420', + assetId: 'cosmos:osmosis-1/ibc:118', + precision: 6, + format: AssetValueFormat.BASE_UNIT, + }) + const term2 = new AssetValue({ + value: '69', + assetId: 'cosmos:osmosis-1/ibc:118', + precision: 6, + format: AssetValueFormat.BASE_UNIT, + }) + + expect(term1.isGreaterThan(term2)).toEqual(true) + }) + it('should return false when term 1 is less than term 2', () => { + const term1 = new AssetValue({ + value: '69', + assetId: 'cosmos:osmosis-1/ibc:118', + precision: 6, + format: AssetValueFormat.BASE_UNIT, + }) + const term2 = new AssetValue({ + value: '420', + assetId: 'cosmos:osmosis-1/ibc:118', + precision: 6, + format: AssetValueFormat.BASE_UNIT, + }) + + expect(term1.isGreaterThan(term2)).toEqual(false) + }) + it('should return false when term 1 is equal to term 2', () => { + const term1 = new AssetValue({ + value: '420', + assetId: 'cosmos:osmosis-1/ibc:118', + precision: 6, + format: AssetValueFormat.BASE_UNIT, + }) + const term2 = new AssetValue({ + value: '420', + assetId: 'cosmos:osmosis-1/ibc:118', + precision: 6, + format: AssetValueFormat.BASE_UNIT, + }) + + expect(term1.isGreaterThan(term2)).toEqual(false) + }) + }) + describe('Greater Than Or Equal To', () => { + it('should return true when term 1 is greater than term 2', () => { + const term1 = new AssetValue({ + value: '420', + assetId: 'cosmos:osmosis-1/ibc:118', + precision: 6, + format: AssetValueFormat.BASE_UNIT, + }) + const term2 = new AssetValue({ + value: '69', + assetId: 'cosmos:osmosis-1/ibc:118', + precision: 6, + format: AssetValueFormat.BASE_UNIT, + }) + + expect(term1.isGreaterThanOrEqualTo(term2)).toEqual(true) + }) + it('should return false when term 1 is less than term 2', () => { + const term1 = new AssetValue({ + value: '69', + assetId: 'cosmos:osmosis-1/ibc:118', + precision: 6, + format: AssetValueFormat.BASE_UNIT, + }) + const term2 = new AssetValue({ + value: '420', + assetId: 'cosmos:osmosis-1/ibc:118', + precision: 6, + format: AssetValueFormat.BASE_UNIT, + }) + + expect(term1.isGreaterThanOrEqualTo(term2)).toEqual(false) + }) + it('should return true when term 1 is equal to term 2', () => { + const term1 = new AssetValue({ + value: '420', + assetId: 'cosmos:osmosis-1/ibc:118', + precision: 6, + format: AssetValueFormat.BASE_UNIT, + }) + const term2 = new AssetValue({ + value: '420', + assetId: 'cosmos:osmosis-1/ibc:118', + precision: 6, + format: AssetValueFormat.BASE_UNIT, + }) + + expect(term1.isGreaterThanOrEqualTo(term2)).toEqual(true) + }) + }) + describe('Less Than', () => { + it('should return true when term 1 is less than term 2', () => { + const term1 = new AssetValue({ + value: '69', + assetId: 'cosmos:osmosis-1/ibc:118', + precision: 6, + format: AssetValueFormat.BASE_UNIT, + }) + const term2 = new AssetValue({ + value: '420', + assetId: 'cosmos:osmosis-1/ibc:118', + precision: 6, + format: AssetValueFormat.BASE_UNIT, + }) + + expect(term1.isLessThan(term2)).toEqual(true) + }) + it('should return false when term 1 is greater than term 2', () => { + const term1 = new AssetValue({ + value: '420', + assetId: 'cosmos:osmosis-1/ibc:118', + precision: 6, + format: AssetValueFormat.BASE_UNIT, + }) + const term2 = new AssetValue({ + value: '69', + assetId: 'cosmos:osmosis-1/ibc:118', + precision: 6, + format: AssetValueFormat.BASE_UNIT, + }) + + expect(term1.isLessThan(term2)).toEqual(false) + }) + it('should return false when term 1 is equal to term 2', () => { + const term1 = new AssetValue({ + value: '420', + assetId: 'cosmos:osmosis-1/ibc:118', + precision: 6, + format: AssetValueFormat.BASE_UNIT, + }) + const term2 = new AssetValue({ + value: '420', + assetId: 'cosmos:osmosis-1/ibc:118', + precision: 6, + format: AssetValueFormat.BASE_UNIT, + }) + + expect(term1.isLessThan(term2)).toEqual(false) + }) + }) + describe('Less Than Or Equal To', () => { + it('should return true when term 1 is less than term 2', () => { + const term1 = new AssetValue({ + value: '69', + assetId: 'cosmos:osmosis-1/ibc:118', + precision: 6, + format: AssetValueFormat.BASE_UNIT, + }) + const term2 = new AssetValue({ + value: '420', + assetId: 'cosmos:osmosis-1/ibc:118', + precision: 6, + format: AssetValueFormat.BASE_UNIT, + }) + + expect(term1.isLessThanOrEqualTo(term2)).toEqual(true) + }) + it('should return false when term 1 is greater than term 2', () => { + const term1 = new AssetValue({ + value: '420', + assetId: 'cosmos:osmosis-1/ibc:118', + precision: 6, + format: AssetValueFormat.BASE_UNIT, + }) + const term2 = new AssetValue({ + value: '69', + assetId: 'cosmos:osmosis-1/ibc:118', + precision: 6, + format: AssetValueFormat.BASE_UNIT, + }) + + expect(term1.isLessThanOrEqualTo(term2)).toEqual(false) + }) + it('should return true when term 1 is equal to term 2', () => { + const term1 = new AssetValue({ + value: '420', + assetId: 'cosmos:osmosis-1/ibc:118', + precision: 6, + format: AssetValueFormat.BASE_UNIT, + }) + const term2 = new AssetValue({ + value: '420', + assetId: 'cosmos:osmosis-1/ibc:118', + precision: 6, + format: AssetValueFormat.BASE_UNIT, + }) + + expect(term1.isLessThanOrEqualTo(term2)).toEqual(true) + }) + }) + describe('Equal To', () => { + it('should return false for two unequal assetValues of the same type', () => { + const term1 = new AssetValue({ + value: '-420', + assetId: 'cosmos:osmosis-1/ibc:118', + precision: 6, + format: AssetValueFormat.BASE_UNIT, + }) + const term2 = new AssetValue({ + value: '69', + assetId: 'cosmos:osmosis-1/ibc:118', + precision: 6, + format: AssetValueFormat.BASE_UNIT, + }) + + expect(term1.isEqualTo(term2)).toEqual(false) + }) + it('should return true for two equal assetValues of the same type', () => { + const term1 = new AssetValue({ + value: '-420', + assetId: 'cosmos:osmosis-1/ibc:118', + precision: 6, + format: AssetValueFormat.BASE_UNIT, + }) + + const term2 = new AssetValue({ + value: '-420', + assetId: 'cosmos:osmosis-1/ibc:118', + precision: 6, + format: AssetValueFormat.BASE_UNIT, + }) + + expect(term1.isEqualTo(term2)).toEqual(true) + }) + + it('should throw an error for two assetValues of different types', () => { + const term1 = new AssetValue({ + value: '-420', + assetId: 'cosmos:osmosis-1/ibc:118', + precision: 6, + format: AssetValueFormat.BASE_UNIT, + }) + + const term2 = new AssetValue({ + value: '-420', + assetId: 'cosmos:evmos-1/ibc:118', + precision: 6, + format: AssetValueFormat.BASE_UNIT, + }) + + expect(() => { + term1.isEqualTo(term2) + }).toThrow( + new Error( + 'Cannot compare assets of different type (cosmos:evmos-1/ibc:118 and cosmos:osmosis-1/ibc:118)', + ), + ) + }) + }) + describe('Negative', () => { + it('should return true for an AssetValue with a negative value', () => { + const av = new AssetValue({ + value: '-420', + assetId: 'cosmos:osmosis-1/ibc:118', + precision: 6, + format: AssetValueFormat.BASE_UNIT, + }) + + expect(av.isNegative()).toEqual(true) + }) + + it('should return false for an AssetValue with a non-negative value', () => { + const av = new AssetValue({ + value: '0', + assetId: 'cosmos:osmosis-1/ibc:118', + precision: 6, + format: AssetValueFormat.BASE_UNIT, + }) + + expect(av.isNegative()).toEqual(false) + }) + }) + + describe('Zero', () => { + it('should return false for an AssetValue with a non-zero value', () => { + const av = new AssetValue({ + value: '-420', + assetId: 'cosmos:osmosis-1/ibc:118', + precision: 6, + format: AssetValueFormat.BASE_UNIT, + }) + + expect(av.isZero()).toEqual(false) + }) + + it('should return true for an AssetValue with a zero value', () => { + const av = new AssetValue({ + value: '0', + assetId: 'cosmos:osmosis-1/ibc:118', + precision: 6, + format: AssetValueFormat.BASE_UNIT, + }) + + expect(av.isZero()).toEqual(true) + }) + }) + }) + + describe('Unary Operations', () => { + describe('Negation', () => { + it('should negate the value of an AssetValue with a positive value', () => { + const av1 = new AssetValue({ + value: '1', + assetId: 'cosmos:osmosis-1/ibc:118', + precision: 6, + format: AssetValueFormat.BASE_UNIT, + }) + + const av2 = new AssetValue({ + value: '-1', + assetId: 'cosmos:osmosis-1/ibc:118', + precision: 6, + format: AssetValueFormat.BASE_UNIT, + }) + + expect(av1.negated().toBaseUnit()).toEqual(av2.toBaseUnit()) + }) + it('should negate the value of an AssetValue with a negative value', () => { + const av1 = new AssetValue({ + value: '-1', + assetId: 'cosmos:osmosis-1/ibc:118', + precision: 6, + format: AssetValueFormat.BASE_UNIT, + }) + + const av2 = new AssetValue({ + value: '1', + assetId: 'cosmos:osmosis-1/ibc:118', + precision: 6, + format: AssetValueFormat.BASE_UNIT, + }) + + expect(av1.negated().toBaseUnit()).toEqual(av2.toBaseUnit()) + }) + }) + }) + + describe('Formatting', () => { + it('should return a value in base units', () => { + const term1 = new AssetValue({ + value: '420', + assetId: 'cosmos:osmosis-1/ibc:118', + precision: 18, + format: AssetValueFormat.BASE_UNIT, + }) + expect(term1.toBaseUnit()).toEqual('420') + }) + it('should return a value in asset precision when asset precision is less than default precision', () => { + const term1 = new AssetValue({ + value: '420', + assetId: 'cosmos:osmosis-1/ibc:118', + precision: 4, + format: AssetValueFormat.BASE_UNIT, + }) + expect(term1.toPrecision()).toEqual('0.0420') + }) + + it('should return a value in default precision when asset precision is greater than default precision and no precision argument is provided', () => { + const term1 = new AssetValue({ + value: '420', + assetId: 'cosmos:osmosis-1/ibc:118', + precision: 7, + format: AssetValueFormat.BASE_UNIT, + }) + expect(term1.toPrecision()).toEqual('0.000042') + }) + + it('should return a value in specified precision when precision argument is provided', () => { + const term1 = new AssetValue({ + value: '420', + assetId: 'cosmos:osmosis-1/ibc:118', + precision: 9, + format: AssetValueFormat.BASE_UNIT, + }) + expect(term1.toPrecision(18)).toEqual('0.000000420000000000') + }) + + it('should return a value in specified precision when precision argument is provided and the default precision formatter is overridden', () => { + const term1 = new AssetValue({ + value: '420', + assetId: 'cosmos:osmosis-1/ibc:118', + precision: 9, + format: AssetValueFormat.PRECISION, + }) + expect(term1.toFixed(7)).toEqual('420.0000000') + }) + + it('should return a value in zero-decimal place precision when the manual formatter is called with no arguments', () => { + const term1 = new AssetValue({ + value: '420', + assetId: 'cosmos:osmosis-1/ibc:118', + precision: 9, + format: AssetValueFormat.PRECISION, + }) + expect(term1.toFixed()).toEqual('420') + }) + }) + + describe('Method Aliases', () => { + it('should bind mult() to the multipliedBy() method', () => { + const av = new AssetValue({ + value: '420', + assetId: 'cosmos:osmosis-1/ibc:118', + precision: 6, + format: AssetValueFormat.BASE_UNIT, + }) + const res1 = av.multipliedBy(69).toBaseUnit() + const res2 = av.mult(69).toBaseUnit() + expect(res1).toEqual(res2) + }) + it('should bind div() to the dividedBy() method', () => { + const av = new AssetValue({ + value: '420', + assetId: 'cosmos:osmosis-1/ibc:118', + precision: 6, + format: AssetValueFormat.BASE_UNIT, + }) + const res1 = av.dividedBy(69).toBaseUnit() + const res2 = av.div(69).toBaseUnit() + expect(res1).toEqual(res2) + }) + it('should bind gte() to the isGreaterThanOrEqualTo() method', () => { + const term1 = new AssetValue({ + value: '69', + assetId: 'cosmos:osmosis-1/ibc:118', + precision: 6, + format: AssetValueFormat.BASE_UNIT, + }) + const term2 = new AssetValue({ + value: '420', + assetId: 'cosmos:osmosis-1/ibc:118', + precision: 6, + format: AssetValueFormat.BASE_UNIT, + }) + const res1 = term1.isGreaterThan(term2) + const res2 = term1.gte(term2) + expect(res1).toEqual(res2) + }) + it('should bind lt() to the isLessThan() method', () => { + const term1 = new AssetValue({ + value: '420', + assetId: 'cosmos:osmosis-1/ibc:118', + precision: 6, + format: AssetValueFormat.BASE_UNIT, + }) + const term2 = new AssetValue({ + value: '420', + assetId: 'cosmos:osmosis-1/ibc:118', + precision: 6, + format: AssetValueFormat.BASE_UNIT, + }) + const res1 = term1.isLessThan(term2) + const res2 = term2.lt(term2) + expect(res1).toEqual(res2) + }) + it('should bind lte() to the isLessThanOrEqualTo() method', () => { + const term1 = new AssetValue({ + value: '420', + assetId: 'cosmos:osmosis-1/ibc:118', + precision: 6, + format: AssetValueFormat.BASE_UNIT, + }) + const term2 = new AssetValue({ + value: '420', + assetId: 'cosmos:osmosis-1/ibc:118', + precision: 6, + format: AssetValueFormat.BASE_UNIT, + }) + const res1 = term1.isLessThanOrEqualTo(term2) + const res2 = term2.lte(term2) + expect(res1).toEqual(res2) + }) + it('should bind eq() to the isEqualTo() method', () => { + const term1 = new AssetValue({ + value: '420', + assetId: 'cosmos:osmosis-1/ibc:118', + precision: 6, + format: AssetValueFormat.BASE_UNIT, + }) + const term2 = new AssetValue({ + value: '420', + assetId: 'cosmos:osmosis-1/ibc:118', + precision: 6, + format: AssetValueFormat.BASE_UNIT, + }) + const res1 = term1.isEqualTo(term2) + const res2 = term2.eq(term2) + expect(res1).toEqual(res2) + }) + it('should bind base() to the toBaseUnit() method', () => { + const av = new AssetValue({ + value: '420', + assetId: 'cosmos:osmosis-1/ibc:118', + precision: 6, + format: AssetValueFormat.BASE_UNIT, + }) + const res1 = av.dividedBy(69).toBaseUnit() + const res2 = av.div(69).base() + expect(res1).toEqual(res2) + }) + it('should bind prec() to the toPrecision() method', () => { + const av = new AssetValue({ + value: '420', + assetId: 'cosmos:osmosis-1/ibc:118', + precision: 6, + format: AssetValueFormat.BASE_UNIT, + }) + const res1 = av.dividedBy(69).toPrecision() + const res2 = av.div(69).prec() + expect(res1).toEqual(res2) + }) + }) + describe('Serialization', () => { + it('should produce a serialized state', () => { + const term1 = new AssetValue({ + value: '420', + assetId: 'cosmos:osmosis-1/ibc:118', + precision: 6, + format: AssetValueFormat.BASE_UNIT, + }) + expect(term1.toSerialized()).toEqual( + '{"a":"cosmos:osmosis-1/ibc:118","p":6,"v":"420"}|a35b8ad1', + ) + }) + }) +}) diff --git a/packages/asset-value/src/assetValue.ts b/packages/asset-value/src/assetValue.ts new file mode 100644 index 000000000..b2dff10ab --- /dev/null +++ b/packages/asset-value/src/assetValue.ts @@ -0,0 +1,212 @@ +import { AssetId } from '@shapeshiftoss/caip' +import { BigNumber } from 'bignumber.js' +import { Md5 } from 'ts-md5' + +import { CHECKSUM_LENGTH, DEFAULT_FORMAT_DECIMALS } from './constants' +import { AssetValueParams, isAssetValueParams, SerializedAssetValue } from './types' +import { AssetValueFormat } from './types' +import { bn, bnOrZero } from './utils' + +type AssetValueState = { + assetId: AssetId + precision: number + value: string +} + +//TODO(pastaghost): Add JSDoc documentation +export class AssetValue { + state: AssetValueState + + public constructor(params: AssetValueParams | SerializedAssetValue) { + if (isAssetValueParams(params)) { + if (params.asset && !(params.asset.assetId?.length && params.asset.precision > -1)) { + throw new Error('Cannot initialize AssetValue with invalid asset') + } + + if (!params.asset && !(params.assetId?.length && params.precision > -1)) { + throw new Error('Cannot initialize AssetValue with invalid asset') + } + + let _value: string + switch (params.format) { + case AssetValueFormat.BASE_UNIT: + _value = bnOrZero(params.value).toFixed(0, BigNumber.ROUND_DOWN) + break + case AssetValueFormat.PRECISION: + _value = bnOrZero(params.value) + .multipliedBy(bn(10).pow(params.asset ? params.asset.precision : params.precision)) + .toString() + break + } + this.state = { + assetId: params.asset ? params.asset.assetId : params.assetId, + precision: params.asset ? params.asset.precision : params.precision, + value: _value, + } + } else { + this.loadSerializedAssetValue(params as SerializedAssetValue) + } + } + + public get assetId(): AssetId { + return this.state.assetId + } + + public get precision(): number { + return this.state.precision + } + + public plus(term: AssetValue): AssetValue { + this.assertIsSameAsset(term, 'add') + + const value = bnOrZero(this.state.value).plus(bnOrZero(term.toBaseUnit())).toFixed() + return new AssetValue({ + value, + assetId: this.state.assetId, + precision: this.state.precision, + format: AssetValueFormat.BASE_UNIT, + }) + } + + public minus(term: AssetValue): AssetValue { + this.assertIsSameAsset(term, 'subtract') + + const value = bnOrZero(this.state.value).minus(bnOrZero(term.toBaseUnit())).toFixed() + return new AssetValue({ + value, + assetId: this.state.assetId, + precision: this.state.precision, + format: AssetValueFormat.BASE_UNIT, + }) + } + + public multipliedBy(factor: string | number): AssetValue { + const value = bnOrZero(this.state.value).multipliedBy(bnOrZero(factor)).toFixed() + return new AssetValue({ + value, + assetId: this.state.assetId, + precision: this.state.precision, + format: AssetValueFormat.BASE_UNIT, + }) + } + + public dividedBy(factor: string | number): AssetValue { + const value = bnOrZero(this.state.value).dividedBy(bnOrZero(factor)).toFixed() + return new AssetValue({ + value, + assetId: this.state.assetId, + precision: this.state.precision, + format: AssetValueFormat.BASE_UNIT, + }) + } + + public negated(): AssetValue { + const value = bnOrZero(this.state.value).negated().toFixed() + return new AssetValue({ + value, + assetId: this.state.assetId, + precision: this.state.precision, + format: AssetValueFormat.BASE_UNIT, + }) + } + + public isGreaterThan(term: AssetValue): boolean { + this.assertIsSameAsset(term, 'compare') + return bnOrZero(this.state.value).gt(bnOrZero(term.toBaseUnit())) + } + + public isGreaterThanOrEqualTo(term: AssetValue): boolean { + this.assertIsSameAsset(term, 'compare') + return bnOrZero(this.state.value).gte(bnOrZero(term.toBaseUnit())) + } + + public isLessThan(term: AssetValue): boolean { + this.assertIsSameAsset(term, 'compare') + return bnOrZero(this.state.value).lt(bnOrZero(term.toBaseUnit())) + } + + public isLessThanOrEqualTo(term: AssetValue): boolean { + this.assertIsSameAsset(term, 'compare') + return bnOrZero(this.state.value).lte(bnOrZero(term.toBaseUnit())) + } + + public isEqualTo(term: AssetValue): boolean { + this.assertIsSameAsset(term, 'compare') + return this.state.value === term.toBaseUnit() + } + + public isNegative(): boolean { + return bnOrZero(this.state.value).isNegative() + } + + public isZero(): boolean { + return bnOrZero(this.state.value).isZero() + } + + public toBaseUnit(): string { + return this.state.value + } + + public toPrecision(decimals?: number | string): string { + const _decimals = Number(decimals ?? Math.min(this.state.precision, DEFAULT_FORMAT_DECIMALS)) + return bnOrZero(this.state.value) + .dividedBy(bn(10).pow(bnOrZero(this.state.precision))) + .toFixed(_decimals) + } + + public toFixed(decimalPlaces?: number, roundingMode?: BigNumber.RoundingMode): string { + if (decimalPlaces) { + return bnOrZero(this.toPrecision()).toFixed(decimalPlaces, roundingMode) + } + return bnOrZero(this.toPrecision()).toFixed() + } + + public toSerialized(): SerializedAssetValue { + const serializedState = JSON.stringify({ + a: this.state.assetId, + p: this.state.precision, + v: this.state.value, + }) + const hash = Md5.hashStr(serializedState).slice(0, CHECKSUM_LENGTH) + return `${serializedState}|${hash}` as SerializedAssetValue + } + + public mult = this.multipliedBy.bind(this) + public div = this.dividedBy.bind(this) + public gte = this.isGreaterThanOrEqualTo.bind(this) + public lt = this.isLessThan.bind(this) + public lte = this.isLessThanOrEqualTo.bind(this) + public eq = this.isEqualTo.bind(this) + public base = this.toBaseUnit.bind(this) + public prec = this.toPrecision.bind(this) + + private loadSerializedAssetValue(value: SerializedAssetValue) { + const [serializedState, hash] = value.split('|') + if (!(serializedState && hash)) { + throw new Error('Cannot initialize AssetValue from improperly-formatted SerializedAssetValue') + } + const parsedState = JSON.parse(serializedState) + if (!(parsedState && parsedState.a && parsedState.p && parsedState.v)) { + throw new Error('Cannot initialize AssetValue from underspecified SerializedAssetValue') + } + const _hash = Md5.hashStr(serializedState).slice(0, CHECKSUM_LENGTH) + if (_hash !== hash) { + throw new Error(`Invalid checksum for SerializedAssetValue. Expected ${_hash}.`) + } + this.state = { + assetId: parsedState.a, + precision: parsedState.p, + value: parsedState.v, + } + } + + private assertIsSameAsset(asset: AssetValue, op: string) { + if (!(asset.assetId === this.assetId && asset.precision === this.precision)) { + throw new Error( + `Cannot ${op} assets of different type (${asset.assetId} and ${this.assetId})`, + ) + } + } +} + +export class AV extends AssetValue {} diff --git a/packages/asset-value/src/constants.ts b/packages/asset-value/src/constants.ts new file mode 100644 index 000000000..a3012b4a9 --- /dev/null +++ b/packages/asset-value/src/constants.ts @@ -0,0 +1,2 @@ +export const CHECKSUM_LENGTH = 8 +export const DEFAULT_FORMAT_DECIMALS = 6 diff --git a/packages/asset-value/src/index.ts b/packages/asset-value/src/index.ts new file mode 100644 index 000000000..2ae936d1c --- /dev/null +++ b/packages/asset-value/src/index.ts @@ -0,0 +1,2 @@ +export * from './assetValue' +export * from './types' diff --git a/packages/asset-value/src/types.ts b/packages/asset-value/src/types.ts new file mode 100644 index 000000000..867dd056d --- /dev/null +++ b/packages/asset-value/src/types.ts @@ -0,0 +1,38 @@ +import { Asset } from '@shapeshiftoss/asset-service' +import { AssetId } from '@shapeshiftoss/caip' + +export enum AssetValueFormat { + BASE_UNIT = 1, + PRECISION = 2, +} + +export type AssetValueParams = { + value: string | number + format: AssetValueFormat +} & ( + | { + asset: Asset + assetId?: never + precision?: never + } + | { + asset?: never + assetId: AssetId + precision: number + } +) + +export type SerializedAssetValue = string + +export const isAssetValueParams = ( + params: AssetValueParams | SerializedAssetValue +): params is AssetValueParams => { + const _params = params as AssetValueParams + if ( + (_params && _params.asset !== undefined) || + (_params && _params.assetId !== undefined && _params.precision !== undefined) + ) { + return true + } + return false +} diff --git a/packages/asset-value/src/utils.ts b/packages/asset-value/src/utils.ts new file mode 100644 index 000000000..8bfe342ef --- /dev/null +++ b/packages/asset-value/src/utils.ts @@ -0,0 +1,12 @@ +import BigNumber from 'bignumber.js' + +export * from 'bignumber.js' + +export type BN = BigNumber + +export const bn = (n: BigNumber.Value, base = 10): BN => new BigNumber(n, base) + +export const bnOrZero = (n: BigNumber.Value | null | undefined): BN => { + const value = bn(n || 0) + return value.isFinite() ? value : bn(0) +} diff --git a/packages/asset-value/tsconfig.build.json b/packages/asset-value/tsconfig.build.json new file mode 100644 index 000000000..48f6cf962 --- /dev/null +++ b/packages/asset-value/tsconfig.build.json @@ -0,0 +1,14 @@ +{ + "extends": "../../tsconfig.build.json", + "compilerOptions": { + "composite": true, + "outDir": "./dist", + "rootDir": "src", + }, + "include": ["src/**/*", "src/**/*.json"], + "exclude": ["node_modules", "**/*.test.ts", "**/*.spec.ts"], + "references": [ + { "path": "../caip/tsconfig.build.json" }, + { "path": "../types/tsconfig.build.json" }, + ], +} diff --git a/packages/asset-value/tsconfig.json b/packages/asset-value/tsconfig.json new file mode 100644 index 000000000..c182faaca --- /dev/null +++ b/packages/asset-value/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "composite": true, + }, + "include": ["src/**/*", "src/**/*.json"], +} diff --git a/tsconfig.json b/tsconfig.json index a970799b9..3ff349311 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,6 +5,7 @@ // Configure imports and their directory resolution "paths": { "@shapeshiftoss/asset-service/*": ["packages/asset-service"], + "@shapeshiftoss/asset-value/*": ["packages/asset-value"], "@shapeshiftoss/caip/*": ["packages/caip"], "@shapeshiftoss/chain-adapters/*": ["packages/chain-adapters"], "@shapeshiftoss/investory-foxy/*": ["packages/investory-foxy"], diff --git a/yarn.lock b/yarn.lock index c4179c4a6..8e3925daf 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4293,6 +4293,11 @@ bignumber.js@^9.0.0, bignumber.js@^9.0.1, bignumber.js@^9.0.2: resolved "https://registry.yarnpkg.com/bignumber.js/-/bignumber.js-9.0.2.tgz#71c6c6bed38de64e24a65ebe16cfcf23ae693673" integrity sha512-GAcQvbpsM0pUb0zw1EI0KhQEZ+lRwR5fYaAp3vPOYuP7aDvGy6cVN6XHLauvF8SOga2y0dcLcjt3iQDTSEliyw== +bignumber.js@^9.1.1: + version "9.1.1" + resolved "https://registry.yarnpkg.com/bignumber.js/-/bignumber.js-9.1.1.tgz#c4df7dc496bd849d4c9464344c1aa74228b4dac6" + integrity sha512-pHm4LsMJ6lzgNGVfZHjMoO8sdoRhOzOH4MLmY65Jg70bpxCKu5iOHNJyfF6OyvYw7t8Fpf35RuzUyqnQsj8Vig== + bin-links@^2.2.1: version "2.3.0" resolved "https://registry.yarnpkg.com/bin-links/-/bin-links-2.3.0.tgz#1ff241c86d2c29b24ae52f49544db5d78a4eb967" @@ -13038,6 +13043,11 @@ ts-jest@^27.0.5: semver "7.x" yargs-parser "20.x" +ts-md5@^1.3.1: + version "1.3.1" + resolved "https://registry.yarnpkg.com/ts-md5/-/ts-md5-1.3.1.tgz#f5b860c0d5241dd9bb4e909dd73991166403f511" + integrity sha512-DiwiXfwvcTeZ5wCE0z+2A9EseZsztaiZtGrtSaY5JOD7ekPnR/GoIVD5gXZAlK9Na9Kvpo9Waz5rW64WKAWApg== + ts-node@^10.2.1: version "10.7.0" resolved "https://registry.yarnpkg.com/ts-node/-/ts-node-10.7.0.tgz#35d503d0fab3e2baa672a0e94f4b40653c2463f5" From 8dbb978644ce20a32f24fe7c17a4b64664ff9006 Mon Sep 17 00:00:00 2001 From: pastaghost Date: Wed, 22 Feb 2023 02:05:24 -0700 Subject: [PATCH 2/5] doc: update readme --- packages/asset-value/README.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/packages/asset-value/README.md b/packages/asset-value/README.md index 38c7d419d..ea00432f3 100644 --- a/packages/asset-value/README.md +++ b/packages/asset-value/README.md @@ -56,7 +56,11 @@ With AssetValue instances, this is much simpler. Just call the `toPrecision()` f ```typescript import { AssetValue } from '@shapeshiftoss/asset-value' +// Return a string formatted to the native asset precision or DEFAULT_FORMAT_DECIMALS, whichever is lower. const underlyingAsset0Amount: string = asset0Amount.toPrecision() + +// Return a string formatted to six decimal places or DEFAULT_FORMAT_DECIMALS, whichever is lower. +const underlyingAsset0Amount: string = asset0Amount.toPrecision(6) ``` Likewise, for a string contining the numerical value in baseunit representation, call the `toBaseUnit()` formatting method of the AssetValue. @@ -67,6 +71,16 @@ import { AssetValue } from '@shapeshiftoss/asset-value' const underlyingAsset0Amount: string = asset0Amount.toBaseUnit() ``` +In cases where some formatting other than what is available through either `toPrecision()` or `toBaseUnit()` is needed, `toFixed()` is available and functions exactly like the [corresponding method](https://mikemcl.github.io/bignumber.js/#toFix) from [bignumber.js](https://mikemcl.github.io/bignumber.js). + +```typescript +import { AssetValue } from '@shapeshiftoss/asset-value' +import BigNumber from 'bignumber.js' + +const amountTo18DecimalPlaces: string = asset0Amount.toFixed(18, BigNumber.ROUND_DOWN) +``` + +
### Math The AssetValue class keeps track of the appropriate precision for the value based on the asset used to initialize the instance. This way, you don't need to keep track of which set of units the value is in or what the correct precision for the asset is. In fact, there is no longer any concept of precision or baseunit representations for values. You can perform arbitrary arithmetic operations on AssetValues as needed and then call whichever formatting method is appropriate when you need a string representation of the value. @@ -87,6 +101,7 @@ const product: AssetValue = av1.multipliedBy('100') const percent: AssetValue = av1.dividedBy(100) ``` +
### Redux For Redux-compatibility, AssetValues are serializable. From 9500908cb68781e4d602b2ef86e2cb5a32d19e5c Mon Sep 17 00:00:00 2001 From: pastaghost Date: Wed, 22 Feb 2023 02:07:52 -0700 Subject: [PATCH 3/5] doc: update readme --- packages/asset-value/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/asset-value/README.md b/packages/asset-value/README.md index ea00432f3..bb6e53a37 100644 --- a/packages/asset-value/README.md +++ b/packages/asset-value/README.md @@ -123,7 +123,7 @@ export const selectAccountBalancesByAccountId(state, id: AccountId){ const balances = state.portfolio.accountBalances.ById[id] return Object.entries(balances).map((assetId: AssetId, balance: string) => { const asset = state.assets[id] - return new AssetValue(asset, value: balance, format: AssetValueFormat.BASE_UNIT).toSerialized() + return (new AssetValue(asset, value: balance, format: AssetValueFormat.BASE_UNIT)).toSerialized() }) } From 34fa5269fa4b26ef3d5bcf78ff085a7e11913542 Mon Sep 17 00:00:00 2001 From: pastaghost Date: Wed, 22 Feb 2023 02:10:42 -0700 Subject: [PATCH 4/5] doc: update readme --- packages/asset-value/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/asset-value/README.md b/packages/asset-value/README.md index bb6e53a37..7e0692532 100644 --- a/packages/asset-value/README.md +++ b/packages/asset-value/README.md @@ -129,7 +129,7 @@ export const selectAccountBalancesByAccountId(state, id: AccountId){ // Component.tsx const serializedBalanceData = useAppSelector(state => selectAccountBalancesByAccountId(state, accountId)) -const doubledBalances: AssetValue[] = serializedBalanceData.map(data: SerializedAssetValue => { +const doubledBalances: AssetValue[] = serializedBalanceData.map((data: SerializedAssetValue) => { return (new AssetValue(data)).multipliedBy(2) }) From 5854a3327bb64e530eec522f90842ec6f6169b32 Mon Sep 17 00:00:00 2001 From: pastaghost Date: Wed, 22 Feb 2023 08:09:42 -0700 Subject: [PATCH 5/5] chore: run linter --- packages/asset-value/src/types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/asset-value/src/types.ts b/packages/asset-value/src/types.ts index 867dd056d..291063cfa 100644 --- a/packages/asset-value/src/types.ts +++ b/packages/asset-value/src/types.ts @@ -25,7 +25,7 @@ export type AssetValueParams = { export type SerializedAssetValue = string export const isAssetValueParams = ( - params: AssetValueParams | SerializedAssetValue + params: AssetValueParams | SerializedAssetValue, ): params is AssetValueParams => { const _params = params as AssetValueParams if (