From 2ff5ecca68c91b6a6d03d376d749a5d6b46751c8 Mon Sep 17 00:00:00 2001 From: Lukas Date: Thu, 2 Nov 2023 15:11:41 +0100 Subject: [PATCH] fix: better number formatting (#25) * fix: better number formatting * fix: improve generator * fix: uncomment test * Update src/20231029_AaveV3Ethereum_ACIPhaseII/ACIPhaseII_20231029.s.sol * fix: add advanced input * fix: add correct error --- .../__snapshots__/assetListing.spec.ts.snap | 2 +- generator/features/mocks/configs.ts | 2 +- generator/prompts.spec.ts | 80 ++++++++++++++++ generator/prompts.ts | 80 ++++++++++------ generator/prompts/advancedInput.ts | 96 +++++++++++++++++++ package.json | 2 + .../ACIPhaseII_20231029.s.sol | 1 - yarn.lock | 24 +++++ 8 files changed, 253 insertions(+), 34 deletions(-) create mode 100644 generator/prompts.spec.ts create mode 100644 generator/prompts/advancedInput.ts diff --git a/generator/features/__snapshots__/assetListing.spec.ts.snap b/generator/features/__snapshots__/assetListing.spec.ts.snap index 2573fb620..fa297e7e4 100644 --- a/generator/features/__snapshots__/assetListing.spec.ts.snap +++ b/generator/features/__snapshots__/assetListing.spec.ts.snap @@ -167,7 +167,7 @@ contract AaveV3Ethereum_Test_20231023_Test is ProtocolV3TestBase { AaveV3Ethereum_Test_20231023 internal proposal; function setUp() public { - vm.createSelectFork(vm.rpcUrl('mainnet'), 18480118); + vm.createSelectFork(vm.rpcUrl('mainnet'), 18484119); proposal = new AaveV3Ethereum_Test_20231023(); } diff --git a/generator/features/mocks/configs.ts b/generator/features/mocks/configs.ts index 7af85354d..eb0f1ccce 100644 --- a/generator/features/mocks/configs.ts +++ b/generator/features/mocks/configs.ts @@ -1,5 +1,5 @@ import {Options} from '../../types'; -import {Listing} from '../types'; +import {CapsUpdate, Listing} from '../types'; export const MOCK_OPTIONS: Options = { pools: ['AaveV3Ethereum'], diff --git a/generator/prompts.spec.ts b/generator/prompts.spec.ts new file mode 100644 index 000000000..c6e97059c --- /dev/null +++ b/generator/prompts.spec.ts @@ -0,0 +1,80 @@ +import {expect, describe, it} from 'vitest'; +import {render} from '@inquirer/testing'; +import { + numberInput, + percentInput, + transformNumberToHumanReadable, + transformNumberToPercent, + translateJsNumberToSol, + translateJsPercentToSol, +} from './prompts'; + +describe('prompts', () => { + describe('numberInput', () => { + it('handles "yes"', async () => { + const {answer, events, getScreen} = await render(numberInput, { + message: 'Enter number?', + }); + + expect(getScreen()).toMatchInlineSnapshot('"? Enter number? (KEEP_CURRENT)"'); + + events.type('yes112.3'); + expect(getScreen()).toMatchInlineSnapshot('"? Enter number? 1,123"'); + + events.keypress('enter'); + await expect(answer).resolves.toEqual('1_123'); + }); + }); + + describe('percentInput', () => { + it('handles "yes"', async () => { + const {answer, events, getScreen} = await render(percentInput, { + message: 'Enter number?', + }); + + expect(getScreen()).toMatchInlineSnapshot('"? Enter number? (KEEP_CURRENT)"'); + + events.type('yes12.3'); + expect(getScreen()).toMatchInlineSnapshot('"? Enter number? 12.3 %"'); + + events.keypress('enter'); + await expect(answer).resolves.toEqual('12_30'); + }); + }); + /** + * Transformers are here to format the input based on a users input + * They do not change the users input value though, the effect is purely visual + */ + describe('transforms', () => { + it('transformNumberToHumanReadable: should return a human readable full number', () => { + expect(transformNumberToHumanReadable('1000')).toBe('1,000'); + expect(transformNumberToHumanReadable('1000000')).toBe('1,000,000'); + }); + + it('transformNumberToPercent: should return a human readable % number', () => { + expect(transformNumberToPercent('100')).toBe('100 %'); + expect(transformNumberToPercent('3333.33')).toBe('3,333.33 %'); + expect(transformNumberToPercent('0.33')).toBe('0.33 %'); + expect(transformNumberToPercent('0.3')).toBe('0.3 %'); + }); + }); + + /** + * Translates, translate the js input value to solidity + */ + describe('translate', () => { + it('translateJsNumberToSol: should properly translate values', () => { + expect(translateJsNumberToSol('0')).toBe('0'); + expect(translateJsNumberToSol('1000')).toBe('1_000'); + expect(translateJsNumberToSol('1000000')).toBe('1_000_000'); + }); + + it('translateJsPercentToSol: should properly translate % values', () => { + expect(translateJsPercentToSol('0')).toBe('0'); + expect(translateJsPercentToSol('100')).toBe('100_00'); + expect(translateJsPercentToSol('3333.33')).toBe('3_333_33'); + expect(translateJsPercentToSol('0.33')).toBe('33'); + expect(translateJsPercentToSol('0.3')).toBe('30'); + }); + }); +}); diff --git a/generator/prompts.ts b/generator/prompts.ts index c4c5de71a..129bed84c 100644 --- a/generator/prompts.ts +++ b/generator/prompts.ts @@ -2,6 +2,7 @@ import {checkbox, input, select} from '@inquirer/prompts'; import {ENGINE_FLAGS, PoolIdentifier} from './types'; import {getAssets, getEModes} from './common'; import {Hex, getAddress, isAddress} from 'viem'; +import {advancedInput} from './prompts/advancedInput'; // VALIDATION function isNumber(value: string) { @@ -19,29 +20,39 @@ function isAddressOrKeepCurrent(value: string) { } // TRANSFORMS -function transformNumberToPercent(value: string) { +export function transformNumberToPercent(value: string) { if (value && isNumber(value)) { - if (Number(value) <= 9) value = value.padStart(2, '0'); - return value.replace(/(?=(\d{2}$)+(?!\d))/g, '.') + ' %'; + return ( + new Intl.NumberFormat('en-us', { + maximumFractionDigits: 2, + }).format(value as unknown as number) + ' %' + ); } return value; } -function transformNumberToHumanReadable(value: string) { +export function transformNumberToHumanReadable(value: string) { if (value && isNumber(value)) { - return value.replace(/(?=(\d{3}$)+(?!\d))/g, '.'); + return new Intl.NumberFormat('en-us').format(BigInt(value)); } return value; } // TRANSLATIONS -function translateJsPercentToSol(value: string, bpsToRay?: boolean) { +export function translateJsPercentToSol(value: string, bpsToRay?: boolean) { if (value === ENGINE_FLAGS.KEEP_CURRENT) return `EngineFlags.KEEP_CURRENT`; - if (bpsToRay) return `_bpsToRay(${value.replace(/(?=(\d{2}$))/g, '_')})`; - return value.replace(/(?=(\d{2}$)+(?!\d))/g, '_'); -} - -function translateJsNumberToSol(value: string) { + const formattedValue = new Intl.NumberFormat('en-us', { + maximumFractionDigits: 2, + minimumFractionDigits: 2, + }).format(value as unknown as number); + const _value = ( + Number(value) >= 1 ? formattedValue : formattedValue.replace(/^0\.0*(?=[0-9])/, '') + ).replace(/[\.,]/g, '_'); + if (bpsToRay) return `_bpsToRay(${_value})`; + return _value; +} + +export function translateJsNumberToSol(value: string) { if (value === ENGINE_FLAGS.KEEP_CURRENT) return `EngineFlags.KEEP_CURRENT`; return String(value).replace(/\B(?=(\d{3})+(?!\d))/g, '_'); } @@ -118,31 +129,38 @@ interface PercentInputPrompt extends GenericPrompt { export type PercentInputValues = typeof ENGINE_FLAGS.KEEP_CURRENT | string; -export async function percentInput({ - message, - disableKeepCurrent, - toRay, -}: PercentInputPrompt): Promise< - T extends true ? PercentInputValues : Exclude -> { - const value = await input({ - message, - transformer: transformNumberToPercent, - validate: disableKeepCurrent ? isNumber : isNumberOrKeepCurrent, - ...(disableKeepCurrent ? {} : {default: ENGINE_FLAGS.KEEP_CURRENT}), - }); +export async function percentInput( + {message, disableKeepCurrent, toRay}: PercentInputPrompt, + opts +): Promise> { + const value = await advancedInput( + { + message, + transformer: transformNumberToPercent, + validate: disableKeepCurrent ? isNumber : isNumberOrKeepCurrent, + ...(disableKeepCurrent ? {} : {default: ENGINE_FLAGS.KEEP_CURRENT}), + pattern: /^[0-9]*\.?[0-9]*$/, + patternError: 'Only decimal numbers are allowed (e.g. 1.1)', + }, + opts + ); return translateJsPercentToSol(value, toRay); } export type NumberInputValues = typeof ENGINE_FLAGS.KEEP_CURRENT | string; -export async function numberInput({message, disableKeepCurrent}: GenericPrompt) { - const value = await input({ - message, - transformer: transformNumberToHumanReadable, - validate: disableKeepCurrent ? isNumber : isNumberOrKeepCurrent, - ...(disableKeepCurrent ? {} : {default: ENGINE_FLAGS.KEEP_CURRENT}), - }); +export async function numberInput({message, disableKeepCurrent}: GenericPrompt, opts) { + const value = await advancedInput( + { + message, + transformer: transformNumberToHumanReadable, + validate: disableKeepCurrent ? isNumber : isNumberOrKeepCurrent, + ...(disableKeepCurrent ? {} : {default: ENGINE_FLAGS.KEEP_CURRENT}), + pattern: /^[0-9]*$/, + patternError: 'Only full numbers are allowed', + }, + opts + ); return translateJsNumberToSol(value); } diff --git a/generator/prompts/advancedInput.ts b/generator/prompts/advancedInput.ts new file mode 100644 index 000000000..70b242508 --- /dev/null +++ b/generator/prompts/advancedInput.ts @@ -0,0 +1,96 @@ +import { + createPrompt, + useState, + useKeypress, + usePrefix, + isEnterKey, + isBackspaceKey, + type PromptConfig, +} from '@inquirer/core'; +import type {} from '@inquirer/type'; +import chalk from 'chalk'; + +type InputConfig = PromptConfig<{ + default?: string; + transformer?: (value: string, {isFinal}: {isFinal: boolean}) => string; + validate?: (value: string) => boolean | string | Promise; + pattern?: RegExp; + patternError?: string; +}>; + +/** + * It's a modified input prompt allowing to specify a pattern + * The input will simply discard any non conform input and show an error + */ +export const advancedInput = createPrompt((config, done) => { + const {validate = () => true, pattern, patternError} = config; + const [status, setStatus] = useState('pending'); + const [defaultValue = '', setDefaultValue] = useState(config.default); + const [errorMsg, setError] = useState(undefined); + const [value, setValue] = useState(''); + + const isLoading = status === 'loading'; + const prefix = usePrefix(isLoading); + + useKeypress(async (key, rl) => { + // Ignore keypress while our prompt is doing other processing. + if (status !== 'pending') { + return; + } + + if (!pattern || pattern?.test(rl.line)) { + if (isEnterKey(key)) { + const answer = value || defaultValue; + setStatus('loading'); + const isValid = await validate(answer); + if (isValid === true) { + setValue(answer); + setStatus('done'); + done(answer); + } else { + // Reset the readline line value to the previous value. On line event, the value + // get cleared, forcing the user to re-enter the value instead of fixing it. + rl.write(value); + setError(isValid || 'You must provide a valid value'); + setStatus('pending'); + } + } else if (isBackspaceKey(key) && !value) { + setDefaultValue(undefined); + } else if (key.name === 'tab' && !value) { + setDefaultValue(undefined); + rl.clearLine(0); // Remove the tab character. + rl.write(defaultValue); + setValue(defaultValue); + } else { + setValue(rl.line); + setError(undefined); + } + } else { + const line = rl.line; + rl.clearLine(0); + rl.write(line.slice(0, -1)); + setError(patternError); + } + }); + + const message = chalk.bold(config.message); + let formattedValue = value; + if (typeof config.transformer === 'function') { + formattedValue = config.transformer(value, {isFinal: status === 'done'}); + } + if (status === 'done') { + formattedValue = chalk.cyan(formattedValue); + } + + let defaultStr = ''; + if (defaultValue && status !== 'done' && !value) { + defaultStr = chalk.dim(` (${defaultValue})`); + } + + let error = ''; + if (errorMsg) { + error = chalk.red(`> ${errorMsg}`); + } + + return [`${prefix} ${message}${defaultStr} ${formattedValue}`, error]; +}); diff --git a/package.json b/package.json index 0bedd9b5c..2ad7b1232 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ }, "homepage": "https://github.com/bgd-labs/aave-proposals-v3#readme", "devDependencies": { + "@types/node": "^20.8.10", "prettier": "2.8.7", "prettier-plugin-solidity": "1.1.3", "vitest": "^0.34.6" @@ -32,6 +33,7 @@ "@bgd-labs/aave-address-book": "^2.8.0", "@bgd-labs/aave-cli": "0.0.27-0a01f2a07efe0ec4c875cf479004d20350235f64.0", "@inquirer/prompts": "^3.2.0", + "@inquirer/testing": "^2.1.8", "commander": "^11.0.0", "tsx": "^3.13.0", "viem": "^1.16.6" diff --git a/src/20231029_AaveV3Ethereum_ACIPhaseII/ACIPhaseII_20231029.s.sol b/src/20231029_AaveV3Ethereum_ACIPhaseII/ACIPhaseII_20231029.s.sol index 3248d6160..55e7016af 100644 --- a/src/20231029_AaveV3Ethereum_ACIPhaseII/ACIPhaseII_20231029.s.sol +++ b/src/20231029_AaveV3Ethereum_ACIPhaseII/ACIPhaseII_20231029.s.sol @@ -18,7 +18,6 @@ contract DeployEthereum is EthereumScript { IPayloadsControllerCore.ExecutionAction[] memory actions = new IPayloadsControllerCore.ExecutionAction[](1); actions[0] = GovV3Helpers.buildAction(address(payload0)); - // register action at payloadsController GovV3Helpers.createPayload(actions); } diff --git a/yarn.lock b/yarn.lock index e4535fd5f..b087ae6c7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -294,6 +294,18 @@ chalk "^4.1.2" figures "^3.2.0" +"@inquirer/testing@^2.1.8": + version "2.1.8" + resolved "https://registry.yarnpkg.com/@inquirer/testing/-/testing-2.1.8.tgz#809dffe6a4891100c54eee02ae99e3cd26b8425e" + integrity sha512-PPCiS5wN/MDjeJRQuMZa3cUDwsVmmB9wwbWNSNoC3ciPUwhX8fHdC0Um/gKMlF7EnaZaPa+AOrA8zLo/zJLjPw== + dependencies: + "@inquirer/type" "^1.1.5" + "@types/mute-stream" "^0.0.2" + "@types/node" "^20.8.2" + ansi-escapes "^4.3.2" + mute-stream "^1.0.0" + strip-ansi "^6.0.1" + "@inquirer/type@^1.1.5": version "1.1.5" resolved "https://registry.yarnpkg.com/@inquirer/type/-/type-1.1.5.tgz#b8c171f755859c8159b10e41e1e3a88f0ca99d7f" @@ -451,6 +463,13 @@ dependencies: undici-types "~5.25.1" +"@types/node@^20.8.10": + version "20.8.10" + resolved "https://registry.yarnpkg.com/@types/node/-/node-20.8.10.tgz#a5448b895c753ae929c26ce85cab557c6d4a365e" + integrity sha512-TlgT8JntpcbmKUFzjhsyhGfP2fsiz1Mv56im6enJ905xG1DAYesxJaeSbGqQmAw8OWPdhyJGhGSQGKRNJ45u9w== + dependencies: + undici-types "~5.26.4" + "@types/normalize-package-data@^2.4.0": version "2.4.2" resolved "https://registry.yarnpkg.com/@types/normalize-package-data/-/normalize-package-data-2.4.2.tgz#9b0e3e8533fe5024ad32d6637eb9589988b6fdca" @@ -1789,6 +1808,11 @@ undici-types@~5.25.1: resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-5.25.3.tgz#e044115914c85f0bcbb229f346ab739f064998c3" integrity sha512-Ga1jfYwRn7+cP9v8auvEXN1rX3sWqlayd4HP7OKk4mZWylEmu3KzXDUGrQUN6Ol7qo1gPvB2e5gX6udnyEPgdA== +undici-types@~5.26.4: + version "5.26.5" + resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-5.26.5.tgz#bcd539893d00b56e964fd2657a4866b221a65617" + integrity sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA== + util-deprecate@^1.0.1: version "1.0.2" resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf"