diff --git a/packages/core/solidity/CHANGELOG.md b/packages/core/solidity/CHANGELOG.md index 746dde13a..ba49d7ad9 100644 --- a/packages/core/solidity/CHANGELOG.md +++ b/packages/core/solidity/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## Unreleased + +- Add validation for ERC20 premint field. ([#488](https://github.com/OpenZeppelin/contracts-wizard/pull/488)) + ## 0.5.3 (2025-03-13) - Add ERC20 Cross-Chain Bridging, SuperchainERC20. ([#436](https://github.com/OpenZeppelin/contracts-wizard/pull/436)) diff --git a/packages/core/solidity/src/erc20.test.ts b/packages/core/solidity/src/erc20.test.ts index 14a2caa46..f609e42dc 100644 --- a/packages/core/solidity/src/erc20.test.ts +++ b/packages/core/solidity/src/erc20.test.ts @@ -69,6 +69,43 @@ testERC20('erc20 premint of 0', { premint: '0', }); +function testPremint(scenario: string, premint: string, expectedError?: string) { + test(`erc20 premint - ${scenario} - ${expectedError ? 'invalid' : 'valid'}`, async t => { + if (expectedError) { + const error = t.throws(() => + buildERC20({ + name: 'MyToken', + symbol: 'MTK', + premint, + }), + ); + t.is((error as OptionsError).messages.premint, expectedError); + } else { + const c = buildERC20({ + name: 'MyToken', + symbol: 'MTK', + premint, + }); + t.snapshot(printContract(c)); + } + }); +} + +testPremint('max literal', '115792089237316195423570985008687907853269984665640564039457.584007913129639935'); // 2^256 - 1, shifted by 18 decimals +testPremint( + 'max literal + 1', + '115792089237316195423570985008687907853269984665640564039457.584007913129639936', + 'Value is greater than uint256 max value', +); +testPremint('no arithmetic overflow', '115792089237316195423570985008687907853269984665640564039457'); // 2^256 - 1, truncated by 18 decimals +testPremint( + 'arithmetic overflow', + '115792089237316195423570985008687907853269984665640564039458', + 'Amount would overflow uint256 after applying decimals', +); +testPremint('e notation', '1e59'); +testPremint('e notation arithmetic overflow', '1e60', 'Amount would overflow uint256 after applying decimals'); + testERC20('erc20 mintable', { mintable: true, access: 'ownable', diff --git a/packages/core/solidity/src/erc20.test.ts.md b/packages/core/solidity/src/erc20.test.ts.md index 81a604339..ed14a684d 100644 --- a/packages/core/solidity/src/erc20.test.ts.md +++ b/packages/core/solidity/src/erc20.test.ts.md @@ -235,6 +235,69 @@ Generated by [AVA](https://avajs.dev). }␊ ` +## erc20 premint - max literal - valid + +> Snapshot 1 + + `// SPDX-License-Identifier: MIT␊ + // Compatible with OpenZeppelin Contracts ^5.0.0␊ + pragma solidity ^0.8.22;␊ + ␊ + import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";␊ + import {ERC20Permit} from "@openzeppelin/contracts/token/ERC20/extensions/ERC20Permit.sol";␊ + ␊ + contract MyToken is ERC20, ERC20Permit {␊ + constructor(address recipient)␊ + ERC20("MyToken", "MTK")␊ + ERC20Permit("MyToken")␊ + {␊ + _mint(recipient, 115792089237316195423570985008687907853269984665640564039457584007913129639935 * 10 ** (decimals() - 18));␊ + }␊ + }␊ + ` + +## erc20 premint - no arithmetic overflow - valid + +> Snapshot 1 + + `// SPDX-License-Identifier: MIT␊ + // Compatible with OpenZeppelin Contracts ^5.0.0␊ + pragma solidity ^0.8.22;␊ + ␊ + import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";␊ + import {ERC20Permit} from "@openzeppelin/contracts/token/ERC20/extensions/ERC20Permit.sol";␊ + ␊ + contract MyToken is ERC20, ERC20Permit {␊ + constructor(address recipient)␊ + ERC20("MyToken", "MTK")␊ + ERC20Permit("MyToken")␊ + {␊ + _mint(recipient, 115792089237316195423570985008687907853269984665640564039457 * 10 ** decimals());␊ + }␊ + }␊ + ` + +## erc20 premint - e notation - valid + +> Snapshot 1 + + `// SPDX-License-Identifier: MIT␊ + // Compatible with OpenZeppelin Contracts ^5.0.0␊ + pragma solidity ^0.8.22;␊ + ␊ + import {ERC20} from "@openzeppelin/contracts/token/ERC20/ERC20.sol";␊ + import {ERC20Permit} from "@openzeppelin/contracts/token/ERC20/extensions/ERC20Permit.sol";␊ + ␊ + contract MyToken is ERC20, ERC20Permit {␊ + constructor(address recipient)␊ + ERC20("MyToken", "MTK")␊ + ERC20Permit("MyToken")␊ + {␊ + _mint(recipient, 100000000000000000000000000000000000000000000000000000000000 * 10 ** decimals());␊ + }␊ + }␊ + ` + ## erc20 mintable > Snapshot 1 diff --git a/packages/core/solidity/src/erc20.test.ts.snap b/packages/core/solidity/src/erc20.test.ts.snap index c17d6fdaa..7619e4332 100644 Binary files a/packages/core/solidity/src/erc20.test.ts.snap and b/packages/core/solidity/src/erc20.test.ts.snap differ diff --git a/packages/core/solidity/src/erc20.ts b/packages/core/solidity/src/erc20.ts index df7246a76..450225479 100644 --- a/packages/core/solidity/src/erc20.ts +++ b/packages/core/solidity/src/erc20.ts @@ -13,6 +13,7 @@ import type { ClockMode } from './set-clock-mode'; import { clockModeDefault, setClockMode } from './set-clock-mode'; import { supportsInterface } from './common-functions'; import { OptionsError } from './error'; +import { toUint256, UINT256_MAX } from './utils/convert-strings'; export const crossChainBridgingOptions = [false, 'custom', 'superchain'] as const; export type CrossChainBridging = (typeof crossChainBridgingOptions)[number]; @@ -163,6 +164,14 @@ export function isValidChainId(str: string): boolean { return chainIdPattern.test(str); } +function scaleByPowerOfTen(base: bigint, exponent: number): bigint { + if (exponent < 0) { + return base / BigInt(10) ** BigInt(-exponent); + } else { + return base * BigInt(10) ** BigInt(exponent); + } +} + function addPremint( c: ContractBuilder, amount: string, @@ -181,6 +190,9 @@ function addPremint( const units = integer + decimals + zeroes; const exp = decimalPlace <= 0 ? 'decimals()' : `(decimals() - ${decimalPlace})`; + const validatedBaseUnits = toUint256(units, 'premint'); + checkPotentialPremintOverflow(validatedBaseUnits, decimalPlace); + c.addConstructorArgument({ type: 'address', name: 'recipient' }); const mintLine = `_mint(recipient, ${units} * 10 ** ${exp});`; @@ -212,6 +224,24 @@ function addPremint( } } +/** + * Check for potential premint overflow assuming the user's contract has decimals() = 18 + * + * @param baseUnits The base units of the token, before applying power of 10 + * @param decimalPlace If positive, the number of assumed decimal places in the least significant digits of `validatedBaseUnits`. Ignored if <= 0. + * @throws OptionsError if the calculated value would overflow uint256 + */ +function checkPotentialPremintOverflow(baseUnits: bigint, decimalPlace: number) { + const assumedExp = decimalPlace <= 0 ? 18 : 18 - decimalPlace; + const calculatedValue = scaleByPowerOfTen(baseUnits, assumedExp); + + if (calculatedValue > UINT256_MAX) { + throw new OptionsError({ + premint: 'Amount would overflow uint256 after applying decimals', + }); + } +} + function addMintable(c: ContractBuilder, access: Access) { requireAccessControl(c, functions.mint, access, 'MINTER', 'minter'); c.addFunctionCode('_mint(to, amount);', functions.mint); diff --git a/packages/core/solidity/src/utils/convert-strings.test.ts b/packages/core/solidity/src/utils/convert-strings.test.ts new file mode 100644 index 000000000..241589949 --- /dev/null +++ b/packages/core/solidity/src/utils/convert-strings.test.ts @@ -0,0 +1,23 @@ +import test from 'ava'; + +import { toUint256, UINT256_MAX } from './convert-strings'; +import { OptionsError } from '../error'; + +test('toUint256', t => { + t.is(toUint256('123', 'foo'), BigInt(123)); +}); + +test('toUint256 - not number', t => { + const error = t.throws(() => toUint256('abc', 'foo'), { instanceOf: OptionsError }); + t.is(error.messages.foo, 'Not a valid number'); +}); + +test('toUint256 - negative', t => { + const error = t.throws(() => toUint256('-1', 'foo'), { instanceOf: OptionsError }); + t.is(error.messages.foo, 'Not a valid number'); +}); + +test('toUint256 - too large', t => { + const error = t.throws(() => toUint256(String(UINT256_MAX + BigInt(1)), 'foo'), { instanceOf: OptionsError }); + t.is(error.messages.foo, 'Value is greater than uint256 max value'); +}); diff --git a/packages/core/solidity/src/utils/convert-strings.ts b/packages/core/solidity/src/utils/convert-strings.ts new file mode 100644 index 000000000..04d03329c --- /dev/null +++ b/packages/core/solidity/src/utils/convert-strings.ts @@ -0,0 +1,27 @@ +import { OptionsError } from '../error'; + +export const UINT256_MAX = BigInt(2) ** BigInt(256) - BigInt(1); + +/** + * Checks that a string is a valid `uint256` value and converts it to bigint. + * + * @param value The value to check. + * @param field The field name to use in the error if the value is invalid. + * @throws OptionsError if the value is not a valid number or is greater than the maximum value for `uint256`. + * @returns The value as a bigint. + */ +export function toUint256(value: string, field: string): bigint { + const isValidNumber = /^\d+$/.test(value); + if (!isValidNumber) { + throw new OptionsError({ + [field]: 'Not a valid number', + }); + } + const numValue = BigInt(value); + if (numValue > UINT256_MAX) { + throw new OptionsError({ + [field]: 'Value is greater than uint256 max value', + }); + } + return numValue; +}