diff --git a/.babelrc b/.babelrc new file mode 100644 index 00000000..ea0a7281 --- /dev/null +++ b/.babelrc @@ -0,0 +1,7 @@ +{ + "presets": [ + "@babel/preset-env", + "@babel/preset-typescript", + "next/babel" + ] +} \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e6fc541b..06d6385d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,12 +2,12 @@ name: Corehub CI on: push: - branches: [main] + branches: [master] pull_request: - branches: [main] + branches: [master] jobs: - build: + build-and-test: runs-on: ubuntu-latest steps: @@ -31,8 +31,6 @@ jobs: - name: Check Prettier run: npm run format - all: - needs: [build] - runs-on: ubuntu-latest - steps: - - run: echo Success + # Run tests + - name: Run Tests + run: npm run test diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 00000000..36b9a78f --- /dev/null +++ b/jest.config.js @@ -0,0 +1,13 @@ +module.exports = { + transform: { + '^.+\\.(ts|tsx)$': 'ts-jest', + '^.+\\.(js|jsx)$': 'babel-jest', + }, + moduleNameMapper: { + // If you're using absolute imports or path aliases, set them up here + }, + testEnvironment: 'jsdom', // or "node", depending on your project needs + moduleNameMapper: { + '^@/(.*)$': '/src/$1', // Ensure this matches the structure defined in tsconfig.json + }, +}; diff --git a/package.json b/package.json index 8998a2cc..ef0f1db7 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,8 @@ "start": "next start", "build": "next build", "lint": "next lint", - "format": "npx prettier --write '**/*.{js,jsx,ts,tsx}'" + "format": "npx prettier --write '**/*.{js,jsx,ts,tsx}'", + "test": "jest" }, "dependencies": { "@date-io/date-fns": "^3.0.0", @@ -16,7 +17,7 @@ "@emotion/styled": "^11.6.0", "@mui/icons-material": "^5.2.5", "@mui/lab": "5.0.0-alpha.134", - "@mui/material": "^5.2.8", + "@mui/material": "^5.15.14", "@mui/x-date-pickers": "^6.19.5", "@polkadot/api": "^10.8.1", "@polkadot/api-contract": "^10.8.1", @@ -40,11 +41,15 @@ }, "devDependencies": { "@babel/core": "^7.16.7", + "@babel/preset-env": "^7.24.3", + "@babel/preset-typescript": "^7.24.1", + "@types/jest": "^29.5.12", "@types/node": "^18.16.1", "@types/react": "^18.2.0", "@types/react-dom": "^18.2.1", "@typescript-eslint/eslint-plugin": "^5.59.1", "@typescript-eslint/parser": "^5.59.1", + "babel-jest": "^29.7.0", "babel-loader": "^9.1.2", "eslint": "^8.39.0", "eslint-config-next": "^13.3.1", @@ -52,6 +57,9 @@ "eslint-plugin-simple-import-sort": "^7.0.0", "eslint-plugin-storybook": "^0.5.5", "eslint-plugin-unused-imports": "^2.0.0", + "jest": "^29.7.0", + "jest-environment-jsdom": "^29.7.0", + "ts-jest": "^29.1.2", "typescript": "^4.7.4", "webpack": "^5.81.0" } diff --git a/src/components/Modals/Interlace/index.tsx b/src/components/Modals/Interlace/index.tsx index 67c85428..ee2e2fbc 100644 --- a/src/components/Modals/Interlace/index.tsx +++ b/src/components/Modals/Interlace/index.tsx @@ -45,20 +45,16 @@ export const InterlaceModal = ({ const { fetchRegions } = useRegions(); const currentMask = regionMetadata.region.getMask().toBin(); + // Represents the first active bit in the bitmap. const oneStart = currentMask.indexOf('1'); + // Represents the last active bit in the bitmap. const oneEnd = currentMask.lastIndexOf('1'); const activeBits = oneEnd - oneStart + 1; const [working, setWorking] = useState(false); const [position, setPosition] = useState(oneStart); - const generateMask = (position: number): string => { - const mask = Array(COREMASK_BIT_LEN).fill('0'); - for (let i = oneStart; i <= position; ++i) mask[i] = '1'; - return mask.join(''); - }; - - const newMask = generateMask(position); + const newMask = CoreMask.fromChunk(oneStart, position + 1).toBin(); const onInterlace = async () => { if (!api || !activeAccount || !activeSigner) return; diff --git a/src/components/Sidebar/index.tsx b/src/components/Sidebar/index.tsx index 960212c0..0aaf53fe 100644 --- a/src/components/Sidebar/index.tsx +++ b/src/components/Sidebar/index.tsx @@ -92,7 +92,7 @@ export const Sidebar = () => { { label: 'Explore the Market', route: '/marketplace', - enabled: true, + enabled: false, icon: , }, ], diff --git a/src/models/consts.ts b/src/models/consts.ts index 570b8b48..4994bde9 100644 --- a/src/models/consts.ts +++ b/src/models/consts.ts @@ -8,7 +8,10 @@ export const RELAY_CHAIN_BLOCK_TIME = 6 * SECOND; export const CORETIME_DECIMALS = 12; export const CONTRACT_DECIMALS = 18; -export const LISTING_DEPOSIT = 0 * CONTRACT_DECIMALS; + +export const CORETIME_TOKEN_UNIT = Math.pow(10, CORETIME_DECIMALS); +export const CONTRACTS_TOKEN_UNIT = Math.pow(10, CONTRACT_DECIMALS); +export const LISTING_DEPOSIT = 0 * CONTRACTS_TOKEN_UNIT; /// Given that a timeslice is 8 minutes; export const DAY_IN_TIMESLICES = 180; diff --git a/src/pages/purchase.tsx b/src/pages/purchase.tsx index 79278d32..ab930614 100644 --- a/src/pages/purchase.tsx +++ b/src/pages/purchase.tsx @@ -11,7 +11,6 @@ import { useCallback, useEffect, useState } from 'react'; import { formatBalance, getBlockTimestamp, - leadinFactorAt, parseHNString, } from '@/utils/functions'; @@ -24,6 +23,14 @@ import { useSaleInfo } from '@/contexts/sales'; import { useToast } from '@/contexts/toast'; import { SalePhase } from '@/models'; +import { + getCurrentPhase, + getCurrentPrice, + getSaleEndInBlocks, + getSaleProgress, + getSaleStartInBlocks, +} from '../utils/sale/utils'; + const Purchase = () => { const theme = useTheme(); @@ -79,9 +86,13 @@ const Purchase = () => { (await api.query.broker.status()).toHuman() as any ).lastCommittedTimeslice.toString() ); - const _saleStart = saleInfo.saleStart; - const _saleEnd = - blockNumber + 80 * (saleInfo.regionBegin - lastCommittedTimeslice); + + const _saleStart = getSaleStartInBlocks(saleInfo, config); + const _saleEnd = getSaleEndInBlocks( + saleInfo, + blockNumber, + lastCommittedTimeslice + ); setCurrentBlockNumber(blockNumber); setSaleEnd(_saleEnd); @@ -89,19 +100,17 @@ const Purchase = () => { setSaleEndTimestamp(value) ); - const saleDuration = _saleEnd - _saleStart; - const elapsed = blockNumber - _saleStart; + const progress = getSaleProgress( + saleInfo, + config, + blockNumber, + lastCommittedTimeslice + ); + setProgress(progress); - const progress = elapsed / saleDuration; - setProgress(progress * 100); + setCurrentPhase(getCurrentPhase(saleInfo, blockNumber)); - if (saleInfo.saleStart > blockNumber) { - setCurrentPhase(SalePhase.Interlude); - } else if (saleInfo.saleStart + saleInfo.leadinLength > blockNumber) { - setCurrentPhase(SalePhase.Leadin); - } else { - setCurrentPhase(SalePhase.Regular); - } + const saleDuration = _saleEnd - _saleStart; setSaleSections([ { name: 'Interlude', value: 0 }, @@ -124,14 +133,8 @@ const Purchase = () => { async (api: ApiPromise) => { const blockNumber = (await api.query.system.number()).toJSON() as number; - const num = Math.min( - blockNumber - saleInfo.saleStart, - saleInfo.leadinLength - ); - const through = num / saleInfo.leadinLength; - setCurrentPrice( - Number((leadinFactorAt(through) * saleInfo.price).toFixed()) - ); + const price = getCurrentPrice(saleInfo, blockNumber); + setCurrentPrice(price); }, [saleInfo] ); diff --git a/src/utils/functions.test.ts b/src/utils/functions.test.ts new file mode 100644 index 00000000..53dbf63e --- /dev/null +++ b/src/utils/functions.test.ts @@ -0,0 +1,41 @@ +import { CoreMask, RegionId } from 'coretime-utils'; + +import { + extractRegionIdFromRaw, + parseHNString, + parseHNStringToString, +} from './functions'; + +describe('Util functions', () => { + describe('parseHNString', () => { + it('works', () => { + const a = '100,305'; + expect(parseHNString(a)).toBe(100305); + const b = '42'; + expect(parseHNString(b)).toBe(42); + expect(parseHNString('')).toBe(NaN); + }); + }); + + describe('parseHNStringToString', () => { + it('works', () => { + const a = '100,305'; + expect(parseHNStringToString(a)).toBe('100305'); + const b = '42'; + expect(parseHNStringToString(b)).toBe('42'); + expect(parseHNStringToString('')).toBe(''); + }); + }); + + describe('extractRegionIdFromRaw', () => { + it('works', () => { + const raw = '316913858982876965003350507519'; + const regionId: RegionId = { + begin: 4, + core: 0, + mask: CoreMask.completeMask(), + }; + expect(extractRegionIdFromRaw(BigInt(raw))).toStrictEqual(regionId); + }); + }); +}); diff --git a/src/utils/functions.ts b/src/utils/functions.ts index bb135ea6..ae51b442 100644 --- a/src/utils/functions.ts +++ b/src/utils/functions.ts @@ -1,4 +1,5 @@ import { ApiPromise } from '@polkadot/api'; +import { formatBalance as polkadotFormatBalance } from '@polkadot/util'; import { CoreMask, RegionId } from 'coretime-utils'; import Decimal from 'decimal.js'; @@ -82,18 +83,16 @@ export const formatBalance = (balance: string, contractChain: boolean) => { Decimal.config({ rounding: Decimal.ROUND_DOWN }); const decimals = contractChain ? CONTRACT_DECIMALS : CORETIME_DECIMALS; - if (new Decimal(balance).lt(new Decimal(10).pow(CONTRACT_DECIMALS))) { - return new Decimal(balance) - .dividedBy(new Decimal(10).pow(decimals)) - .toPrecision(2); - } else { - return new Decimal(balance) - .dividedBy(new Decimal(10).pow(decimals)) - .toFixed(2); - } + return polkadotFormatBalance(balance, { + decimals, + withUnit: false, + withSiFull: true, + }); }; // TODO: should be queried from runtime api instead. +// +// https://github.com/paritytech/polkadot-sdk/pull/3485 export const leadinFactorAt = (when: number) => { return 2 - when; }; diff --git a/src/utils/sale/utils.test.ts b/src/utils/sale/utils.test.ts new file mode 100644 index 00000000..1c256c60 --- /dev/null +++ b/src/utils/sale/utils.test.ts @@ -0,0 +1,142 @@ +import { CORETIME_TOKEN_UNIT, SaleConfig, SaleInfo, SalePhase } from '@/models'; + +import { + getCurrentPhase, + getCurrentPrice, + getSaleEndInBlocks, + getSaleProgress, + getSaleStartInBlocks, +} from './utils'; + +describe('Purchase page', () => { + const mockSaleInfo: SaleInfo = { + coresOffered: 50, + coresSold: 0, + firstCore: 45, + idealCoresSold: 5, + leadinLength: 21600, // Block number + price: 50 * CORETIME_TOKEN_UNIT, + regionBegin: 124170, // Timeslice + regionEnd: 125430, // Timeslice + saleStart: 1001148, // Block number + selloutPrice: null, + }; + + const mockConfig: SaleConfig = { + advanceNotice: 10, // RC block number + contributionTimeout: 1260, // Block number + idealBulkProportion: '40.00%', + interludeLength: 7200, // Block number + leadinLength: 21600, // Block number + limitCoresOffered: null, + regionLength: 1260, // Timeslice + renewalBump: '0.35%', + }; + + describe('getCurrentPhase', () => { + it('Successfully recognizes interlude phase', () => { + let blockNumber = mockSaleInfo.saleStart - 50; + expect(getCurrentPhase(mockSaleInfo, blockNumber)).toBe( + SalePhase.Interlude + ); + + blockNumber = mockSaleInfo.saleStart - 1; + expect(getCurrentPhase(mockSaleInfo, blockNumber)).toBe( + SalePhase.Interlude + ); + + blockNumber = mockSaleInfo.saleStart; + expect(getCurrentPhase(mockSaleInfo, blockNumber)).not.toBe( + SalePhase.Interlude + ); + }); + + it('Successfully recognizes leadin phase', () => { + let blockNumber = mockSaleInfo.saleStart; + expect(getCurrentPhase(mockSaleInfo, blockNumber)).toBe(SalePhase.Leadin); + + blockNumber = mockSaleInfo.saleStart + mockSaleInfo.leadinLength - 1; + expect(getCurrentPhase(mockSaleInfo, blockNumber)).toBe(SalePhase.Leadin); + + blockNumber = mockSaleInfo.saleStart + mockSaleInfo.leadinLength; + expect(getCurrentPhase(mockSaleInfo, blockNumber)).not.toBe( + SalePhase.Leadin + ); + }); + + it('Successfully recognizes fixed price phase', () => { + let blockNumber = mockSaleInfo.saleStart + mockSaleInfo.leadinLength; + expect(getCurrentPhase(mockSaleInfo, blockNumber)).toBe( + SalePhase.Regular + ); + + blockNumber = mockSaleInfo.saleStart + mockSaleInfo.leadinLength + 50; + expect(getCurrentPhase(mockSaleInfo, blockNumber)).toBe( + SalePhase.Regular + ); + }); + }); + + describe('getSaleStartInBlocks', () => { + it('works', () => { + expect(getSaleStartInBlocks(mockSaleInfo, mockConfig)).toBe( + mockSaleInfo.saleStart - mockConfig.interludeLength + ); + }); + }); + + describe('getSaleEndInBlocks', () => { + it('works', () => { + const blockNumber = mockSaleInfo.saleStart; + const rcBlockNumber = 9_832_800; + const lastCommittedTimeslice = Math.floor( + (rcBlockNumber + mockConfig.advanceNotice) / 80 + ); + + expect( + getSaleEndInBlocks(mockSaleInfo, blockNumber, lastCommittedTimeslice) + ).toBe(mockSaleInfo.saleStart + mockConfig.regionLength * 80); + }); + }); + + describe('getSaleProgress', () => { + it('works', () => { + let blockNumber = mockSaleInfo.saleStart - mockConfig.interludeLength; + const rcBlockNumber = 9_832_800; + const lastCommittedTimeslice = Math.floor( + (rcBlockNumber + mockConfig.advanceNotice) / 80 + ); + + expect( + getSaleProgress( + mockSaleInfo, + mockConfig, + blockNumber, + lastCommittedTimeslice + ) + ).toBe(0); + + blockNumber = mockSaleInfo.saleStart - mockConfig.interludeLength + 500; + + expect( + getSaleProgress( + mockSaleInfo, + mockConfig, + blockNumber, + lastCommittedTimeslice + ) + ).toBe(0.49); // 0.49 % + }); + }); + + describe('getCurrentPrice', () => { + it('works', () => { + const blockNumber = mockSaleInfo.saleStart; + + // leading factor is equal to 2 at the start of the sale. + expect(getCurrentPrice(mockSaleInfo, blockNumber)).toBe( + mockSaleInfo.price * 2 + ); + }); + }); +}); diff --git a/src/utils/sale/utils.ts b/src/utils/sale/utils.ts new file mode 100644 index 00000000..3480d3a4 --- /dev/null +++ b/src/utils/sale/utils.ts @@ -0,0 +1,60 @@ +import { Timeslice } from 'coretime-utils'; + +import { leadinFactorAt } from '@/utils/functions'; + +import { BlockNumber, SaleConfig, SaleInfo, SalePhase } from '@/models'; + +export const getCurrentPhase = ( + saleInfo: SaleInfo, + blockNumber: number +): SalePhase => { + if (saleInfo.saleStart > blockNumber) { + return SalePhase.Interlude; + } else if (saleInfo.saleStart + saleInfo.leadinLength > blockNumber) { + return SalePhase.Leadin; + } else { + return SalePhase.Regular; + } +}; + +export const getSaleStartInBlocks = ( + saleInfo: SaleInfo, + config: SaleConfig +) => { + // `saleInfo.saleStart` defines the start of the leadin phase. + // However, we want to account for the interlude period as well. + return saleInfo.saleStart - config.interludeLength; +}; + +export const getSaleEndInBlocks = ( + saleInfo: SaleInfo, + blockNumber: BlockNumber, + lastCommittedTimeslice: Timeslice +) => { + const timeslicesUntilSaleEnd = saleInfo.regionBegin - lastCommittedTimeslice; + return blockNumber + 80 * timeslicesUntilSaleEnd; +}; + +// Returns a range between 0 and 100. +export const getSaleProgress = ( + saleInfo: SaleInfo, + config: SaleConfig, + blockNumber: number, + lastCommittedTimeslice: number +): number => { + const start = getSaleStartInBlocks(saleInfo, config); + const end = getSaleEndInBlocks(saleInfo, blockNumber, lastCommittedTimeslice); + + const saleDuration = end - start; + const elapsed = blockNumber - start; + + const progress = elapsed / saleDuration; + return Number((progress * 100).toFixed(2)); +}; + +export const getCurrentPrice = (saleInfo: SaleInfo, blockNumber: number) => { + const num = Math.min(blockNumber - saleInfo.saleStart, saleInfo.leadinLength); + const through = num / saleInfo.leadinLength; + + return Number((leadinFactorAt(through) * saleInfo.price).toFixed()); +}; diff --git a/tsconfig.json b/tsconfig.json index e64da086..0ae27ecc 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,7 +1,11 @@ { "compilerOptions": { "target": "es5", - "lib": ["dom", "dom.iterable", "esnext"], + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], "allowJs": true, "skipLibCheck": true, "strict": true, @@ -15,11 +19,21 @@ "jsx": "preserve", "baseUrl": ".", "paths": { - "@/*": ["./src/*"], - "~/*": ["./public/*"] + "@/*": [ + "./src/*" + ], + "~/*": [ + "./public/*" + ] }, "incremental": true }, - "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], - "exclude": ["node_modules"] -} + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx" + ], + "exclude": [ + "node_modules" + ] +} \ No newline at end of file