diff --git a/.github/workflows/compile-cairo-alpha-project.yml b/.github/workflows/compile-cairo-alpha-project.yml index 76752d3d5..b409c92fd 100644 --- a/.github/workflows/compile-cairo-alpha-project.yml +++ b/.github/workflows/compile-cairo-alpha-project.yml @@ -56,6 +56,7 @@ jobs: declare -a all_access_options=("disabled" "ownable" "roles" "roles-dar-default" "roles-dar-custom") declare -a all_upgradeable_options=("true" "false") declare -a all_royalty_options=("disabled" "enabled-default" "enabled-custom") + declare -a all_flashmint_options=("disabled" "enabled-default" "enabled-percent-fee" "enabled-custom-fee") kind="$KIND" @@ -78,18 +79,38 @@ jobs: done done - elif [[ "$kind" == "ERC20" || "$kind" == "ERC6909" || "$kind" == "Custom" ]]; then + elif [[ "$kind" == "ERC20" ]]; then for access_option in "${all_access_options[@]}"; do - proj_name="'$kind, macros: $macros_option, access: $access_option' test project" - echo "Generating $proj_name..." - yarn run update_scarb_project --kind=$kind --macros=$macros_option --access=$access_option + for upgradeable_option in "${all_upgradeable_options[@]}"; do + for flashmint_option in "${all_flashmint_options[@]}"; do + proj_name="'$kind, macros: $macros_option, access: $access_option, upgradeable: $upgradeable_option, flashmint: $flashmint_option' test project" + echo "Generating $proj_name..." + yarn run update_scarb_project --kind=$kind --macros=$macros_option --access=$access_option --upgradeable=$upgradeable_option --flashmint=$flashmint_option + + echo "Compiling $proj_name..." + scarb clean + scarb build + + echo "✅ Compiled $proj_name!" + echo "---------------------------------" + done + done + done - echo "Compiling $proj_name..." - scarb clean - scarb build + elif [[ "$kind" == "ERC6909" || "$kind" == "Custom" ]]; then + for access_option in "${all_access_options[@]}"; do + for upgradeable_option in "${all_upgradeable_options[@]}"; do + proj_name="'$kind, macros: $macros_option, access: $access_option, upgradeable: $upgradeable_option' test project" + echo "Generating $proj_name..." + yarn run update_scarb_project --kind=$kind --macros=$macros_option --access=$access_option --upgradeable=$upgradeable_option - echo "✅ Compiled $proj_name!" - echo "---------------------------------" + echo "Compiling $proj_name..." + scarb clean + scarb build + + echo "✅ Compiled $proj_name!" + echo "---------------------------------" + done done else diff --git a/.github/workflows/compile-cairo-project.yml b/.github/workflows/compile-cairo-project.yml index d11da8142..9506c0317 100644 --- a/.github/workflows/compile-cairo-project.yml +++ b/.github/workflows/compile-cairo-project.yml @@ -56,6 +56,7 @@ jobs: declare -a all_access_options=("disabled" "ownable" "roles" "roles-dar-default" "roles-dar-custom") declare -a all_upgradeable_options=("true" "false") declare -a all_royalty_options=("disabled" "enabled-default" "enabled-custom") + declare -a all_flashmint_options=("disabled" "enabled-default" "enabled-percent-fee" "enabled-custom-fee") kind="$KIND" @@ -78,18 +79,38 @@ jobs: done done - elif [[ "$kind" == "ERC20" || "$kind" == "ERC6909" || "$kind" == "Custom" ]]; then + elif [[ "$kind" == "ERC20" ]]; then for access_option in "${all_access_options[@]}"; do - proj_name="'$kind, macros: $macros_option, access: $access_option' test project" - echo "Generating $proj_name..." - yarn run update_scarb_project --kind=$kind --macros=$macros_option --access=$access_option + for upgradeable_option in "${all_upgradeable_options[@]}"; do + for flashmint_option in "${all_flashmint_options[@]}"; do + proj_name="'$kind, macros: $macros_option, access: $access_option, upgradeable: $upgradeable_option, flashmint: $flashmint_option' test project" + echo "Generating $proj_name..." + yarn run update_scarb_project --kind=$kind --macros=$macros_option --access=$access_option --upgradeable=$upgradeable_option --flashmint=$flashmint_option + + echo "Compiling $proj_name..." + scarb clean + scarb build + + echo "✅ Compiled $proj_name!" + echo "---------------------------------" + done + done + done - echo "Compiling $proj_name..." - scarb clean - scarb build + elif [[ "$kind" == "ERC6909" || "$kind" == "Custom" ]]; then + for access_option in "${all_access_options[@]}"; do + for upgradeable_option in "${all_upgradeable_options[@]}"; do + proj_name="'$kind, macros: $macros_option, access: $access_option, upgradeable: $upgradeable_option' test project" + echo "Generating $proj_name..." + yarn run update_scarb_project --kind=$kind --macros=$macros_option --access=$access_option --upgradeable=$upgradeable_option - echo "✅ Compiled $proj_name!" - echo "---------------------------------" + echo "Compiling $proj_name..." + scarb clean + scarb build + + echo "✅ Compiled $proj_name!" + echo "---------------------------------" + done done else diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md index a4674bc5d..786390b88 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -1,6 +1,17 @@ # Changelog +## 0.2.1 (2026-09-08) + +- Cairo: Add ERC20FlashMint extension for the ERC20 token kind. ([#870](https://github.com/OpenZeppelin/contracts-wizard/pull/870)) + - Configurable max flash loan (default or custom cap), flash fee (percent of the loan amount or custom stub), and fee destination (burn or fee receiver). + - Add `flashmint` options to the `cairo-erc20` MCP tool and CLI command. +- Updated dependencies [[`5ca9bbd`](https://github.com/OpenZeppelin/contracts-wizard/commit/5ca9bbd13725dc7baf74ca8d8ca8e1bdf5e0a94f), [`df1fa2f`](https://github.com/OpenZeppelin/contracts-wizard/commit/df1fa2ffb82bc7ee3652b165e7ef045b17b3664a)]: + - @openzeppelin/wizard-cairo@4.1.0 + - @openzeppelin/wizard-common@0.5.9 + - @openzeppelin/wizard-stellar@0.6.5 + - @openzeppelin/wizard@0.10.16 + ## 0.2.0 (2026-09-01) - Cairo: support OpenZeppelin Contracts for Cairo v4.0.1. ([#861](https://github.com/OpenZeppelin/contracts-wizard/pull/861)) diff --git a/packages/cli/package.json b/packages/cli/package.json index 2c3d0f600..33616344e 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@openzeppelin/contracts-cli", - "version": "0.2.0", + "version": "0.2.1", "description": "CLI for generating smart contracts using OpenZeppelin Contracts Wizard", "license": "AGPL-3.0-only", "repository": "https://github.com/OpenZeppelin/contracts-wizard", @@ -27,10 +27,10 @@ }, "dependencies": { "zod": "^4.0", - "@openzeppelin/wizard-common": "^0.5.8", - "@openzeppelin/wizard": "^0.10.14", - "@openzeppelin/wizard-cairo": "^4.0.0", - "@openzeppelin/wizard-stellar": "^0.6.4", + "@openzeppelin/wizard-common": "^0.5.9", + "@openzeppelin/wizard": "^0.10.16", + "@openzeppelin/wizard-cairo": "^4.1.0", + "@openzeppelin/wizard-stellar": "^0.6.5", "@openzeppelin/wizard-stylus": "^0.3.0", "@openzeppelin/wizard-confidential": "^0.1.2", "@openzeppelin/wizard-uniswap-hooks": "^0.1.1" diff --git a/packages/cli/src/cli-options.test.ts b/packages/cli/src/cli-options.test.ts index 2e28a29c4..c6f19217c 100644 --- a/packages/cli/src/cli-options.test.ts +++ b/packages/cli/src/cli-options.test.ts @@ -8,6 +8,7 @@ import { erc721 as cairoErc721, erc1155 as cairoErc1155, erc6909 as cairoErc6909, + flashMintDefaults as cairoFlashMintDefaults, account as cairoAccount, multisig as cairoMultisig, governor as cairoGovernor, @@ -401,6 +402,47 @@ test('cairo-erc20: access ownable without dar delays', t => { t.is(output, cairoErc20.print(opts)); }); +test('cairo-erc20: flash mint with defaults', t => { + const opts = { + name: 'TestToken', + symbol: 'TST', + flashmint: { ...cairoFlashMintDefaults, enabled: true }, + }; + const output = run('cairo-erc20', '--name', opts.name, '--symbol', opts.symbol, '--flashmint.enabled'); + t.is(output, cairoErc20.print(opts)); +}); + +test('cairo-erc20: flash mint with custom config', t => { + const opts = { + name: 'TestToken', + symbol: 'TST', + flashmint: { + enabled: true, + maxAmount: '1000000', + feeMode: 'percent' as const, + feePercent: '0.5', + feeDestination: 'fee_receiver' as const, + }, + }; + const output = run( + 'cairo-erc20', + '--name', + opts.name, + '--symbol', + opts.symbol, + '--flashmint.enabled', + '--flashmint.maxAmount', + opts.flashmint.maxAmount, + '--flashmint.feeMode', + opts.flashmint.feeMode, + '--flashmint.feePercent', + opts.flashmint.feePercent, + '--flashmint.feeDestination', + opts.flashmint.feeDestination, + ); + t.is(output, cairoErc20.print(opts)); +}); + test('cairo-erc721: most options', t => { const opts = { name: 'TestNFT', diff --git a/packages/cli/src/cli.test.ts.md b/packages/cli/src/cli.test.ts.md index e22511951..4c1a6f2e9 100644 --- a/packages/cli/src/cli.test.ts.md +++ b/packages/cli/src/cli.test.ts.md @@ -270,6 +270,11 @@ Generated by [AVA](https://avajs.dev). --votes Whether to keep track of historical balances for voting in on-chain governance, with a way to delegate one's voting power to a trusted account.␊ --appName Required when votes is enabled, for hashing and signing typed structured data. Name for domain separator implementing SNIP12Metadata trait. Prevents two applications from producing the same hash.␊ --appVersion Required when votes is enabled, for hashing and signing typed structured data. Version for domain separator implementing SNIP12Metadata trait. Prevents two versions of the same application from producing the same hash.␊ + --flashmint.enabled Whether to include ERC20FlashMint functionality, allowing flash loans of tokens compliant with ERC-3156.␊ + --flashmint.maxAmount Maximum amount of tokens that can be flash-loaned in a single call. Use the literal string "max" to inherit the default (the maximum representable u256 minus the current total supply), or a non-negative number in the token's decimal units to set a custom cap. A value of 0 effectively disables flash loans without removing the extension.␊ + --flashmint.feeMode Mode for the flash loan fee. 'percent' charges a percentage of the loaned amount (value provided via feePercent). 'custom' emits a TODO stub for the caller to implement.␊ + --flashmint.feePercent Percentage of the loan amount charged as the flash loan fee. Number between 0 and 100, fractional values supported (e.g. "0.0013725"). Used when feeMode is 'percent'. Defaults to 0 (no fee).␊ + --flashmint.feeDestination Where the flash loan fee is sent. 'burn' sends it to the zero address (effectively burning it). 'fee_receiver' adds a constructor argument that the deployer must populate with a non-zero address; the address is stored on-chain and validated at deploy time.␊ --access.type The type of access control to provision. Ownable is a simple mechanism with a single account authorized for all privileged actions. Roles is a flexible mechanism with a separate role for each privileged action. A role can have many authorized accounts. Roles (Default Admin Rules) provides additional enforced security measures on top of standard Roles mechanism for managing the most privileged role: default admin.␊ --access.darInitialDelay The initial delay for the default admin role (in case Roles (Default Admin Rules) is used). Default is "1 day".␊ --access.darDefaultDelayIncrease The default delay increase for the default admin role (in case Roles (Default Admin Rules) is used). Default is "5 days".␊ diff --git a/packages/cli/src/cli.test.ts.snap b/packages/cli/src/cli.test.ts.snap index b0f12a5da..2d4436500 100644 Binary files a/packages/cli/src/cli.test.ts.snap and b/packages/cli/src/cli.test.ts.snap differ diff --git a/packages/common/CHANGELOG.md b/packages/common/CHANGELOG.md index 03b09b213..4cd0e0118 100644 --- a/packages/common/CHANGELOG.md +++ b/packages/common/CHANGELOG.md @@ -1,6 +1,12 @@ # Changelog +## 0.5.9 (2026-09-08) + +- Cairo: Add ERC20FlashMint extension for the ERC20 token kind. ([#870](https://github.com/OpenZeppelin/contracts-wizard/pull/870)) + - Configurable max flash loan (default or custom cap), flash fee (percent of the loan amount or custom stub), and fee destination (burn or fee receiver). + - Add `flashmint` options to the `cairo-erc20` MCP tool and CLI command. + ## 0.5.8 (2026-09-01) - Cairo: support OpenZeppelin Contracts for Cairo v4.0.1. ([#861](https://github.com/OpenZeppelin/contracts-wizard/pull/861)) diff --git a/packages/common/package.json b/packages/common/package.json index 5ce1ad2d2..4f3c516d2 100644 --- a/packages/common/package.json +++ b/packages/common/package.json @@ -1,6 +1,6 @@ { "name": "@openzeppelin/wizard-common", - "version": "0.5.8", + "version": "0.5.9", "description": "Common library for OpenZeppelin Contracts Wizard components. Used internally.", "license": "AGPL-3.0-only", "repository": "https://github.com/OpenZeppelin/contracts-wizard", @@ -34,9 +34,9 @@ "zod": "^4.0" }, "devDependencies": { - "@openzeppelin/wizard": "^0.10.14", - "@openzeppelin/wizard-cairo": "^4.0.0", - "@openzeppelin/wizard-stellar": "^0.6.4", + "@openzeppelin/wizard": "^0.10.16", + "@openzeppelin/wizard-cairo": "^4.1.0", + "@openzeppelin/wizard-stellar": "^0.6.5", "@openzeppelin/wizard-stylus": "^0.3.0", "@openzeppelin/wizard-confidential": "^0.1.2", "@openzeppelin/wizard-uniswap-hooks": "^0.1.1", diff --git a/packages/common/src/ai/descriptions/cairo.ts b/packages/common/src/ai/descriptions/cairo.ts index d9b38fd93..eb7d49c24 100644 --- a/packages/common/src/ai/descriptions/cairo.ts +++ b/packages/common/src/ai/descriptions/cairo.ts @@ -55,6 +55,18 @@ export const cairoERC20Descriptions = { wrapper: 'Whether to include ERC20Wrapper functionality for depositing and withdrawing an underlying token.', votes: "Whether to keep track of historical balances for voting in on-chain governance, with a way to delegate one's voting power to a trusted account.", + flashmint: + 'Configuration object for the ERC20FlashMint extension (ERC-3156 flash loans). The extension is included only when `enabled` is true; the other fields tune the loan limit, fee, and fee destination.', + flashMintEnabled: + 'Whether to include ERC20FlashMint functionality, allowing flash loans of tokens compliant with ERC-3156.', + flashMintMaxAmount: + 'Maximum amount of tokens that can be flash-loaned in a single call. Use the literal string "max" to inherit the default (the maximum representable u256 minus the current total supply), or a non-negative number in the token\'s decimal units to set a custom cap. A value of 0 effectively disables flash loans without removing the extension.', + flashMintFeeMode: + "Mode for the flash loan fee. 'percent' charges a percentage of the loaned amount (value provided via feePercent). 'custom' emits a TODO stub for the caller to implement.", + flashMintFeePercent: + 'Percentage of the loan amount charged as the flash loan fee. Number between 0 and 100, fractional values supported (e.g. "0.0013725"). Used when feeMode is \'percent\'. Defaults to 0 (no fee).', + flashMintFeeDestination: + "Where the flash loan fee is sent. 'burn' sends it to the zero address (effectively burning it). 'fee_receiver' adds a constructor argument that the deployer must populate with a non-zero address; the address is stored on-chain and validated at deploy time.", }; export const cairoERC721Descriptions = { diff --git a/packages/common/src/ai/schemas/cairo.ts b/packages/common/src/ai/schemas/cairo.ts index 9efc5f6e7..832efe98b 100644 --- a/packages/common/src/ai/schemas/cairo.ts +++ b/packages/common/src/ai/schemas/cairo.ts @@ -58,6 +58,19 @@ export const cairoERC20Schema = { votes: z.boolean().optional().describe(cairoERC20Descriptions.votes), appName: z.string().optional().describe(cairoCommonDescriptions.appName), appVersion: z.string().optional().describe(cairoCommonDescriptions.appVersion), + flashmint: z + .object({ + enabled: z.boolean().describe(cairoERC20Descriptions.flashMintEnabled), + maxAmount: z.string().default('max').describe(cairoERC20Descriptions.flashMintMaxAmount), + feeMode: z.enum(['percent', 'custom']).default('percent').describe(cairoERC20Descriptions.flashMintFeeMode), + feePercent: z.string().default('0').describe(cairoERC20Descriptions.flashMintFeePercent), + feeDestination: z + .enum(['burn', 'fee_receiver']) + .default('burn') + .describe(cairoERC20Descriptions.flashMintFeeDestination), + }) + .optional() + .describe(cairoERC20Descriptions.flashmint), ...cairoCommonSchema, } as const satisfies z.ZodRawShape; diff --git a/packages/core/cairo/CHANGELOG.md b/packages/core/cairo/CHANGELOG.md index 50a49f099..03459fa81 100644 --- a/packages/core/cairo/CHANGELOG.md +++ b/packages/core/cairo/CHANGELOG.md @@ -1,6 +1,14 @@ # Changelog +## 4.1.0 (2026-09-08) + +- Cairo: Add ERC20FlashMint extension for the ERC20 token kind. ([#870](https://github.com/OpenZeppelin/contracts-wizard/pull/870)) + - Configurable max flash loan (default or custom cap), flash fee (percent of the loan amount or custom stub), and fee destination (burn or fee receiver). + - Add `flashmint` options to the `cairo-erc20` MCP tool and CLI command. + +- Improve performance of premint amount parsing. ([#872](https://github.com/OpenZeppelin/contracts-wizard/pull/872)) + ## 4.0.0 (2026-09-01) - Cairo: support OpenZeppelin Contracts for Cairo v4.0.1. ([#861](https://github.com/OpenZeppelin/contracts-wizard/pull/861)) diff --git a/packages/core/cairo/package.json b/packages/core/cairo/package.json index 0576177d0..1bdc579ca 100644 --- a/packages/core/cairo/package.json +++ b/packages/core/cairo/package.json @@ -1,6 +1,6 @@ { "name": "@openzeppelin/wizard-cairo", - "version": "4.0.0", + "version": "4.1.0", "description": "A boilerplate generator to get started with OpenZeppelin Contracts for Cairo", "license": "AGPL-3.0-only", "repository": "https://github.com/OpenZeppelin/contracts-wizard", diff --git a/packages/core/cairo/src/contract.ts b/packages/core/cairo/src/contract.ts index 2543086dc..759503eb6 100644 --- a/packages/core/cairo/src/contract.ts +++ b/packages/core/cairo/src/contract.ts @@ -15,6 +15,12 @@ export interface Contract { upgradeable: boolean; implementedTraits: ImplementedTrait[]; superVariables: Variable[]; + storageMembers: StorageMember[]; +} + +export interface StorageMember { + name: string; + type: string; } export type Value = string | number | bigint | { lit: string } | { note: string; value: Value }; @@ -118,6 +124,7 @@ export class ContractBuilder implements Contract { private constantsMap: Map = new Map(); private useClausesMap: Map = new Map(); private interfaceFlagsSet: Set = new Set(); + private storageMembersMap: Map = new Map(); constructor(name: string, macros: MacrosOptions, account: boolean = false) { this.name = toIdentifier(name, true); @@ -145,6 +152,24 @@ export class ContractBuilder implements Contract { return [...this.useClausesMap.values()]; } + get storageMembers(): StorageMember[] { + return [...this.storageMembersMap.values()]; + } + + addStorageMember(member: StorageMember): boolean { + const existing = this.storageMembersMap.get(member.name); + if (existing !== undefined) { + if (existing.type !== member.type) { + throw new Error( + `Tried to add duplicate storage member ${member.name} with different type: ${member.type} instead of ${existing.type}.`, + ); + } + return false; + } + this.storageMembersMap.set(member.name, member); + return true; + } + /** * Custom flags to denote that the contract implements a specific interface, e.g. ISRC5, to avoid duplicates **/ diff --git a/packages/core/cairo/src/erc20.ts b/packages/core/cairo/src/erc20.ts index 0db80febe..cae4ea6a8 100644 --- a/packages/core/cairo/src/erc20.ts +++ b/packages/core/cairo/src/erc20.ts @@ -18,6 +18,66 @@ import { addVotesComponent } from './common-components'; const DEFAULT_DECIMALS = BigInt(18); +export type FlashMintFeeMode = 'percent' | 'custom'; +export type FlashMintFeeDestination = 'burn' | 'fee_receiver'; + +export type FlashMintOptions = { + enabled: boolean; + maxAmount: string; + feeMode: FlashMintFeeMode; + feePercent: string; + feeDestination: FlashMintFeeDestination; +}; + +export const flashMintDefaults: FlashMintOptions = { + enabled: false, + maxAmount: 'max', + feeMode: 'percent', + feePercent: '0', + feeDestination: 'burn', +}; + +export type FlashMintSubset = 'all' | 'disabled' | 'enabled-default' | 'enabled-percent-fee' | 'enabled-custom-fee'; + +export const flashMintOptions = { + disabled: flashMintDefaults, + enabledDefault: { ...flashMintDefaults, enabled: true } satisfies FlashMintOptions, + enabledPercentFee: { + enabled: true, + maxAmount: '1000000', + feeMode: 'percent', + feePercent: '0.5', + feeDestination: 'fee_receiver', + } satisfies FlashMintOptions, + enabledCustomFee: { + enabled: true, + maxAmount: 'max', + feeMode: 'custom', + feePercent: '0', + feeDestination: 'fee_receiver', + } satisfies FlashMintOptions, +}; + +export function resolveFlashMintOptionsSubset(subset: FlashMintSubset): FlashMintOptions[] { + const { disabled, enabledDefault, enabledPercentFee, enabledCustomFee } = flashMintOptions; + switch (subset) { + case 'all': + return [disabled, enabledDefault, enabledPercentFee, enabledCustomFee]; + case 'disabled': + return [disabled]; + case 'enabled-default': + return [enabledDefault]; + case 'enabled-percent-fee': + return [enabledPercentFee]; + case 'enabled-custom-fee': + return [enabledCustomFee]; + default: { + const _: never = subset; + throw new Error('Unknown FlashMintSubset'); + } + } +} + export const defaults: Required = { name: 'MyToken', symbol: 'MTK', @@ -30,6 +90,7 @@ export const defaults: Required = { votes: false, appName: '', // Defaults to empty string, but user must provide a non-empty value if votes are enabled appVersion: 'v1', + flashmint: flashMintDefaults, access: commonDefaults.access, upgradeable: commonDefaults.upgradeable, info: commonDefaults.info, @@ -52,6 +113,7 @@ export interface ERC20Options extends CommonContractOptions { votes?: boolean; appName?: string; appVersion?: string; + flashmint?: FlashMintOptions; } function withDefaults(opts: ERC20Options): Required { @@ -67,6 +129,7 @@ function withDefaults(opts: ERC20Options): Required { votes: opts.votes ?? defaults.votes, appName: opts.appName ?? defaults.appName, appVersion: opts.appVersion ?? defaults.appVersion, + flashmint: opts.flashmint ?? defaults.flashmint, }; } @@ -102,6 +165,10 @@ export function buildERC20(opts: ERC20Options): Contract { addWrapper(c); } + if (allOpts.flashmint.enabled) { + addFlashMint(c, allOpts.flashmint, decimals); + } + addHooks(c, allOpts); setAccessControl(c, allOpts.access); @@ -224,7 +291,7 @@ function addBurnable(c: ContractBuilder) { c.addFunction(externalTrait, functions.burn); } -export const premintPattern = /^(\d*\.?\d*)$/; +export const premintPattern = /^\d*(?:\.\d*)?$/; function addPremint(c: ContractBuilder, amount: string, decimals: bigint) { if (amount !== undefined && amount !== '0') { @@ -294,6 +361,145 @@ function addWrapper(c: ContractBuilder) { c.addComponent(components.ERC20WrapperComponent, [{ lit: 'underlying' }], true); } +function parseFlashMintMaxAmount(value: string, decimals: bigint): bigint | null { + if (value === 'max') { + return null; + } + if (value === '' || !premintPattern.test(value)) { + throw new OptionsError({ flashMintMaxAmount: 'Must be "max" or a non-negative number' }); + } + return toUint(getInitialSupply(value, Number(decimals)), 'flashMintMaxAmount', 'u256'); +} + +function parseFlashMintFeePercent(value: string): { numerator: bigint; denominator: bigint } | null { + if (value === '') { + return null; + } + if (!premintPattern.test(value)) { + throw new OptionsError({ flashMintFeePercent: 'Must be a number between 0 and 100' }); + } + const [intPart = '', fracPart = ''] = value.split('.'); + const decimalDigits = fracPart.length; + const combined = (intPart + fracPart).replace(/^0+/, ''); + if (combined === '') { + return null; + } + const numerator = BigInt(combined); + const decimalScale = 10n ** BigInt(decimalDigits); + // value = numerator / decimalScale, must be <= 100 + if (numerator > 100n * decimalScale) { + throw new OptionsError({ flashMintFeePercent: 'Must be a number between 0 and 100' }); + } + // For percent of amount: amount * value / 100 = amount * numerator / (100 * decimalScale). + // Both literals are emitted into Cairo and must fit u256; numerator <= denominator from the + // check above, so bounding the denominator is sufficient. + const denominator = 100n * decimalScale; + toUint(denominator.toString(), 'flashMintFeePercent', 'u256'); + return { numerator, denominator }; +} + +function buildFlashFeeOverrideBody(opts: FlashMintOptions): string[] | null { + switch (opts.feeMode) { + case 'percent': { + const parsed = parseFlashMintFeePercent(opts.feePercent); + if (parsed === null) { + return null; + } + return [`amount * ${parsed.numerator} / ${parsed.denominator}`]; + } + case 'custom': + return ['// TODO: Must be implemented according to the desired flash fee logic', '0']; + default: { + const _: never = opts.feeMode; + throw new Error(`Unknown flashMintFeeMode: ${_}`); + } + } +} + +function addFlashMint(c: ContractBuilder, opts: FlashMintOptions, decimals: bigint) { + c.addComponent(components.ERC20FlashMintComponent, [], false); + + const customMax = parseFlashMintMaxAmount(opts.maxAmount, decimals); + const overridesMax = customMax !== null; + const overridesReceiver = opts.feeDestination === 'fee_receiver'; + const feeOverrideBody = buildFlashFeeOverrideBody(opts); + const overridesFee = feeOverrideBody !== null; + + if (!overridesMax && !overridesFee && !overridesReceiver) { + c.addUseClause('openzeppelin_token::erc20::extensions::erc20_flash_mint', 'DefaultConfig', { + alias: 'ERC20FlashMintDefaultConfig', + }); + return; + } + + const flashMintConfigTrait: BaseImplementedTrait = { + name: 'FlashMintConfigImpl', + of: 'ERC20FlashMintComponent::FlashMintConfigTrait', + tags: [], + }; + c.addImplementedTrait(flashMintConfigTrait); + + if (overridesMax || overridesFee || overridesReceiver) { + c.addUseClause('starknet', 'ContractAddress'); + } + + if (overridesMax) { + c.addUseClause('starknet', 'get_contract_address'); + c.addUseClause('core::num::traits', 'Bounded'); + const fn = c.addFunction(flashMintConfigTrait, { + name: 'max_flash_loan', + args: [ + { name: 'self', type: '@ERC20FlashMintComponent::ComponentState' }, + { name: 'token', type: 'ContractAddress' }, + { name: 'total_supply', type: 'u256' }, + ], + returns: 'u256', + code: [], + }); + // Clamp the configured cap to the remaining mint headroom so the loan size we report is + // never larger than what the underlying mint path can actually honor. + fn.code.push( + 'if token != get_contract_address() {', + ' return 0;', + '}', + 'let headroom = Bounded::::MAX - total_supply;', + `let cap: u256 = ${customMax!};`, + 'if cap < headroom { cap } else { headroom }', + ); + } + + if (overridesFee) { + const fn = c.addFunction(flashMintConfigTrait, { + name: 'flash_fee', + args: [ + { name: 'self', type: '@ERC20FlashMintComponent::ComponentState' }, + { name: 'token', type: 'ContractAddress' }, + { name: 'amount', type: 'u256' }, + ], + returns: 'u256', + code: [], + }); + fn.code.push(...feeOverrideBody!); + } + + if (overridesReceiver) { + c.addUseClause('core::num::traits', 'Zero'); + c.addUseClause('starknet::storage', 'StoragePointerReadAccess'); + c.addUseClause('starknet::storage', 'StoragePointerWriteAccess'); + c.addStorageMember({ name: 'flash_fee_receiver', type: 'ContractAddress' }); + c.addConstructorArgument({ name: 'flash_fee_receiver', type: 'ContractAddress' }); + c.addConstructorCode(`assert(!flash_fee_receiver.is_zero(), 'FlashMint: invalid receiver')`); + c.addConstructorCode(`self.flash_fee_receiver.write(flash_fee_receiver)`); + const fn = c.addFunction(flashMintConfigTrait, { + name: 'flash_fee_receiver', + args: [{ name: 'self', type: '@ERC20FlashMintComponent::ComponentState' }], + returns: 'ContractAddress', + code: [], + }); + fn.code.push('self.get_contract().flash_fee_receiver.read()'); + } +} + const components = defineComponents({ ERC20Component: { path: 'openzeppelin_token::erc20', @@ -336,6 +542,24 @@ const components = defineComponents({ }, ], }, + ERC20FlashMintComponent: { + path: 'openzeppelin_token::erc20::extensions::erc20_flash_mint', + substorage: { + name: 'erc20_flash_mint', + type: 'ERC20FlashMintComponent::Storage', + }, + event: { + name: 'ERC20FlashMintEvent', + type: 'ERC20FlashMintComponent::Event', + }, + impls: [ + { + name: 'ERC20FlashMintImpl', + embed: true, + value: 'ERC20FlashMintComponent::ERC20FlashMintImpl', + }, + ], + }, }); const functions = defineFunctions({ diff --git a/packages/core/cairo/src/generate/erc20.ts b/packages/core/cairo/src/generate/erc20.ts index 2849f72b3..0b7d6b428 100644 --- a/packages/core/cairo/src/generate/erc20.ts +++ b/packages/core/cairo/src/generate/erc20.ts @@ -1,4 +1,5 @@ -import type { ERC20Options } from '../erc20'; +import type { ERC20Options, FlashMintSubset } from '../erc20'; +import { resolveFlashMintOptionsSubset } from '../erc20'; import type { AccessSubset } from '../set-access-control'; import { resolveAccessControlOptions } from '../set-access-control'; import { infoOptions } from '../set-info'; @@ -13,6 +14,7 @@ const booleans = [true, false]; type GeneratorOptions = { access: AccessSubset; upgradeable: UpgradeableSubset; + flashmint: FlashMintSubset; macros: MacrosSubset; }; @@ -29,6 +31,7 @@ function prepareBlueprint(opts: GeneratorOptions) { votes: booleans, appName: ['MyApp'], appVersion: ['v1'], + flashmint: resolveFlashMintOptionsSubset(opts.flashmint), access: resolveAccessControlOptions(opts.access), upgradeable: resolveUpgradeableOptionsSubset(opts.upgradeable), info: infoOptions, diff --git a/packages/core/cairo/src/generate/sources.ts b/packages/core/cairo/src/generate/sources.ts index 72b1b371c..76f5a8392 100644 --- a/packages/core/cairo/src/generate/sources.ts +++ b/packages/core/cairo/src/generate/sources.ts @@ -18,6 +18,7 @@ import { OptionsError } from '../error'; import { findCover } from '../utils/find-cover'; import type { Contract } from '../contract'; import type { RoyaltyInfoSubset } from '../set-royalty-info'; +import type { FlashMintSubset } from '../erc20'; import type { MacrosSubset } from '../set-macros'; import type { AccessSubset } from '../set-access-control'; import type { UpgradeableSubset } from '../set-upgradeable'; @@ -31,11 +32,12 @@ export function* generateOptions(params: { access: AccessSubset; upgradeable: UpgradeableSubset; royaltyInfo: RoyaltyInfoSubset; + flashmint: FlashMintSubset; macros: MacrosSubset; }): Generator { - const { kind, access, upgradeable, royaltyInfo, macros } = params; + const { kind, access, upgradeable, royaltyInfo, flashmint, macros } = params; if (kind === 'all' || kind === 'ERC20') { - for (const kindOpts of generateERC20Options({ access, upgradeable, macros })) { + for (const kindOpts of generateERC20Options({ access, upgradeable, flashmint, macros })) { yield { kind: 'ERC20', ...kindOpts }; } } @@ -105,12 +107,13 @@ function generateContractSubset(params: { access: AccessSubset; upgradeable: UpgradeableSubset; royaltyInfo: RoyaltyInfoSubset; + flashmint: FlashMintSubset; macros: MacrosSubset; }): GeneratedContract[] { - const { subset, kind, access, upgradeable, royaltyInfo, macros } = params; + const { subset, kind, access, upgradeable, royaltyInfo, flashmint, macros } = params; const contracts = []; - for (const options of generateOptions({ kind, access, upgradeable, royaltyInfo, macros })) { + for (const options of generateOptions({ kind, access, upgradeable, royaltyInfo, flashmint, macros })) { const id = crypto.createHash('sha1').update(JSON.stringify(options)).digest().toString('hex'); try { const contract = buildGeneric(options); @@ -163,11 +166,12 @@ export function* generateSources(params: { access: AccessSubset; upgradeable: UpgradeableSubset; royaltyInfo: RoyaltyInfoSubset; + flashmint: FlashMintSubset; macros: MacrosSubset; }): Generator { - const { subset, uniqueName, kind, access, upgradeable, royaltyInfo, macros } = params; + const { subset, uniqueName, kind, access, upgradeable, royaltyInfo, flashmint, macros } = params; let counter = 1; - for (const c of generateContractSubset({ subset, kind, access, upgradeable, royaltyInfo, macros })) { + for (const c of generateContractSubset({ subset, kind, access, upgradeable, royaltyInfo, flashmint, macros })) { if (uniqueName) { c.contract.name = `Contract${counter++}`; } @@ -184,10 +188,11 @@ export async function writeGeneratedSources(params: { access: AccessSubset; upgradeable: UpgradeableSubset; royaltyInfo: RoyaltyInfoSubset; + flashmint: FlashMintSubset; macros: MacrosSubset; logsEnabled: boolean; }): Promise { - const { dir, subset, uniqueName, kind, access, upgradeable, royaltyInfo, macros, logsEnabled } = params; + const { dir, subset, uniqueName, kind, access, upgradeable, royaltyInfo, flashmint, macros, logsEnabled } = params; await fs.mkdir(dir, { recursive: true }); const contractNames = []; @@ -198,6 +203,7 @@ export async function writeGeneratedSources(params: { access, upgradeable, royaltyInfo, + flashmint, macros, })) { const name = uniqueName ? contract.name : id; @@ -205,7 +211,7 @@ export async function writeGeneratedSources(params: { contractNames.push(name); } if (logsEnabled) { - const sourceLabel = resolveSourceLabel({ kind, access, upgradeable, royaltyInfo, macros }); + const sourceLabel = resolveSourceLabel({ kind, access, upgradeable, royaltyInfo, flashmint, macros }); console.log(`Generated ${contractNames.length} contracts for ${sourceLabel}`); } @@ -217,15 +223,17 @@ function resolveSourceLabel(params: { access: AccessSubset; upgradeable: UpgradeableSubset; royaltyInfo: RoyaltyInfoSubset; + flashmint: FlashMintSubset; macros: MacrosSubset; }): string { - const { kind, access, upgradeable, royaltyInfo, macros } = params; + const { kind, access, upgradeable, royaltyInfo, flashmint, macros } = params; return [ resolveKindLabel(kind), resolveMacrosLabel(macros), resolveAccessLabel(kind, access), resolveUpgradeableLabel(kind, upgradeable), resolveRoyaltyInfoLabel(kind, royaltyInfo), + resolveFlashMintLabel(kind, flashmint), ] .filter(elem => elem !== undefined) .join(', '); @@ -318,3 +326,24 @@ function resolveRoyaltyInfoLabel(kind: KindSubset, royaltyInfo: RoyaltyInfoSubse } } } + +function resolveFlashMintLabel(kind: KindSubset, flashmint: FlashMintSubset): string | undefined { + switch (kind) { + case 'all': + case 'ERC20': + return `flashmint: ${flashmint}`; + case 'ERC721': + case 'ERC1155': + case 'ERC6909': + case 'Account': + case 'Custom': + case 'Multisig': + case 'Governor': + case 'Vesting': + return undefined; + default: { + const _: never = kind; + throw new Error('Unknown kind'); + } + } +} diff --git a/packages/core/cairo/src/index.ts b/packages/core/cairo/src/index.ts index 27bd959f3..bceb4c1ad 100644 --- a/packages/core/cairo/src/index.ts +++ b/packages/core/cairo/src/index.ts @@ -13,7 +13,8 @@ export type { Info } from './set-info'; export type { RoyaltyInfoOptions } from './set-royalty-info'; export type { MacrosOptions } from './set-macros'; -export { premintPattern } from './erc20'; +export { premintPattern, flashMintDefaults } from './erc20'; +export type { FlashMintOptions, FlashMintFeeMode, FlashMintFeeDestination } from './erc20'; export { defaults as infoDefaults } from './set-info'; export { defaults as royaltyInfoDefaults } from './set-royalty-info'; diff --git a/packages/core/cairo/src/print.ts b/packages/core/cairo/src/print.ts index 817ba2925..0916103d6 100644 --- a/packages/core/cairo/src/print.ts +++ b/packages/core/cairo/src/print.ts @@ -219,11 +219,16 @@ function printImpl(impl: Impl): Lines[] { } function printStorage(contract: Contract): (string | string[])[] { + const memberLines = contract.storageMembers.map(m => `${m.name}: ${m.type},`); + if (contract.macros.withComponents || contract.components.length === 0) { // storage is required regardless of whether there are components - return ['#[storage]', 'struct Storage {}']; + if (memberLines.length === 0) { + return ['#[storage]', 'struct Storage {}']; + } + return ['#[storage]', 'struct Storage {', memberLines, '}']; } - const storageLines = []; + const storageLines = [...memberLines]; for (const component of contract.components) { storageLines.push(`#[substorage(v0)]`); storageLines.push(`${component.substorage.name}: ${component.substorage.type},`); diff --git a/packages/core/cairo/src/scripts/update-scarb-project.ts b/packages/core/cairo/src/scripts/update-scarb-project.ts index a76869aeb..73b2bbff1 100644 --- a/packages/core/cairo/src/scripts/update-scarb-project.ts +++ b/packages/core/cairo/src/scripts/update-scarb-project.ts @@ -5,6 +5,7 @@ import type { KindSubset } from '../generate/sources'; import type { AccessSubset } from '../set-access-control'; import type { UpgradeableSubset } from '../set-upgradeable'; import type { RoyaltyInfoSubset } from '../set-royalty-info'; +import type { FlashMintSubset } from '../erc20'; import type { MacrosSubset } from '../set-macros'; import { writeGeneratedSources } from '../generate/sources'; import { contractsVersion, edition, cairoVersion, scarbVersion } from '../utils/version'; @@ -14,6 +15,7 @@ type Arguments = { access: AccessSubset; upgradeable: UpgradeableSubset; royaltyInfo: RoyaltyInfoSubset; + flashmint: FlashMintSubset; macros: MacrosSubset; }; @@ -22,6 +24,7 @@ const defaults = { access: 'all', upgradeable: 'all', royaltyInfo: 'all', + flashmint: 'all', macros: 'all', } as const; @@ -33,6 +36,7 @@ export function resolveArguments(): Arguments { access: parseAccessSubset(args.access ?? defaults.access), upgradeable: parseUpgradeableSubset(args.upgradeable ?? defaults.upgradeable), royaltyInfo: parseRoyaltyInfoSubset(args.royalty ?? defaults.royaltyInfo), + flashmint: parseFlashMintSubset(args.flashmint ?? defaults.flashmint), macros: parseMacrosSubset(args.macros ?? defaults.macros), }; } @@ -42,7 +46,7 @@ export async function updateScarbProject() { await fs.rm(generatedSourcesPath, { force: true, recursive: true }); // Generate the contracts source code - const { kind, access, upgradeable, royaltyInfo, macros } = resolveArguments(); + const { kind, access, upgradeable, royaltyInfo, flashmint, macros } = resolveArguments(); const contractNames = await writeGeneratedSources({ dir: generatedSourcesPath, subset: 'all', @@ -51,6 +55,7 @@ export async function updateScarbProject() { access, upgradeable, royaltyInfo, + flashmint, macros, logsEnabled: true, }); @@ -189,6 +194,26 @@ function parseRoyaltyInfoSubset(value: string): RoyaltyInfoSubset { } } +function parseFlashMintSubset(value: string): FlashMintSubset { + switch (value.toLowerCase()) { + case 'all': + return 'all'; + case 'disabled': + return 'disabled'; + case 'enabled-default': + case 'enabled_default': + return 'enabled-default'; + case 'enabled-percent-fee': + case 'enabled_percent_fee': + return 'enabled-percent-fee'; + case 'enabled-custom-fee': + case 'enabled_custom_fee': + return 'enabled-custom-fee'; + default: + throw new Error(`Failed to resolve flashmint subset from '${value}' value.`); + } +} + function parseMacrosSubset(value: string): MacrosSubset { switch (value.toLowerCase()) { case 'all': diff --git a/packages/core/cairo/src/test.ts b/packages/core/cairo/src/test.ts index 3559218f5..6bb72a19c 100644 --- a/packages/core/cairo/src/test.ts +++ b/packages/core/cairo/src/test.ts @@ -8,6 +8,7 @@ import type { KindSubset } from './generate/sources'; import type { AccessSubset } from './set-access-control'; import type { UpgradeableSubset } from './set-upgradeable'; import type { RoyaltyInfoSubset } from './set-royalty-info'; +import type { FlashMintSubset } from './erc20'; import type { GenericOptions } from './build-generic'; import type { MacrosSubset } from './set-macros'; import { generateSources, writeGeneratedSources } from './generate/sources'; @@ -20,7 +21,7 @@ interface Context { const test = _test as TestFn; test.serial('erc20 results generated', async ctx => { - await testGenerate({ ctx, kind: 'ERC20', access: 'all' }); + await testGenerate({ ctx, kind: 'ERC20', access: 'all', flashmint: 'all' }); }); test.serial('erc721 results generated', async ctx => { @@ -61,9 +62,10 @@ async function testGenerate(params: { access?: AccessSubset; upgradeable?: UpgradeableSubset; royaltyInfo?: RoyaltyInfoSubset; + flashmint?: FlashMintSubset; macros?: MacrosSubset; }) { - const { ctx, kind, access, upgradeable, royaltyInfo, macros } = params; + const { ctx, kind, access, upgradeable, royaltyInfo, flashmint, macros } = params; const generatedSourcesPath = path.join(os.tmpdir(), 'oz-wizard-cairo'); await fs.rm(generatedSourcesPath, { force: true, recursive: true }); await writeGeneratedSources({ @@ -74,6 +76,7 @@ async function testGenerate(params: { access: access || 'all', upgradeable: upgradeable || 'all', royaltyInfo: royaltyInfo || 'all', + flashmint: flashmint || 'all', macros: macros || 'all', logsEnabled: false, }); @@ -114,6 +117,7 @@ test('is access control required', async t => { access: 'all', upgradeable: 'all', royaltyInfo: 'all', + flashmint: 'all', macros: 'none', }); for (const contract of allSources) { diff --git a/packages/core/cairo/src/tests/with_components_off/erc20/erc20.test.ts b/packages/core/cairo/src/tests/with_components_off/erc20/erc20.test.ts index f723379c3..1b789eaf4 100644 --- a/packages/core/cairo/src/tests/with_components_off/erc20/erc20.test.ts +++ b/packages/core/cairo/src/tests/with_components_off/erc20/erc20.test.ts @@ -1,7 +1,7 @@ import test from 'ava'; import type { ERC20Options } from '../../../erc20'; -import { buildERC20, getInitialSupply } from '../../../erc20'; +import { buildERC20, flashMintDefaults, getInitialSupply } from '../../../erc20'; import { printContract } from '../../../print'; import { AccessControl, darDefaultOpts, darCustomOpts } from '../../../set-access-control'; @@ -76,6 +76,128 @@ testERC20('erc20 wrapper', { wrapper: true, }); +testERC20('erc20 flash mint', { + flashmint: { ...flashMintDefaults, enabled: true }, +}); + +testERC20('erc20 flash mint with custom max', { + flashmint: { ...flashMintDefaults, enabled: true, maxAmount: '1000000' }, +}); + +testERC20('erc20 flash mint with percent fee', { + flashmint: { ...flashMintDefaults, enabled: true, feeMode: 'percent', feePercent: '5' }, +}); + +testERC20('erc20 flash mint with fractional percent fee', { + flashmint: { ...flashMintDefaults, enabled: true, feeMode: 'percent', feePercent: '0.0013725' }, +}); + +testERC20('erc20 flash mint with percent fee and fee receiver', { + flashmint: { + ...flashMintDefaults, + enabled: true, + feeMode: 'percent', + feePercent: '5', + feeDestination: 'fee_receiver', + }, +}); + +testERC20('erc20 flash mint with custom fee impl', { + flashmint: { ...flashMintDefaults, enabled: true, feeMode: 'custom' }, +}); + +testERC20('erc20 flash mint with all custom config', { + flashmint: { + enabled: true, + maxAmount: '1000000', + feeMode: 'percent', + feePercent: '5', + feeDestination: 'fee_receiver', + }, +}); + +testERC20('erc20 flash mint with max amount of zero', { + flashmint: { ...flashMintDefaults, enabled: true, maxAmount: '0' }, +}); + +test('erc20 flash mint, max amount empty string', async t => { + const error = t.throws(() => + buildERC20({ + name: 'MyToken', + symbol: 'MTK', + flashmint: { ...flashMintDefaults, enabled: true, maxAmount: '' }, + }), + ); + t.is((error as OptionsError).messages.flashMintMaxAmount, 'Must be "max" or a non-negative number'); +}); + +test('erc20 flash mint, invalid max amount', async t => { + const error = t.throws(() => + buildERC20({ + name: 'MyToken', + symbol: 'MTK', + flashmint: { ...flashMintDefaults, enabled: true, maxAmount: 'abc' }, + }), + ); + t.is((error as OptionsError).messages.flashMintMaxAmount, 'Must be "max" or a non-negative number'); +}); + +test('erc20 flash mint, invalid percent fee', async t => { + const error = t.throws(() => + buildERC20({ + name: 'MyToken', + symbol: 'MTK', + flashmint: { ...flashMintDefaults, enabled: true, feeMode: 'percent', feePercent: 'abc' }, + }), + ); + t.is((error as OptionsError).messages.flashMintFeePercent, 'Must be a number between 0 and 100'); +}); + +test('erc20 flash mint, percent fee out of range', async t => { + const error = t.throws(() => + buildERC20({ + name: 'MyToken', + symbol: 'MTK', + flashmint: { ...flashMintDefaults, enabled: true, feeMode: 'percent', feePercent: '100.5' }, + }), + ); + t.is((error as OptionsError).messages.flashMintFeePercent, 'Must be a number between 0 and 100'); +}); + +test('erc20 flash mint, max amount overflows u256', async t => { + const error = t.throws(() => + buildERC20({ + name: 'MyToken', + symbol: 'MTK', + decimals: '0', + flashmint: { + ...flashMintDefaults, + enabled: true, + // 2^256 — one past u256::MAX with decimals: '0' so getInitialSupply preserves the value. + maxAmount: '115792089237316195423570985008687907853269984665640564039457584007913129639936', + }, + }), + ); + t.is((error as OptionsError).messages.flashMintMaxAmount, 'Value is greater than u256 max value'); +}); + +test('erc20 flash mint, percent denominator overflows u256', async t => { + const error = t.throws(() => + buildERC20({ + name: 'MyToken', + symbol: 'MTK', + flashmint: { + ...flashMintDefaults, + enabled: true, + feeMode: 'percent', + // 77 fractional digits — 100 * 10^77 > u256::MAX. + feePercent: '0.' + '0'.repeat(76) + '1', + }, + }), + ); + t.is((error as OptionsError).messages.flashMintFeePercent, 'Value is greater than u256 max value'); +}); + testERC20('erc20 preminted', { premint: '1000', }); diff --git a/packages/core/cairo/src/tests/with_components_off/erc20/erc20.test.ts.md b/packages/core/cairo/src/tests/with_components_off/erc20/erc20.test.ts.md index 6f03c9d88..6c1b7bc17 100644 --- a/packages/core/cairo/src/tests/with_components_off/erc20/erc20.test.ts.md +++ b/packages/core/cairo/src/tests/with_components_off/erc20/erc20.test.ts.md @@ -896,6 +896,780 @@ Generated by [AVA](https://avajs.dev). }␊ ` +## erc20 flash mint + +> Snapshot 1 + + `// SPDX-License-Identifier: MIT␊ + // Compatible with OpenZeppelin Contracts for Cairo ^4.0.1␊ + ␊ + #[starknet::contract]␊ + mod MyToken {␊ + use openzeppelin_access::ownable::OwnableComponent;␊ + use openzeppelin_interfaces::upgrades::IUpgradeable;␊ + use openzeppelin_token::erc20::{␊ + DefaultConfig as ERC20DefaultConfig, ERC20Component, ERC20HooksEmptyImpl␊ + };␊ + use openzeppelin_token::erc20::extensions::erc20_flash_mint::{␊ + DefaultConfig as ERC20FlashMintDefaultConfig, ERC20FlashMintComponent␊ + };␊ + use openzeppelin_upgrades::UpgradeableComponent;␊ + use starknet::{ClassHash, ContractAddress};␊ + ␊ + component!(path: ERC20Component, storage: erc20, event: ERC20Event);␊ + component!(path: ERC20FlashMintComponent, storage: erc20_flash_mint, event: ERC20FlashMintEvent);␊ + component!(path: UpgradeableComponent, storage: upgradeable, event: UpgradeableEvent);␊ + component!(path: OwnableComponent, storage: ownable, event: OwnableEvent);␊ + ␊ + // External␊ + #[abi(embed_v0)]␊ + impl ERC20MixinImpl = ERC20Component::ERC20MixinImpl;␊ + #[abi(embed_v0)]␊ + impl ERC20FlashMintImpl = ERC20FlashMintComponent::ERC20FlashMintImpl;␊ + #[abi(embed_v0)]␊ + impl OwnableMixinImpl = OwnableComponent::OwnableMixinImpl;␊ + ␊ + // Internal␊ + impl ERC20InternalImpl = ERC20Component::InternalImpl;␊ + impl UpgradeableInternalImpl = UpgradeableComponent::InternalImpl;␊ + impl OwnableInternalImpl = OwnableComponent::InternalImpl;␊ + ␊ + #[storage]␊ + struct Storage {␊ + #[substorage(v0)]␊ + erc20: ERC20Component::Storage,␊ + #[substorage(v0)]␊ + erc20_flash_mint: ERC20FlashMintComponent::Storage,␊ + #[substorage(v0)]␊ + upgradeable: UpgradeableComponent::Storage,␊ + #[substorage(v0)]␊ + ownable: OwnableComponent::Storage,␊ + }␊ + ␊ + #[event]␊ + #[derive(Drop, starknet::Event)]␊ + enum Event {␊ + #[flat]␊ + ERC20Event: ERC20Component::Event,␊ + #[flat]␊ + ERC20FlashMintEvent: ERC20FlashMintComponent::Event,␊ + #[flat]␊ + UpgradeableEvent: UpgradeableComponent::Event,␊ + #[flat]␊ + OwnableEvent: OwnableComponent::Event,␊ + }␊ + ␊ + #[constructor]␊ + fn constructor(ref self: ContractState, owner: ContractAddress) {␊ + self.erc20.initializer("MyToken", "MTK");␊ + self.ownable.initializer(owner);␊ + }␊ + ␊ + //␊ + // Upgradeable␊ + //␊ + ␊ + #[abi(embed_v0)]␊ + impl UpgradeableImpl of IUpgradeable {␊ + fn upgrade(ref self: ContractState, new_class_hash: ClassHash) {␊ + self.ownable.assert_only_owner();␊ + self.upgradeable.upgrade(new_class_hash);␊ + }␊ + }␊ + }␊ + ` + +## erc20 flash mint with custom max + +> Snapshot 1 + + `// SPDX-License-Identifier: MIT␊ + // Compatible with OpenZeppelin Contracts for Cairo ^4.0.1␊ + ␊ + #[starknet::contract]␊ + mod MyToken {␊ + use core::num::traits::Bounded;␊ + use openzeppelin_access::ownable::OwnableComponent;␊ + use openzeppelin_interfaces::upgrades::IUpgradeable;␊ + use openzeppelin_token::erc20::{␊ + DefaultConfig as ERC20DefaultConfig, ERC20Component, ERC20HooksEmptyImpl␊ + };␊ + use openzeppelin_token::erc20::extensions::erc20_flash_mint::ERC20FlashMintComponent;␊ + use openzeppelin_upgrades::UpgradeableComponent;␊ + use starknet::{ClassHash, ContractAddress, get_contract_address};␊ + ␊ + component!(path: ERC20Component, storage: erc20, event: ERC20Event);␊ + component!(path: ERC20FlashMintComponent, storage: erc20_flash_mint, event: ERC20FlashMintEvent);␊ + component!(path: UpgradeableComponent, storage: upgradeable, event: UpgradeableEvent);␊ + component!(path: OwnableComponent, storage: ownable, event: OwnableEvent);␊ + ␊ + // External␊ + #[abi(embed_v0)]␊ + impl ERC20MixinImpl = ERC20Component::ERC20MixinImpl;␊ + #[abi(embed_v0)]␊ + impl ERC20FlashMintImpl = ERC20FlashMintComponent::ERC20FlashMintImpl;␊ + #[abi(embed_v0)]␊ + impl OwnableMixinImpl = OwnableComponent::OwnableMixinImpl;␊ + ␊ + // Internal␊ + impl ERC20InternalImpl = ERC20Component::InternalImpl;␊ + impl UpgradeableInternalImpl = UpgradeableComponent::InternalImpl;␊ + impl OwnableInternalImpl = OwnableComponent::InternalImpl;␊ + ␊ + #[storage]␊ + struct Storage {␊ + #[substorage(v0)]␊ + erc20: ERC20Component::Storage,␊ + #[substorage(v0)]␊ + erc20_flash_mint: ERC20FlashMintComponent::Storage,␊ + #[substorage(v0)]␊ + upgradeable: UpgradeableComponent::Storage,␊ + #[substorage(v0)]␊ + ownable: OwnableComponent::Storage,␊ + }␊ + ␊ + #[event]␊ + #[derive(Drop, starknet::Event)]␊ + enum Event {␊ + #[flat]␊ + ERC20Event: ERC20Component::Event,␊ + #[flat]␊ + ERC20FlashMintEvent: ERC20FlashMintComponent::Event,␊ + #[flat]␊ + UpgradeableEvent: UpgradeableComponent::Event,␊ + #[flat]␊ + OwnableEvent: OwnableComponent::Event,␊ + }␊ + ␊ + #[constructor]␊ + fn constructor(ref self: ContractState, owner: ContractAddress) {␊ + self.erc20.initializer("MyToken", "MTK");␊ + self.ownable.initializer(owner);␊ + }␊ + ␊ + impl FlashMintConfigImpl of ERC20FlashMintComponent::FlashMintConfigTrait {␊ + fn max_flash_loan(␊ + self: @ERC20FlashMintComponent::ComponentState,␊ + token: ContractAddress,␊ + total_supply: u256,␊ + ) -> u256 {␊ + if token != get_contract_address() {␊ + return 0;␊ + }␊ + let headroom = Bounded::::MAX - total_supply;␊ + let cap: u256 = 1000000000000000000000000;␊ + if cap < headroom { cap } else { headroom }␊ + }␊ + }␊ + ␊ + //␊ + // Upgradeable␊ + //␊ + ␊ + #[abi(embed_v0)]␊ + impl UpgradeableImpl of IUpgradeable {␊ + fn upgrade(ref self: ContractState, new_class_hash: ClassHash) {␊ + self.ownable.assert_only_owner();␊ + self.upgradeable.upgrade(new_class_hash);␊ + }␊ + }␊ + }␊ + ` + +## erc20 flash mint with percent fee + +> Snapshot 1 + + `// SPDX-License-Identifier: MIT␊ + // Compatible with OpenZeppelin Contracts for Cairo ^4.0.1␊ + ␊ + #[starknet::contract]␊ + mod MyToken {␊ + use openzeppelin_access::ownable::OwnableComponent;␊ + use openzeppelin_interfaces::upgrades::IUpgradeable;␊ + use openzeppelin_token::erc20::{␊ + DefaultConfig as ERC20DefaultConfig, ERC20Component, ERC20HooksEmptyImpl␊ + };␊ + use openzeppelin_token::erc20::extensions::erc20_flash_mint::ERC20FlashMintComponent;␊ + use openzeppelin_upgrades::UpgradeableComponent;␊ + use starknet::{ClassHash, ContractAddress};␊ + ␊ + component!(path: ERC20Component, storage: erc20, event: ERC20Event);␊ + component!(path: ERC20FlashMintComponent, storage: erc20_flash_mint, event: ERC20FlashMintEvent);␊ + component!(path: UpgradeableComponent, storage: upgradeable, event: UpgradeableEvent);␊ + component!(path: OwnableComponent, storage: ownable, event: OwnableEvent);␊ + ␊ + // External␊ + #[abi(embed_v0)]␊ + impl ERC20MixinImpl = ERC20Component::ERC20MixinImpl;␊ + #[abi(embed_v0)]␊ + impl ERC20FlashMintImpl = ERC20FlashMintComponent::ERC20FlashMintImpl;␊ + #[abi(embed_v0)]␊ + impl OwnableMixinImpl = OwnableComponent::OwnableMixinImpl;␊ + ␊ + // Internal␊ + impl ERC20InternalImpl = ERC20Component::InternalImpl;␊ + impl UpgradeableInternalImpl = UpgradeableComponent::InternalImpl;␊ + impl OwnableInternalImpl = OwnableComponent::InternalImpl;␊ + ␊ + #[storage]␊ + struct Storage {␊ + #[substorage(v0)]␊ + erc20: ERC20Component::Storage,␊ + #[substorage(v0)]␊ + erc20_flash_mint: ERC20FlashMintComponent::Storage,␊ + #[substorage(v0)]␊ + upgradeable: UpgradeableComponent::Storage,␊ + #[substorage(v0)]␊ + ownable: OwnableComponent::Storage,␊ + }␊ + ␊ + #[event]␊ + #[derive(Drop, starknet::Event)]␊ + enum Event {␊ + #[flat]␊ + ERC20Event: ERC20Component::Event,␊ + #[flat]␊ + ERC20FlashMintEvent: ERC20FlashMintComponent::Event,␊ + #[flat]␊ + UpgradeableEvent: UpgradeableComponent::Event,␊ + #[flat]␊ + OwnableEvent: OwnableComponent::Event,␊ + }␊ + ␊ + #[constructor]␊ + fn constructor(ref self: ContractState, owner: ContractAddress) {␊ + self.erc20.initializer("MyToken", "MTK");␊ + self.ownable.initializer(owner);␊ + }␊ + ␊ + impl FlashMintConfigImpl of ERC20FlashMintComponent::FlashMintConfigTrait {␊ + fn flash_fee(␊ + self: @ERC20FlashMintComponent::ComponentState,␊ + token: ContractAddress,␊ + amount: u256,␊ + ) -> u256 {␊ + amount * 5 / 100␊ + }␊ + }␊ + ␊ + //␊ + // Upgradeable␊ + //␊ + ␊ + #[abi(embed_v0)]␊ + impl UpgradeableImpl of IUpgradeable {␊ + fn upgrade(ref self: ContractState, new_class_hash: ClassHash) {␊ + self.ownable.assert_only_owner();␊ + self.upgradeable.upgrade(new_class_hash);␊ + }␊ + }␊ + }␊ + ` + +## erc20 flash mint with fractional percent fee + +> Snapshot 1 + + `// SPDX-License-Identifier: MIT␊ + // Compatible with OpenZeppelin Contracts for Cairo ^4.0.1␊ + ␊ + #[starknet::contract]␊ + mod MyToken {␊ + use openzeppelin_access::ownable::OwnableComponent;␊ + use openzeppelin_interfaces::upgrades::IUpgradeable;␊ + use openzeppelin_token::erc20::{␊ + DefaultConfig as ERC20DefaultConfig, ERC20Component, ERC20HooksEmptyImpl␊ + };␊ + use openzeppelin_token::erc20::extensions::erc20_flash_mint::ERC20FlashMintComponent;␊ + use openzeppelin_upgrades::UpgradeableComponent;␊ + use starknet::{ClassHash, ContractAddress};␊ + ␊ + component!(path: ERC20Component, storage: erc20, event: ERC20Event);␊ + component!(path: ERC20FlashMintComponent, storage: erc20_flash_mint, event: ERC20FlashMintEvent);␊ + component!(path: UpgradeableComponent, storage: upgradeable, event: UpgradeableEvent);␊ + component!(path: OwnableComponent, storage: ownable, event: OwnableEvent);␊ + ␊ + // External␊ + #[abi(embed_v0)]␊ + impl ERC20MixinImpl = ERC20Component::ERC20MixinImpl;␊ + #[abi(embed_v0)]␊ + impl ERC20FlashMintImpl = ERC20FlashMintComponent::ERC20FlashMintImpl;␊ + #[abi(embed_v0)]␊ + impl OwnableMixinImpl = OwnableComponent::OwnableMixinImpl;␊ + ␊ + // Internal␊ + impl ERC20InternalImpl = ERC20Component::InternalImpl;␊ + impl UpgradeableInternalImpl = UpgradeableComponent::InternalImpl;␊ + impl OwnableInternalImpl = OwnableComponent::InternalImpl;␊ + ␊ + #[storage]␊ + struct Storage {␊ + #[substorage(v0)]␊ + erc20: ERC20Component::Storage,␊ + #[substorage(v0)]␊ + erc20_flash_mint: ERC20FlashMintComponent::Storage,␊ + #[substorage(v0)]␊ + upgradeable: UpgradeableComponent::Storage,␊ + #[substorage(v0)]␊ + ownable: OwnableComponent::Storage,␊ + }␊ + ␊ + #[event]␊ + #[derive(Drop, starknet::Event)]␊ + enum Event {␊ + #[flat]␊ + ERC20Event: ERC20Component::Event,␊ + #[flat]␊ + ERC20FlashMintEvent: ERC20FlashMintComponent::Event,␊ + #[flat]␊ + UpgradeableEvent: UpgradeableComponent::Event,␊ + #[flat]␊ + OwnableEvent: OwnableComponent::Event,␊ + }␊ + ␊ + #[constructor]␊ + fn constructor(ref self: ContractState, owner: ContractAddress) {␊ + self.erc20.initializer("MyToken", "MTK");␊ + self.ownable.initializer(owner);␊ + }␊ + ␊ + impl FlashMintConfigImpl of ERC20FlashMintComponent::FlashMintConfigTrait {␊ + fn flash_fee(␊ + self: @ERC20FlashMintComponent::ComponentState,␊ + token: ContractAddress,␊ + amount: u256,␊ + ) -> u256 {␊ + amount * 13725 / 1000000000␊ + }␊ + }␊ + ␊ + //␊ + // Upgradeable␊ + //␊ + ␊ + #[abi(embed_v0)]␊ + impl UpgradeableImpl of IUpgradeable {␊ + fn upgrade(ref self: ContractState, new_class_hash: ClassHash) {␊ + self.ownable.assert_only_owner();␊ + self.upgradeable.upgrade(new_class_hash);␊ + }␊ + }␊ + }␊ + ` + +## erc20 flash mint with percent fee and fee receiver + +> Snapshot 1 + + `// SPDX-License-Identifier: MIT␊ + // Compatible with OpenZeppelin Contracts for Cairo ^4.0.1␊ + ␊ + #[starknet::contract]␊ + mod MyToken {␊ + use core::num::traits::Zero;␊ + use openzeppelin_access::ownable::OwnableComponent;␊ + use openzeppelin_interfaces::upgrades::IUpgradeable;␊ + use openzeppelin_token::erc20::{␊ + DefaultConfig as ERC20DefaultConfig, ERC20Component, ERC20HooksEmptyImpl␊ + };␊ + use openzeppelin_token::erc20::extensions::erc20_flash_mint::ERC20FlashMintComponent;␊ + use openzeppelin_upgrades::UpgradeableComponent;␊ + use starknet::{ClassHash, ContractAddress};␊ + use starknet::storage::{StoragePointerReadAccess, StoragePointerWriteAccess};␊ + ␊ + component!(path: ERC20Component, storage: erc20, event: ERC20Event);␊ + component!(path: ERC20FlashMintComponent, storage: erc20_flash_mint, event: ERC20FlashMintEvent);␊ + component!(path: UpgradeableComponent, storage: upgradeable, event: UpgradeableEvent);␊ + component!(path: OwnableComponent, storage: ownable, event: OwnableEvent);␊ + ␊ + // External␊ + #[abi(embed_v0)]␊ + impl ERC20MixinImpl = ERC20Component::ERC20MixinImpl;␊ + #[abi(embed_v0)]␊ + impl ERC20FlashMintImpl = ERC20FlashMintComponent::ERC20FlashMintImpl;␊ + #[abi(embed_v0)]␊ + impl OwnableMixinImpl = OwnableComponent::OwnableMixinImpl;␊ + ␊ + // Internal␊ + impl ERC20InternalImpl = ERC20Component::InternalImpl;␊ + impl UpgradeableInternalImpl = UpgradeableComponent::InternalImpl;␊ + impl OwnableInternalImpl = OwnableComponent::InternalImpl;␊ + ␊ + #[storage]␊ + struct Storage {␊ + flash_fee_receiver: ContractAddress,␊ + #[substorage(v0)]␊ + erc20: ERC20Component::Storage,␊ + #[substorage(v0)]␊ + erc20_flash_mint: ERC20FlashMintComponent::Storage,␊ + #[substorage(v0)]␊ + upgradeable: UpgradeableComponent::Storage,␊ + #[substorage(v0)]␊ + ownable: OwnableComponent::Storage,␊ + }␊ + ␊ + #[event]␊ + #[derive(Drop, starknet::Event)]␊ + enum Event {␊ + #[flat]␊ + ERC20Event: ERC20Component::Event,␊ + #[flat]␊ + ERC20FlashMintEvent: ERC20FlashMintComponent::Event,␊ + #[flat]␊ + UpgradeableEvent: UpgradeableComponent::Event,␊ + #[flat]␊ + OwnableEvent: OwnableComponent::Event,␊ + }␊ + ␊ + #[constructor]␊ + fn constructor(␊ + ref self: ContractState,␊ + flash_fee_receiver: ContractAddress,␊ + owner: ContractAddress,␊ + ) {␊ + self.erc20.initializer("MyToken", "MTK");␊ + self.ownable.initializer(owner);␊ + ␊ + assert(!flash_fee_receiver.is_zero(), 'FlashMint: invalid receiver');␊ + self.flash_fee_receiver.write(flash_fee_receiver);␊ + }␊ + ␊ + impl FlashMintConfigImpl of ERC20FlashMintComponent::FlashMintConfigTrait {␊ + fn flash_fee(␊ + self: @ERC20FlashMintComponent::ComponentState,␊ + token: ContractAddress,␊ + amount: u256,␊ + ) -> u256 {␊ + amount * 5 / 100␊ + }␊ + ␊ + fn flash_fee_receiver(self: @ERC20FlashMintComponent::ComponentState) -> ContractAddress {␊ + self.get_contract().flash_fee_receiver.read()␊ + }␊ + }␊ + ␊ + //␊ + // Upgradeable␊ + //␊ + ␊ + #[abi(embed_v0)]␊ + impl UpgradeableImpl of IUpgradeable {␊ + fn upgrade(ref self: ContractState, new_class_hash: ClassHash) {␊ + self.ownable.assert_only_owner();␊ + self.upgradeable.upgrade(new_class_hash);␊ + }␊ + }␊ + }␊ + ` + +## erc20 flash mint with custom fee impl + +> Snapshot 1 + + `// SPDX-License-Identifier: MIT␊ + // Compatible with OpenZeppelin Contracts for Cairo ^4.0.1␊ + ␊ + #[starknet::contract]␊ + mod MyToken {␊ + use openzeppelin_access::ownable::OwnableComponent;␊ + use openzeppelin_interfaces::upgrades::IUpgradeable;␊ + use openzeppelin_token::erc20::{␊ + DefaultConfig as ERC20DefaultConfig, ERC20Component, ERC20HooksEmptyImpl␊ + };␊ + use openzeppelin_token::erc20::extensions::erc20_flash_mint::ERC20FlashMintComponent;␊ + use openzeppelin_upgrades::UpgradeableComponent;␊ + use starknet::{ClassHash, ContractAddress};␊ + ␊ + component!(path: ERC20Component, storage: erc20, event: ERC20Event);␊ + component!(path: ERC20FlashMintComponent, storage: erc20_flash_mint, event: ERC20FlashMintEvent);␊ + component!(path: UpgradeableComponent, storage: upgradeable, event: UpgradeableEvent);␊ + component!(path: OwnableComponent, storage: ownable, event: OwnableEvent);␊ + ␊ + // External␊ + #[abi(embed_v0)]␊ + impl ERC20MixinImpl = ERC20Component::ERC20MixinImpl;␊ + #[abi(embed_v0)]␊ + impl ERC20FlashMintImpl = ERC20FlashMintComponent::ERC20FlashMintImpl;␊ + #[abi(embed_v0)]␊ + impl OwnableMixinImpl = OwnableComponent::OwnableMixinImpl;␊ + ␊ + // Internal␊ + impl ERC20InternalImpl = ERC20Component::InternalImpl;␊ + impl UpgradeableInternalImpl = UpgradeableComponent::InternalImpl;␊ + impl OwnableInternalImpl = OwnableComponent::InternalImpl;␊ + ␊ + #[storage]␊ + struct Storage {␊ + #[substorage(v0)]␊ + erc20: ERC20Component::Storage,␊ + #[substorage(v0)]␊ + erc20_flash_mint: ERC20FlashMintComponent::Storage,␊ + #[substorage(v0)]␊ + upgradeable: UpgradeableComponent::Storage,␊ + #[substorage(v0)]␊ + ownable: OwnableComponent::Storage,␊ + }␊ + ␊ + #[event]␊ + #[derive(Drop, starknet::Event)]␊ + enum Event {␊ + #[flat]␊ + ERC20Event: ERC20Component::Event,␊ + #[flat]␊ + ERC20FlashMintEvent: ERC20FlashMintComponent::Event,␊ + #[flat]␊ + UpgradeableEvent: UpgradeableComponent::Event,␊ + #[flat]␊ + OwnableEvent: OwnableComponent::Event,␊ + }␊ + ␊ + #[constructor]␊ + fn constructor(ref self: ContractState, owner: ContractAddress) {␊ + self.erc20.initializer("MyToken", "MTK");␊ + self.ownable.initializer(owner);␊ + }␊ + ␊ + impl FlashMintConfigImpl of ERC20FlashMintComponent::FlashMintConfigTrait {␊ + fn flash_fee(␊ + self: @ERC20FlashMintComponent::ComponentState,␊ + token: ContractAddress,␊ + amount: u256,␊ + ) -> u256 {␊ + // TODO: Must be implemented according to the desired flash fee logic;␊ + 0␊ + }␊ + }␊ + ␊ + //␊ + // Upgradeable␊ + //␊ + ␊ + #[abi(embed_v0)]␊ + impl UpgradeableImpl of IUpgradeable {␊ + fn upgrade(ref self: ContractState, new_class_hash: ClassHash) {␊ + self.ownable.assert_only_owner();␊ + self.upgradeable.upgrade(new_class_hash);␊ + }␊ + }␊ + }␊ + ` + +## erc20 flash mint with all custom config + +> Snapshot 1 + + `// SPDX-License-Identifier: MIT␊ + // Compatible with OpenZeppelin Contracts for Cairo ^4.0.1␊ + ␊ + #[starknet::contract]␊ + mod MyToken {␊ + use core::num::traits::{Bounded, Zero};␊ + use openzeppelin_access::ownable::OwnableComponent;␊ + use openzeppelin_interfaces::upgrades::IUpgradeable;␊ + use openzeppelin_token::erc20::{␊ + DefaultConfig as ERC20DefaultConfig, ERC20Component, ERC20HooksEmptyImpl␊ + };␊ + use openzeppelin_token::erc20::extensions::erc20_flash_mint::ERC20FlashMintComponent;␊ + use openzeppelin_upgrades::UpgradeableComponent;␊ + use starknet::{ClassHash, ContractAddress, get_contract_address};␊ + use starknet::storage::{StoragePointerReadAccess, StoragePointerWriteAccess};␊ + ␊ + component!(path: ERC20Component, storage: erc20, event: ERC20Event);␊ + component!(path: ERC20FlashMintComponent, storage: erc20_flash_mint, event: ERC20FlashMintEvent);␊ + component!(path: UpgradeableComponent, storage: upgradeable, event: UpgradeableEvent);␊ + component!(path: OwnableComponent, storage: ownable, event: OwnableEvent);␊ + ␊ + // External␊ + #[abi(embed_v0)]␊ + impl ERC20MixinImpl = ERC20Component::ERC20MixinImpl;␊ + #[abi(embed_v0)]␊ + impl ERC20FlashMintImpl = ERC20FlashMintComponent::ERC20FlashMintImpl;␊ + #[abi(embed_v0)]␊ + impl OwnableMixinImpl = OwnableComponent::OwnableMixinImpl;␊ + ␊ + // Internal␊ + impl ERC20InternalImpl = ERC20Component::InternalImpl;␊ + impl UpgradeableInternalImpl = UpgradeableComponent::InternalImpl;␊ + impl OwnableInternalImpl = OwnableComponent::InternalImpl;␊ + ␊ + #[storage]␊ + struct Storage {␊ + flash_fee_receiver: ContractAddress,␊ + #[substorage(v0)]␊ + erc20: ERC20Component::Storage,␊ + #[substorage(v0)]␊ + erc20_flash_mint: ERC20FlashMintComponent::Storage,␊ + #[substorage(v0)]␊ + upgradeable: UpgradeableComponent::Storage,␊ + #[substorage(v0)]␊ + ownable: OwnableComponent::Storage,␊ + }␊ + ␊ + #[event]␊ + #[derive(Drop, starknet::Event)]␊ + enum Event {␊ + #[flat]␊ + ERC20Event: ERC20Component::Event,␊ + #[flat]␊ + ERC20FlashMintEvent: ERC20FlashMintComponent::Event,␊ + #[flat]␊ + UpgradeableEvent: UpgradeableComponent::Event,␊ + #[flat]␊ + OwnableEvent: OwnableComponent::Event,␊ + }␊ + ␊ + #[constructor]␊ + fn constructor(␊ + ref self: ContractState,␊ + flash_fee_receiver: ContractAddress,␊ + owner: ContractAddress,␊ + ) {␊ + self.erc20.initializer("MyToken", "MTK");␊ + self.ownable.initializer(owner);␊ + ␊ + assert(!flash_fee_receiver.is_zero(), 'FlashMint: invalid receiver');␊ + self.flash_fee_receiver.write(flash_fee_receiver);␊ + }␊ + ␊ + impl FlashMintConfigImpl of ERC20FlashMintComponent::FlashMintConfigTrait {␊ + fn max_flash_loan(␊ + self: @ERC20FlashMintComponent::ComponentState,␊ + token: ContractAddress,␊ + total_supply: u256,␊ + ) -> u256 {␊ + if token != get_contract_address() {␊ + return 0;␊ + }␊ + let headroom = Bounded::::MAX - total_supply;␊ + let cap: u256 = 1000000000000000000000000;␊ + if cap < headroom { cap } else { headroom }␊ + }␊ + ␊ + fn flash_fee(␊ + self: @ERC20FlashMintComponent::ComponentState,␊ + token: ContractAddress,␊ + amount: u256,␊ + ) -> u256 {␊ + amount * 5 / 100␊ + }␊ + ␊ + fn flash_fee_receiver(self: @ERC20FlashMintComponent::ComponentState) -> ContractAddress {␊ + self.get_contract().flash_fee_receiver.read()␊ + }␊ + }␊ + ␊ + //␊ + // Upgradeable␊ + //␊ + ␊ + #[abi(embed_v0)]␊ + impl UpgradeableImpl of IUpgradeable {␊ + fn upgrade(ref self: ContractState, new_class_hash: ClassHash) {␊ + self.ownable.assert_only_owner();␊ + self.upgradeable.upgrade(new_class_hash);␊ + }␊ + }␊ + }␊ + ` + +## erc20 flash mint with max amount of zero + +> Snapshot 1 + + `// SPDX-License-Identifier: MIT␊ + // Compatible with OpenZeppelin Contracts for Cairo ^4.0.1␊ + ␊ + #[starknet::contract]␊ + mod MyToken {␊ + use core::num::traits::Bounded;␊ + use openzeppelin_access::ownable::OwnableComponent;␊ + use openzeppelin_interfaces::upgrades::IUpgradeable;␊ + use openzeppelin_token::erc20::{␊ + DefaultConfig as ERC20DefaultConfig, ERC20Component, ERC20HooksEmptyImpl␊ + };␊ + use openzeppelin_token::erc20::extensions::erc20_flash_mint::ERC20FlashMintComponent;␊ + use openzeppelin_upgrades::UpgradeableComponent;␊ + use starknet::{ClassHash, ContractAddress, get_contract_address};␊ + ␊ + component!(path: ERC20Component, storage: erc20, event: ERC20Event);␊ + component!(path: ERC20FlashMintComponent, storage: erc20_flash_mint, event: ERC20FlashMintEvent);␊ + component!(path: UpgradeableComponent, storage: upgradeable, event: UpgradeableEvent);␊ + component!(path: OwnableComponent, storage: ownable, event: OwnableEvent);␊ + ␊ + // External␊ + #[abi(embed_v0)]␊ + impl ERC20MixinImpl = ERC20Component::ERC20MixinImpl;␊ + #[abi(embed_v0)]␊ + impl ERC20FlashMintImpl = ERC20FlashMintComponent::ERC20FlashMintImpl;␊ + #[abi(embed_v0)]␊ + impl OwnableMixinImpl = OwnableComponent::OwnableMixinImpl;␊ + ␊ + // Internal␊ + impl ERC20InternalImpl = ERC20Component::InternalImpl;␊ + impl UpgradeableInternalImpl = UpgradeableComponent::InternalImpl;␊ + impl OwnableInternalImpl = OwnableComponent::InternalImpl;␊ + ␊ + #[storage]␊ + struct Storage {␊ + #[substorage(v0)]␊ + erc20: ERC20Component::Storage,␊ + #[substorage(v0)]␊ + erc20_flash_mint: ERC20FlashMintComponent::Storage,␊ + #[substorage(v0)]␊ + upgradeable: UpgradeableComponent::Storage,␊ + #[substorage(v0)]␊ + ownable: OwnableComponent::Storage,␊ + }␊ + ␊ + #[event]␊ + #[derive(Drop, starknet::Event)]␊ + enum Event {␊ + #[flat]␊ + ERC20Event: ERC20Component::Event,␊ + #[flat]␊ + ERC20FlashMintEvent: ERC20FlashMintComponent::Event,␊ + #[flat]␊ + UpgradeableEvent: UpgradeableComponent::Event,␊ + #[flat]␊ + OwnableEvent: OwnableComponent::Event,␊ + }␊ + ␊ + #[constructor]␊ + fn constructor(ref self: ContractState, owner: ContractAddress) {␊ + self.erc20.initializer("MyToken", "MTK");␊ + self.ownable.initializer(owner);␊ + }␊ + ␊ + impl FlashMintConfigImpl of ERC20FlashMintComponent::FlashMintConfigTrait {␊ + fn max_flash_loan(␊ + self: @ERC20FlashMintComponent::ComponentState,␊ + token: ContractAddress,␊ + total_supply: u256,␊ + ) -> u256 {␊ + if token != get_contract_address() {␊ + return 0;␊ + }␊ + let headroom = Bounded::::MAX - total_supply;␊ + let cap: u256 = 0;␊ + if cap < headroom { cap } else { headroom }␊ + }␊ + }␊ + ␊ + //␊ + // Upgradeable␊ + //␊ + ␊ + #[abi(embed_v0)]␊ + impl UpgradeableImpl of IUpgradeable {␊ + fn upgrade(ref self: ContractState, new_class_hash: ClassHash) {␊ + self.ownable.assert_only_owner();␊ + self.upgradeable.upgrade(new_class_hash);␊ + }␊ + }␊ + }␊ + ` + ## erc20 preminted > Snapshot 1 diff --git a/packages/core/cairo/src/tests/with_components_off/erc20/erc20.test.ts.snap b/packages/core/cairo/src/tests/with_components_off/erc20/erc20.test.ts.snap index 42b7d04e4..9447a01a8 100644 Binary files a/packages/core/cairo/src/tests/with_components_off/erc20/erc20.test.ts.snap and b/packages/core/cairo/src/tests/with_components_off/erc20/erc20.test.ts.snap differ diff --git a/packages/core/cairo/src/tests/with_components_on/erc20/erc20.test.ts b/packages/core/cairo/src/tests/with_components_on/erc20/erc20.test.ts index c84a85015..ecd63988e 100644 --- a/packages/core/cairo/src/tests/with_components_on/erc20/erc20.test.ts +++ b/packages/core/cairo/src/tests/with_components_on/erc20/erc20.test.ts @@ -1,7 +1,7 @@ import test from 'ava'; import type { ERC20Options } from '../../../erc20'; -import { buildERC20, getInitialSupply, defaults } from '../../../erc20'; +import { buildERC20, defaults, flashMintDefaults, getInitialSupply } from '../../../erc20'; import { printContract } from '../../../print'; import { AccessControl, darDefaultOpts, darCustomOpts } from '../../../set-access-control'; @@ -71,6 +71,128 @@ testERC20('erc20 wrapper', { wrapper: true, }); +testERC20('erc20 flash mint', { + flashmint: { ...flashMintDefaults, enabled: true }, +}); + +testERC20('erc20 flash mint with custom max', { + flashmint: { ...flashMintDefaults, enabled: true, maxAmount: '1000000' }, +}); + +testERC20('erc20 flash mint with percent fee', { + flashmint: { ...flashMintDefaults, enabled: true, feeMode: 'percent', feePercent: '5' }, +}); + +testERC20('erc20 flash mint with fractional percent fee', { + flashmint: { ...flashMintDefaults, enabled: true, feeMode: 'percent', feePercent: '0.0013725' }, +}); + +testERC20('erc20 flash mint with percent fee and fee receiver', { + flashmint: { + ...flashMintDefaults, + enabled: true, + feeMode: 'percent', + feePercent: '5', + feeDestination: 'fee_receiver', + }, +}); + +testERC20('erc20 flash mint with custom fee impl', { + flashmint: { ...flashMintDefaults, enabled: true, feeMode: 'custom' }, +}); + +testERC20('erc20 flash mint with all custom config', { + flashmint: { + enabled: true, + maxAmount: '1000000', + feeMode: 'percent', + feePercent: '5', + feeDestination: 'fee_receiver', + }, +}); + +testERC20('erc20 flash mint with max amount of zero', { + flashmint: { ...flashMintDefaults, enabled: true, maxAmount: '0' }, +}); + +test('erc20 flash mint, max amount empty string', async t => { + const error = t.throws(() => + buildERC20({ + name: 'MyToken', + symbol: 'MTK', + flashmint: { ...flashMintDefaults, enabled: true, maxAmount: '' }, + }), + ); + t.is((error as OptionsError).messages.flashMintMaxAmount, 'Must be "max" or a non-negative number'); +}); + +test('erc20 flash mint, invalid max amount', async t => { + const error = t.throws(() => + buildERC20({ + name: 'MyToken', + symbol: 'MTK', + flashmint: { ...flashMintDefaults, enabled: true, maxAmount: 'abc' }, + }), + ); + t.is((error as OptionsError).messages.flashMintMaxAmount, 'Must be "max" or a non-negative number'); +}); + +test('erc20 flash mint, invalid percent fee', async t => { + const error = t.throws(() => + buildERC20({ + name: 'MyToken', + symbol: 'MTK', + flashmint: { ...flashMintDefaults, enabled: true, feeMode: 'percent', feePercent: 'abc' }, + }), + ); + t.is((error as OptionsError).messages.flashMintFeePercent, 'Must be a number between 0 and 100'); +}); + +test('erc20 flash mint, percent fee out of range', async t => { + const error = t.throws(() => + buildERC20({ + name: 'MyToken', + symbol: 'MTK', + flashmint: { ...flashMintDefaults, enabled: true, feeMode: 'percent', feePercent: '100.5' }, + }), + ); + t.is((error as OptionsError).messages.flashMintFeePercent, 'Must be a number between 0 and 100'); +}); + +test('erc20 flash mint, max amount overflows u256', async t => { + const error = t.throws(() => + buildERC20({ + name: 'MyToken', + symbol: 'MTK', + decimals: '0', + flashmint: { + ...flashMintDefaults, + enabled: true, + // 2^256 — one past u256::MAX with decimals: '0' so getInitialSupply preserves the value. + maxAmount: '115792089237316195423570985008687907853269984665640564039457584007913129639936', + }, + }), + ); + t.is((error as OptionsError).messages.flashMintMaxAmount, 'Value is greater than u256 max value'); +}); + +test('erc20 flash mint, percent denominator overflows u256', async t => { + const error = t.throws(() => + buildERC20({ + name: 'MyToken', + symbol: 'MTK', + flashmint: { + ...flashMintDefaults, + enabled: true, + feeMode: 'percent', + // 77 fractional digits — 100 * 10^77 > u256::MAX. + feePercent: '0.' + '0'.repeat(76) + '1', + }, + }), + ); + t.is((error as OptionsError).messages.flashMintFeePercent, 'Value is greater than u256 max value'); +}); + testERC20('erc20 preminted', { premint: '1000', }); diff --git a/packages/core/cairo/src/tests/with_components_on/erc20/erc20.test.ts.md b/packages/core/cairo/src/tests/with_components_on/erc20/erc20.test.ts.md index 27b5372e2..8e75ac0df 100644 --- a/packages/core/cairo/src/tests/with_components_on/erc20/erc20.test.ts.md +++ b/packages/core/cairo/src/tests/with_components_on/erc20/erc20.test.ts.md @@ -589,6 +589,493 @@ Generated by [AVA](https://avajs.dev). }␊ ` +## erc20 flash mint + +> Snapshot 1 + + `// SPDX-License-Identifier: MIT␊ + // Compatible with OpenZeppelin Contracts for Cairo ^4.0.1␊ + ␊ + #[starknet::contract]␊ + #[with_components(ERC20, ERC20FlashMint, Upgradeable, Ownable)]␊ + mod MyToken {␊ + use openzeppelin_interfaces::upgrades::IUpgradeable;␊ + use openzeppelin_token::erc20::{DefaultConfig as ERC20DefaultConfig, ERC20HooksEmptyImpl};␊ + use openzeppelin_token::erc20::extensions::erc20_flash_mint::DefaultConfig as ERC20FlashMintDefaultConfig;␊ + use starknet::{ClassHash, ContractAddress};␊ + ␊ + // External␊ + #[abi(embed_v0)]␊ + impl ERC20MixinImpl = ERC20Component::ERC20MixinImpl;␊ + #[abi(embed_v0)]␊ + impl ERC20FlashMintImpl = ERC20FlashMintComponent::ERC20FlashMintImpl;␊ + #[abi(embed_v0)]␊ + impl OwnableMixinImpl = OwnableComponent::OwnableMixinImpl;␊ + ␊ + #[storage]␊ + struct Storage {}␊ + ␊ + #[constructor]␊ + fn constructor(ref self: ContractState, owner: ContractAddress) {␊ + self.erc20.initializer("MyToken", "MTK");␊ + self.ownable.initializer(owner);␊ + }␊ + ␊ + //␊ + // Upgradeable␊ + //␊ + ␊ + #[abi(embed_v0)]␊ + impl UpgradeableImpl of IUpgradeable {␊ + fn upgrade(ref self: ContractState, new_class_hash: ClassHash) {␊ + self.ownable.assert_only_owner();␊ + self.upgradeable.upgrade(new_class_hash);␊ + }␊ + }␊ + }␊ + ` + +## erc20 flash mint with custom max + +> Snapshot 1 + + `// SPDX-License-Identifier: MIT␊ + // Compatible with OpenZeppelin Contracts for Cairo ^4.0.1␊ + ␊ + #[starknet::contract]␊ + #[with_components(ERC20, ERC20FlashMint, Upgradeable, Ownable)]␊ + mod MyToken {␊ + use core::num::traits::Bounded;␊ + use openzeppelin_interfaces::upgrades::IUpgradeable;␊ + use openzeppelin_token::erc20::{DefaultConfig as ERC20DefaultConfig, ERC20HooksEmptyImpl};␊ + use starknet::{ClassHash, ContractAddress, get_contract_address};␊ + ␊ + // External␊ + #[abi(embed_v0)]␊ + impl ERC20MixinImpl = ERC20Component::ERC20MixinImpl;␊ + #[abi(embed_v0)]␊ + impl ERC20FlashMintImpl = ERC20FlashMintComponent::ERC20FlashMintImpl;␊ + #[abi(embed_v0)]␊ + impl OwnableMixinImpl = OwnableComponent::OwnableMixinImpl;␊ + ␊ + #[storage]␊ + struct Storage {}␊ + ␊ + #[constructor]␊ + fn constructor(ref self: ContractState, owner: ContractAddress) {␊ + self.erc20.initializer("MyToken", "MTK");␊ + self.ownable.initializer(owner);␊ + }␊ + ␊ + impl FlashMintConfigImpl of ERC20FlashMintComponent::FlashMintConfigTrait {␊ + fn max_flash_loan(␊ + self: @ERC20FlashMintComponent::ComponentState,␊ + token: ContractAddress,␊ + total_supply: u256,␊ + ) -> u256 {␊ + if token != get_contract_address() {␊ + return 0;␊ + }␊ + let headroom = Bounded::::MAX - total_supply;␊ + let cap: u256 = 1000000000000000000000000;␊ + if cap < headroom { cap } else { headroom }␊ + }␊ + }␊ + ␊ + //␊ + // Upgradeable␊ + //␊ + ␊ + #[abi(embed_v0)]␊ + impl UpgradeableImpl of IUpgradeable {␊ + fn upgrade(ref self: ContractState, new_class_hash: ClassHash) {␊ + self.ownable.assert_only_owner();␊ + self.upgradeable.upgrade(new_class_hash);␊ + }␊ + }␊ + }␊ + ` + +## erc20 flash mint with percent fee + +> Snapshot 1 + + `// SPDX-License-Identifier: MIT␊ + // Compatible with OpenZeppelin Contracts for Cairo ^4.0.1␊ + ␊ + #[starknet::contract]␊ + #[with_components(ERC20, ERC20FlashMint, Upgradeable, Ownable)]␊ + mod MyToken {␊ + use openzeppelin_interfaces::upgrades::IUpgradeable;␊ + use openzeppelin_token::erc20::{DefaultConfig as ERC20DefaultConfig, ERC20HooksEmptyImpl};␊ + use starknet::{ClassHash, ContractAddress};␊ + ␊ + // External␊ + #[abi(embed_v0)]␊ + impl ERC20MixinImpl = ERC20Component::ERC20MixinImpl;␊ + #[abi(embed_v0)]␊ + impl ERC20FlashMintImpl = ERC20FlashMintComponent::ERC20FlashMintImpl;␊ + #[abi(embed_v0)]␊ + impl OwnableMixinImpl = OwnableComponent::OwnableMixinImpl;␊ + ␊ + #[storage]␊ + struct Storage {}␊ + ␊ + #[constructor]␊ + fn constructor(ref self: ContractState, owner: ContractAddress) {␊ + self.erc20.initializer("MyToken", "MTK");␊ + self.ownable.initializer(owner);␊ + }␊ + ␊ + impl FlashMintConfigImpl of ERC20FlashMintComponent::FlashMintConfigTrait {␊ + fn flash_fee(␊ + self: @ERC20FlashMintComponent::ComponentState,␊ + token: ContractAddress,␊ + amount: u256,␊ + ) -> u256 {␊ + amount * 5 / 100␊ + }␊ + }␊ + ␊ + //␊ + // Upgradeable␊ + //␊ + ␊ + #[abi(embed_v0)]␊ + impl UpgradeableImpl of IUpgradeable {␊ + fn upgrade(ref self: ContractState, new_class_hash: ClassHash) {␊ + self.ownable.assert_only_owner();␊ + self.upgradeable.upgrade(new_class_hash);␊ + }␊ + }␊ + }␊ + ` + +## erc20 flash mint with fractional percent fee + +> Snapshot 1 + + `// SPDX-License-Identifier: MIT␊ + // Compatible with OpenZeppelin Contracts for Cairo ^4.0.1␊ + ␊ + #[starknet::contract]␊ + #[with_components(ERC20, ERC20FlashMint, Upgradeable, Ownable)]␊ + mod MyToken {␊ + use openzeppelin_interfaces::upgrades::IUpgradeable;␊ + use openzeppelin_token::erc20::{DefaultConfig as ERC20DefaultConfig, ERC20HooksEmptyImpl};␊ + use starknet::{ClassHash, ContractAddress};␊ + ␊ + // External␊ + #[abi(embed_v0)]␊ + impl ERC20MixinImpl = ERC20Component::ERC20MixinImpl;␊ + #[abi(embed_v0)]␊ + impl ERC20FlashMintImpl = ERC20FlashMintComponent::ERC20FlashMintImpl;␊ + #[abi(embed_v0)]␊ + impl OwnableMixinImpl = OwnableComponent::OwnableMixinImpl;␊ + ␊ + #[storage]␊ + struct Storage {}␊ + ␊ + #[constructor]␊ + fn constructor(ref self: ContractState, owner: ContractAddress) {␊ + self.erc20.initializer("MyToken", "MTK");␊ + self.ownable.initializer(owner);␊ + }␊ + ␊ + impl FlashMintConfigImpl of ERC20FlashMintComponent::FlashMintConfigTrait {␊ + fn flash_fee(␊ + self: @ERC20FlashMintComponent::ComponentState,␊ + token: ContractAddress,␊ + amount: u256,␊ + ) -> u256 {␊ + amount * 13725 / 1000000000␊ + }␊ + }␊ + ␊ + //␊ + // Upgradeable␊ + //␊ + ␊ + #[abi(embed_v0)]␊ + impl UpgradeableImpl of IUpgradeable {␊ + fn upgrade(ref self: ContractState, new_class_hash: ClassHash) {␊ + self.ownable.assert_only_owner();␊ + self.upgradeable.upgrade(new_class_hash);␊ + }␊ + }␊ + }␊ + ` + +## erc20 flash mint with percent fee and fee receiver + +> Snapshot 1 + + `// SPDX-License-Identifier: MIT␊ + // Compatible with OpenZeppelin Contracts for Cairo ^4.0.1␊ + ␊ + #[starknet::contract]␊ + #[with_components(ERC20, ERC20FlashMint, Upgradeable, Ownable)]␊ + mod MyToken {␊ + use core::num::traits::Zero;␊ + use openzeppelin_interfaces::upgrades::IUpgradeable;␊ + use openzeppelin_token::erc20::{DefaultConfig as ERC20DefaultConfig, ERC20HooksEmptyImpl};␊ + use starknet::{ClassHash, ContractAddress};␊ + use starknet::storage::{StoragePointerReadAccess, StoragePointerWriteAccess};␊ + ␊ + // External␊ + #[abi(embed_v0)]␊ + impl ERC20MixinImpl = ERC20Component::ERC20MixinImpl;␊ + #[abi(embed_v0)]␊ + impl ERC20FlashMintImpl = ERC20FlashMintComponent::ERC20FlashMintImpl;␊ + #[abi(embed_v0)]␊ + impl OwnableMixinImpl = OwnableComponent::OwnableMixinImpl;␊ + ␊ + #[storage]␊ + struct Storage {␊ + flash_fee_receiver: ContractAddress,␊ + }␊ + ␊ + #[constructor]␊ + fn constructor(␊ + ref self: ContractState,␊ + flash_fee_receiver: ContractAddress,␊ + owner: ContractAddress,␊ + ) {␊ + self.erc20.initializer("MyToken", "MTK");␊ + self.ownable.initializer(owner);␊ + ␊ + assert(!flash_fee_receiver.is_zero(), 'FlashMint: invalid receiver');␊ + self.flash_fee_receiver.write(flash_fee_receiver);␊ + }␊ + ␊ + impl FlashMintConfigImpl of ERC20FlashMintComponent::FlashMintConfigTrait {␊ + fn flash_fee(␊ + self: @ERC20FlashMintComponent::ComponentState,␊ + token: ContractAddress,␊ + amount: u256,␊ + ) -> u256 {␊ + amount * 5 / 100␊ + }␊ + ␊ + fn flash_fee_receiver(self: @ERC20FlashMintComponent::ComponentState) -> ContractAddress {␊ + self.get_contract().flash_fee_receiver.read()␊ + }␊ + }␊ + ␊ + //␊ + // Upgradeable␊ + //␊ + ␊ + #[abi(embed_v0)]␊ + impl UpgradeableImpl of IUpgradeable {␊ + fn upgrade(ref self: ContractState, new_class_hash: ClassHash) {␊ + self.ownable.assert_only_owner();␊ + self.upgradeable.upgrade(new_class_hash);␊ + }␊ + }␊ + }␊ + ` + +## erc20 flash mint with custom fee impl + +> Snapshot 1 + + `// SPDX-License-Identifier: MIT␊ + // Compatible with OpenZeppelin Contracts for Cairo ^4.0.1␊ + ␊ + #[starknet::contract]␊ + #[with_components(ERC20, ERC20FlashMint, Upgradeable, Ownable)]␊ + mod MyToken {␊ + use openzeppelin_interfaces::upgrades::IUpgradeable;␊ + use openzeppelin_token::erc20::{DefaultConfig as ERC20DefaultConfig, ERC20HooksEmptyImpl};␊ + use starknet::{ClassHash, ContractAddress};␊ + ␊ + // External␊ + #[abi(embed_v0)]␊ + impl ERC20MixinImpl = ERC20Component::ERC20MixinImpl;␊ + #[abi(embed_v0)]␊ + impl ERC20FlashMintImpl = ERC20FlashMintComponent::ERC20FlashMintImpl;␊ + #[abi(embed_v0)]␊ + impl OwnableMixinImpl = OwnableComponent::OwnableMixinImpl;␊ + ␊ + #[storage]␊ + struct Storage {}␊ + ␊ + #[constructor]␊ + fn constructor(ref self: ContractState, owner: ContractAddress) {␊ + self.erc20.initializer("MyToken", "MTK");␊ + self.ownable.initializer(owner);␊ + }␊ + ␊ + impl FlashMintConfigImpl of ERC20FlashMintComponent::FlashMintConfigTrait {␊ + fn flash_fee(␊ + self: @ERC20FlashMintComponent::ComponentState,␊ + token: ContractAddress,␊ + amount: u256,␊ + ) -> u256 {␊ + // TODO: Must be implemented according to the desired flash fee logic;␊ + 0␊ + }␊ + }␊ + ␊ + //␊ + // Upgradeable␊ + //␊ + ␊ + #[abi(embed_v0)]␊ + impl UpgradeableImpl of IUpgradeable {␊ + fn upgrade(ref self: ContractState, new_class_hash: ClassHash) {␊ + self.ownable.assert_only_owner();␊ + self.upgradeable.upgrade(new_class_hash);␊ + }␊ + }␊ + }␊ + ` + +## erc20 flash mint with all custom config + +> Snapshot 1 + + `// SPDX-License-Identifier: MIT␊ + // Compatible with OpenZeppelin Contracts for Cairo ^4.0.1␊ + ␊ + #[starknet::contract]␊ + #[with_components(ERC20, ERC20FlashMint, Upgradeable, Ownable)]␊ + mod MyToken {␊ + use core::num::traits::{Bounded, Zero};␊ + use openzeppelin_interfaces::upgrades::IUpgradeable;␊ + use openzeppelin_token::erc20::{DefaultConfig as ERC20DefaultConfig, ERC20HooksEmptyImpl};␊ + use starknet::{ClassHash, ContractAddress, get_contract_address};␊ + use starknet::storage::{StoragePointerReadAccess, StoragePointerWriteAccess};␊ + ␊ + // External␊ + #[abi(embed_v0)]␊ + impl ERC20MixinImpl = ERC20Component::ERC20MixinImpl;␊ + #[abi(embed_v0)]␊ + impl ERC20FlashMintImpl = ERC20FlashMintComponent::ERC20FlashMintImpl;␊ + #[abi(embed_v0)]␊ + impl OwnableMixinImpl = OwnableComponent::OwnableMixinImpl;␊ + ␊ + #[storage]␊ + struct Storage {␊ + flash_fee_receiver: ContractAddress,␊ + }␊ + ␊ + #[constructor]␊ + fn constructor(␊ + ref self: ContractState,␊ + flash_fee_receiver: ContractAddress,␊ + owner: ContractAddress,␊ + ) {␊ + self.erc20.initializer("MyToken", "MTK");␊ + self.ownable.initializer(owner);␊ + ␊ + assert(!flash_fee_receiver.is_zero(), 'FlashMint: invalid receiver');␊ + self.flash_fee_receiver.write(flash_fee_receiver);␊ + }␊ + ␊ + impl FlashMintConfigImpl of ERC20FlashMintComponent::FlashMintConfigTrait {␊ + fn max_flash_loan(␊ + self: @ERC20FlashMintComponent::ComponentState,␊ + token: ContractAddress,␊ + total_supply: u256,␊ + ) -> u256 {␊ + if token != get_contract_address() {␊ + return 0;␊ + }␊ + let headroom = Bounded::::MAX - total_supply;␊ + let cap: u256 = 1000000000000000000000000;␊ + if cap < headroom { cap } else { headroom }␊ + }␊ + ␊ + fn flash_fee(␊ + self: @ERC20FlashMintComponent::ComponentState,␊ + token: ContractAddress,␊ + amount: u256,␊ + ) -> u256 {␊ + amount * 5 / 100␊ + }␊ + ␊ + fn flash_fee_receiver(self: @ERC20FlashMintComponent::ComponentState) -> ContractAddress {␊ + self.get_contract().flash_fee_receiver.read()␊ + }␊ + }␊ + ␊ + //␊ + // Upgradeable␊ + //␊ + ␊ + #[abi(embed_v0)]␊ + impl UpgradeableImpl of IUpgradeable {␊ + fn upgrade(ref self: ContractState, new_class_hash: ClassHash) {␊ + self.ownable.assert_only_owner();␊ + self.upgradeable.upgrade(new_class_hash);␊ + }␊ + }␊ + }␊ + ` + +## erc20 flash mint with max amount of zero + +> Snapshot 1 + + `// SPDX-License-Identifier: MIT␊ + // Compatible with OpenZeppelin Contracts for Cairo ^4.0.1␊ + ␊ + #[starknet::contract]␊ + #[with_components(ERC20, ERC20FlashMint, Upgradeable, Ownable)]␊ + mod MyToken {␊ + use core::num::traits::Bounded;␊ + use openzeppelin_interfaces::upgrades::IUpgradeable;␊ + use openzeppelin_token::erc20::{DefaultConfig as ERC20DefaultConfig, ERC20HooksEmptyImpl};␊ + use starknet::{ClassHash, ContractAddress, get_contract_address};␊ + ␊ + // External␊ + #[abi(embed_v0)]␊ + impl ERC20MixinImpl = ERC20Component::ERC20MixinImpl;␊ + #[abi(embed_v0)]␊ + impl ERC20FlashMintImpl = ERC20FlashMintComponent::ERC20FlashMintImpl;␊ + #[abi(embed_v0)]␊ + impl OwnableMixinImpl = OwnableComponent::OwnableMixinImpl;␊ + ␊ + #[storage]␊ + struct Storage {}␊ + ␊ + #[constructor]␊ + fn constructor(ref self: ContractState, owner: ContractAddress) {␊ + self.erc20.initializer("MyToken", "MTK");␊ + self.ownable.initializer(owner);␊ + }␊ + ␊ + impl FlashMintConfigImpl of ERC20FlashMintComponent::FlashMintConfigTrait {␊ + fn max_flash_loan(␊ + self: @ERC20FlashMintComponent::ComponentState,␊ + token: ContractAddress,␊ + total_supply: u256,␊ + ) -> u256 {␊ + if token != get_contract_address() {␊ + return 0;␊ + }␊ + let headroom = Bounded::::MAX - total_supply;␊ + let cap: u256 = 0;␊ + if cap < headroom { cap } else { headroom }␊ + }␊ + }␊ + ␊ + //␊ + // Upgradeable␊ + //␊ + ␊ + #[abi(embed_v0)]␊ + impl UpgradeableImpl of IUpgradeable {␊ + fn upgrade(ref self: ContractState, new_class_hash: ClassHash) {␊ + self.ownable.assert_only_owner();␊ + self.upgradeable.upgrade(new_class_hash);␊ + }␊ + }␊ + }␊ + ` + ## erc20 preminted > Snapshot 1 diff --git a/packages/core/cairo/src/tests/with_components_on/erc20/erc20.test.ts.snap b/packages/core/cairo/src/tests/with_components_on/erc20/erc20.test.ts.snap index f064e2142..ffc3117e0 100644 Binary files a/packages/core/cairo/src/tests/with_components_on/erc20/erc20.test.ts.snap and b/packages/core/cairo/src/tests/with_components_on/erc20/erc20.test.ts.snap differ diff --git a/packages/core/cairo_alpha/CHANGELOG.md b/packages/core/cairo_alpha/CHANGELOG.md index 08f145b32..1e4ca8a44 100644 --- a/packages/core/cairo_alpha/CHANGELOG.md +++ b/packages/core/cairo_alpha/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## Unreleased + +- Add ERC20FlashMint extension ([#801](https://github.com/OpenZeppelin/contracts-wizard/pull/801)) +- Improve performance of premint amount parsing ([#872](https://github.com/OpenZeppelin/contracts-wizard/pull/872)) + ## 4.0.1 (2026-08-31) - Add ERC6909 contract kind ([#771](https://github.com/OpenZeppelin/contracts-wizard/pull/771)) diff --git a/packages/core/cairo_alpha/src/contract.ts b/packages/core/cairo_alpha/src/contract.ts index 2543086dc..759503eb6 100644 --- a/packages/core/cairo_alpha/src/contract.ts +++ b/packages/core/cairo_alpha/src/contract.ts @@ -15,6 +15,12 @@ export interface Contract { upgradeable: boolean; implementedTraits: ImplementedTrait[]; superVariables: Variable[]; + storageMembers: StorageMember[]; +} + +export interface StorageMember { + name: string; + type: string; } export type Value = string | number | bigint | { lit: string } | { note: string; value: Value }; @@ -118,6 +124,7 @@ export class ContractBuilder implements Contract { private constantsMap: Map = new Map(); private useClausesMap: Map = new Map(); private interfaceFlagsSet: Set = new Set(); + private storageMembersMap: Map = new Map(); constructor(name: string, macros: MacrosOptions, account: boolean = false) { this.name = toIdentifier(name, true); @@ -145,6 +152,24 @@ export class ContractBuilder implements Contract { return [...this.useClausesMap.values()]; } + get storageMembers(): StorageMember[] { + return [...this.storageMembersMap.values()]; + } + + addStorageMember(member: StorageMember): boolean { + const existing = this.storageMembersMap.get(member.name); + if (existing !== undefined) { + if (existing.type !== member.type) { + throw new Error( + `Tried to add duplicate storage member ${member.name} with different type: ${member.type} instead of ${existing.type}.`, + ); + } + return false; + } + this.storageMembersMap.set(member.name, member); + return true; + } + /** * Custom flags to denote that the contract implements a specific interface, e.g. ISRC5, to avoid duplicates **/ diff --git a/packages/core/cairo_alpha/src/erc20.ts b/packages/core/cairo_alpha/src/erc20.ts index 0db80febe..cae4ea6a8 100644 --- a/packages/core/cairo_alpha/src/erc20.ts +++ b/packages/core/cairo_alpha/src/erc20.ts @@ -18,6 +18,66 @@ import { addVotesComponent } from './common-components'; const DEFAULT_DECIMALS = BigInt(18); +export type FlashMintFeeMode = 'percent' | 'custom'; +export type FlashMintFeeDestination = 'burn' | 'fee_receiver'; + +export type FlashMintOptions = { + enabled: boolean; + maxAmount: string; + feeMode: FlashMintFeeMode; + feePercent: string; + feeDestination: FlashMintFeeDestination; +}; + +export const flashMintDefaults: FlashMintOptions = { + enabled: false, + maxAmount: 'max', + feeMode: 'percent', + feePercent: '0', + feeDestination: 'burn', +}; + +export type FlashMintSubset = 'all' | 'disabled' | 'enabled-default' | 'enabled-percent-fee' | 'enabled-custom-fee'; + +export const flashMintOptions = { + disabled: flashMintDefaults, + enabledDefault: { ...flashMintDefaults, enabled: true } satisfies FlashMintOptions, + enabledPercentFee: { + enabled: true, + maxAmount: '1000000', + feeMode: 'percent', + feePercent: '0.5', + feeDestination: 'fee_receiver', + } satisfies FlashMintOptions, + enabledCustomFee: { + enabled: true, + maxAmount: 'max', + feeMode: 'custom', + feePercent: '0', + feeDestination: 'fee_receiver', + } satisfies FlashMintOptions, +}; + +export function resolveFlashMintOptionsSubset(subset: FlashMintSubset): FlashMintOptions[] { + const { disabled, enabledDefault, enabledPercentFee, enabledCustomFee } = flashMintOptions; + switch (subset) { + case 'all': + return [disabled, enabledDefault, enabledPercentFee, enabledCustomFee]; + case 'disabled': + return [disabled]; + case 'enabled-default': + return [enabledDefault]; + case 'enabled-percent-fee': + return [enabledPercentFee]; + case 'enabled-custom-fee': + return [enabledCustomFee]; + default: { + const _: never = subset; + throw new Error('Unknown FlashMintSubset'); + } + } +} + export const defaults: Required = { name: 'MyToken', symbol: 'MTK', @@ -30,6 +90,7 @@ export const defaults: Required = { votes: false, appName: '', // Defaults to empty string, but user must provide a non-empty value if votes are enabled appVersion: 'v1', + flashmint: flashMintDefaults, access: commonDefaults.access, upgradeable: commonDefaults.upgradeable, info: commonDefaults.info, @@ -52,6 +113,7 @@ export interface ERC20Options extends CommonContractOptions { votes?: boolean; appName?: string; appVersion?: string; + flashmint?: FlashMintOptions; } function withDefaults(opts: ERC20Options): Required { @@ -67,6 +129,7 @@ function withDefaults(opts: ERC20Options): Required { votes: opts.votes ?? defaults.votes, appName: opts.appName ?? defaults.appName, appVersion: opts.appVersion ?? defaults.appVersion, + flashmint: opts.flashmint ?? defaults.flashmint, }; } @@ -102,6 +165,10 @@ export function buildERC20(opts: ERC20Options): Contract { addWrapper(c); } + if (allOpts.flashmint.enabled) { + addFlashMint(c, allOpts.flashmint, decimals); + } + addHooks(c, allOpts); setAccessControl(c, allOpts.access); @@ -224,7 +291,7 @@ function addBurnable(c: ContractBuilder) { c.addFunction(externalTrait, functions.burn); } -export const premintPattern = /^(\d*\.?\d*)$/; +export const premintPattern = /^\d*(?:\.\d*)?$/; function addPremint(c: ContractBuilder, amount: string, decimals: bigint) { if (amount !== undefined && amount !== '0') { @@ -294,6 +361,145 @@ function addWrapper(c: ContractBuilder) { c.addComponent(components.ERC20WrapperComponent, [{ lit: 'underlying' }], true); } +function parseFlashMintMaxAmount(value: string, decimals: bigint): bigint | null { + if (value === 'max') { + return null; + } + if (value === '' || !premintPattern.test(value)) { + throw new OptionsError({ flashMintMaxAmount: 'Must be "max" or a non-negative number' }); + } + return toUint(getInitialSupply(value, Number(decimals)), 'flashMintMaxAmount', 'u256'); +} + +function parseFlashMintFeePercent(value: string): { numerator: bigint; denominator: bigint } | null { + if (value === '') { + return null; + } + if (!premintPattern.test(value)) { + throw new OptionsError({ flashMintFeePercent: 'Must be a number between 0 and 100' }); + } + const [intPart = '', fracPart = ''] = value.split('.'); + const decimalDigits = fracPart.length; + const combined = (intPart + fracPart).replace(/^0+/, ''); + if (combined === '') { + return null; + } + const numerator = BigInt(combined); + const decimalScale = 10n ** BigInt(decimalDigits); + // value = numerator / decimalScale, must be <= 100 + if (numerator > 100n * decimalScale) { + throw new OptionsError({ flashMintFeePercent: 'Must be a number between 0 and 100' }); + } + // For percent of amount: amount * value / 100 = amount * numerator / (100 * decimalScale). + // Both literals are emitted into Cairo and must fit u256; numerator <= denominator from the + // check above, so bounding the denominator is sufficient. + const denominator = 100n * decimalScale; + toUint(denominator.toString(), 'flashMintFeePercent', 'u256'); + return { numerator, denominator }; +} + +function buildFlashFeeOverrideBody(opts: FlashMintOptions): string[] | null { + switch (opts.feeMode) { + case 'percent': { + const parsed = parseFlashMintFeePercent(opts.feePercent); + if (parsed === null) { + return null; + } + return [`amount * ${parsed.numerator} / ${parsed.denominator}`]; + } + case 'custom': + return ['// TODO: Must be implemented according to the desired flash fee logic', '0']; + default: { + const _: never = opts.feeMode; + throw new Error(`Unknown flashMintFeeMode: ${_}`); + } + } +} + +function addFlashMint(c: ContractBuilder, opts: FlashMintOptions, decimals: bigint) { + c.addComponent(components.ERC20FlashMintComponent, [], false); + + const customMax = parseFlashMintMaxAmount(opts.maxAmount, decimals); + const overridesMax = customMax !== null; + const overridesReceiver = opts.feeDestination === 'fee_receiver'; + const feeOverrideBody = buildFlashFeeOverrideBody(opts); + const overridesFee = feeOverrideBody !== null; + + if (!overridesMax && !overridesFee && !overridesReceiver) { + c.addUseClause('openzeppelin_token::erc20::extensions::erc20_flash_mint', 'DefaultConfig', { + alias: 'ERC20FlashMintDefaultConfig', + }); + return; + } + + const flashMintConfigTrait: BaseImplementedTrait = { + name: 'FlashMintConfigImpl', + of: 'ERC20FlashMintComponent::FlashMintConfigTrait', + tags: [], + }; + c.addImplementedTrait(flashMintConfigTrait); + + if (overridesMax || overridesFee || overridesReceiver) { + c.addUseClause('starknet', 'ContractAddress'); + } + + if (overridesMax) { + c.addUseClause('starknet', 'get_contract_address'); + c.addUseClause('core::num::traits', 'Bounded'); + const fn = c.addFunction(flashMintConfigTrait, { + name: 'max_flash_loan', + args: [ + { name: 'self', type: '@ERC20FlashMintComponent::ComponentState' }, + { name: 'token', type: 'ContractAddress' }, + { name: 'total_supply', type: 'u256' }, + ], + returns: 'u256', + code: [], + }); + // Clamp the configured cap to the remaining mint headroom so the loan size we report is + // never larger than what the underlying mint path can actually honor. + fn.code.push( + 'if token != get_contract_address() {', + ' return 0;', + '}', + 'let headroom = Bounded::::MAX - total_supply;', + `let cap: u256 = ${customMax!};`, + 'if cap < headroom { cap } else { headroom }', + ); + } + + if (overridesFee) { + const fn = c.addFunction(flashMintConfigTrait, { + name: 'flash_fee', + args: [ + { name: 'self', type: '@ERC20FlashMintComponent::ComponentState' }, + { name: 'token', type: 'ContractAddress' }, + { name: 'amount', type: 'u256' }, + ], + returns: 'u256', + code: [], + }); + fn.code.push(...feeOverrideBody!); + } + + if (overridesReceiver) { + c.addUseClause('core::num::traits', 'Zero'); + c.addUseClause('starknet::storage', 'StoragePointerReadAccess'); + c.addUseClause('starknet::storage', 'StoragePointerWriteAccess'); + c.addStorageMember({ name: 'flash_fee_receiver', type: 'ContractAddress' }); + c.addConstructorArgument({ name: 'flash_fee_receiver', type: 'ContractAddress' }); + c.addConstructorCode(`assert(!flash_fee_receiver.is_zero(), 'FlashMint: invalid receiver')`); + c.addConstructorCode(`self.flash_fee_receiver.write(flash_fee_receiver)`); + const fn = c.addFunction(flashMintConfigTrait, { + name: 'flash_fee_receiver', + args: [{ name: 'self', type: '@ERC20FlashMintComponent::ComponentState' }], + returns: 'ContractAddress', + code: [], + }); + fn.code.push('self.get_contract().flash_fee_receiver.read()'); + } +} + const components = defineComponents({ ERC20Component: { path: 'openzeppelin_token::erc20', @@ -336,6 +542,24 @@ const components = defineComponents({ }, ], }, + ERC20FlashMintComponent: { + path: 'openzeppelin_token::erc20::extensions::erc20_flash_mint', + substorage: { + name: 'erc20_flash_mint', + type: 'ERC20FlashMintComponent::Storage', + }, + event: { + name: 'ERC20FlashMintEvent', + type: 'ERC20FlashMintComponent::Event', + }, + impls: [ + { + name: 'ERC20FlashMintImpl', + embed: true, + value: 'ERC20FlashMintComponent::ERC20FlashMintImpl', + }, + ], + }, }); const functions = defineFunctions({ diff --git a/packages/core/cairo_alpha/src/generate/erc20.ts b/packages/core/cairo_alpha/src/generate/erc20.ts index 2849f72b3..0b7d6b428 100644 --- a/packages/core/cairo_alpha/src/generate/erc20.ts +++ b/packages/core/cairo_alpha/src/generate/erc20.ts @@ -1,4 +1,5 @@ -import type { ERC20Options } from '../erc20'; +import type { ERC20Options, FlashMintSubset } from '../erc20'; +import { resolveFlashMintOptionsSubset } from '../erc20'; import type { AccessSubset } from '../set-access-control'; import { resolveAccessControlOptions } from '../set-access-control'; import { infoOptions } from '../set-info'; @@ -13,6 +14,7 @@ const booleans = [true, false]; type GeneratorOptions = { access: AccessSubset; upgradeable: UpgradeableSubset; + flashmint: FlashMintSubset; macros: MacrosSubset; }; @@ -29,6 +31,7 @@ function prepareBlueprint(opts: GeneratorOptions) { votes: booleans, appName: ['MyApp'], appVersion: ['v1'], + flashmint: resolveFlashMintOptionsSubset(opts.flashmint), access: resolveAccessControlOptions(opts.access), upgradeable: resolveUpgradeableOptionsSubset(opts.upgradeable), info: infoOptions, diff --git a/packages/core/cairo_alpha/src/generate/sources.ts b/packages/core/cairo_alpha/src/generate/sources.ts index 72b1b371c..76f5a8392 100644 --- a/packages/core/cairo_alpha/src/generate/sources.ts +++ b/packages/core/cairo_alpha/src/generate/sources.ts @@ -18,6 +18,7 @@ import { OptionsError } from '../error'; import { findCover } from '../utils/find-cover'; import type { Contract } from '../contract'; import type { RoyaltyInfoSubset } from '../set-royalty-info'; +import type { FlashMintSubset } from '../erc20'; import type { MacrosSubset } from '../set-macros'; import type { AccessSubset } from '../set-access-control'; import type { UpgradeableSubset } from '../set-upgradeable'; @@ -31,11 +32,12 @@ export function* generateOptions(params: { access: AccessSubset; upgradeable: UpgradeableSubset; royaltyInfo: RoyaltyInfoSubset; + flashmint: FlashMintSubset; macros: MacrosSubset; }): Generator { - const { kind, access, upgradeable, royaltyInfo, macros } = params; + const { kind, access, upgradeable, royaltyInfo, flashmint, macros } = params; if (kind === 'all' || kind === 'ERC20') { - for (const kindOpts of generateERC20Options({ access, upgradeable, macros })) { + for (const kindOpts of generateERC20Options({ access, upgradeable, flashmint, macros })) { yield { kind: 'ERC20', ...kindOpts }; } } @@ -105,12 +107,13 @@ function generateContractSubset(params: { access: AccessSubset; upgradeable: UpgradeableSubset; royaltyInfo: RoyaltyInfoSubset; + flashmint: FlashMintSubset; macros: MacrosSubset; }): GeneratedContract[] { - const { subset, kind, access, upgradeable, royaltyInfo, macros } = params; + const { subset, kind, access, upgradeable, royaltyInfo, flashmint, macros } = params; const contracts = []; - for (const options of generateOptions({ kind, access, upgradeable, royaltyInfo, macros })) { + for (const options of generateOptions({ kind, access, upgradeable, royaltyInfo, flashmint, macros })) { const id = crypto.createHash('sha1').update(JSON.stringify(options)).digest().toString('hex'); try { const contract = buildGeneric(options); @@ -163,11 +166,12 @@ export function* generateSources(params: { access: AccessSubset; upgradeable: UpgradeableSubset; royaltyInfo: RoyaltyInfoSubset; + flashmint: FlashMintSubset; macros: MacrosSubset; }): Generator { - const { subset, uniqueName, kind, access, upgradeable, royaltyInfo, macros } = params; + const { subset, uniqueName, kind, access, upgradeable, royaltyInfo, flashmint, macros } = params; let counter = 1; - for (const c of generateContractSubset({ subset, kind, access, upgradeable, royaltyInfo, macros })) { + for (const c of generateContractSubset({ subset, kind, access, upgradeable, royaltyInfo, flashmint, macros })) { if (uniqueName) { c.contract.name = `Contract${counter++}`; } @@ -184,10 +188,11 @@ export async function writeGeneratedSources(params: { access: AccessSubset; upgradeable: UpgradeableSubset; royaltyInfo: RoyaltyInfoSubset; + flashmint: FlashMintSubset; macros: MacrosSubset; logsEnabled: boolean; }): Promise { - const { dir, subset, uniqueName, kind, access, upgradeable, royaltyInfo, macros, logsEnabled } = params; + const { dir, subset, uniqueName, kind, access, upgradeable, royaltyInfo, flashmint, macros, logsEnabled } = params; await fs.mkdir(dir, { recursive: true }); const contractNames = []; @@ -198,6 +203,7 @@ export async function writeGeneratedSources(params: { access, upgradeable, royaltyInfo, + flashmint, macros, })) { const name = uniqueName ? contract.name : id; @@ -205,7 +211,7 @@ export async function writeGeneratedSources(params: { contractNames.push(name); } if (logsEnabled) { - const sourceLabel = resolveSourceLabel({ kind, access, upgradeable, royaltyInfo, macros }); + const sourceLabel = resolveSourceLabel({ kind, access, upgradeable, royaltyInfo, flashmint, macros }); console.log(`Generated ${contractNames.length} contracts for ${sourceLabel}`); } @@ -217,15 +223,17 @@ function resolveSourceLabel(params: { access: AccessSubset; upgradeable: UpgradeableSubset; royaltyInfo: RoyaltyInfoSubset; + flashmint: FlashMintSubset; macros: MacrosSubset; }): string { - const { kind, access, upgradeable, royaltyInfo, macros } = params; + const { kind, access, upgradeable, royaltyInfo, flashmint, macros } = params; return [ resolveKindLabel(kind), resolveMacrosLabel(macros), resolveAccessLabel(kind, access), resolveUpgradeableLabel(kind, upgradeable), resolveRoyaltyInfoLabel(kind, royaltyInfo), + resolveFlashMintLabel(kind, flashmint), ] .filter(elem => elem !== undefined) .join(', '); @@ -318,3 +326,24 @@ function resolveRoyaltyInfoLabel(kind: KindSubset, royaltyInfo: RoyaltyInfoSubse } } } + +function resolveFlashMintLabel(kind: KindSubset, flashmint: FlashMintSubset): string | undefined { + switch (kind) { + case 'all': + case 'ERC20': + return `flashmint: ${flashmint}`; + case 'ERC721': + case 'ERC1155': + case 'ERC6909': + case 'Account': + case 'Custom': + case 'Multisig': + case 'Governor': + case 'Vesting': + return undefined; + default: { + const _: never = kind; + throw new Error('Unknown kind'); + } + } +} diff --git a/packages/core/cairo_alpha/src/index.ts b/packages/core/cairo_alpha/src/index.ts index 27bd959f3..bceb4c1ad 100644 --- a/packages/core/cairo_alpha/src/index.ts +++ b/packages/core/cairo_alpha/src/index.ts @@ -13,7 +13,8 @@ export type { Info } from './set-info'; export type { RoyaltyInfoOptions } from './set-royalty-info'; export type { MacrosOptions } from './set-macros'; -export { premintPattern } from './erc20'; +export { premintPattern, flashMintDefaults } from './erc20'; +export type { FlashMintOptions, FlashMintFeeMode, FlashMintFeeDestination } from './erc20'; export { defaults as infoDefaults } from './set-info'; export { defaults as royaltyInfoDefaults } from './set-royalty-info'; diff --git a/packages/core/cairo_alpha/src/print.ts b/packages/core/cairo_alpha/src/print.ts index 817ba2925..0916103d6 100644 --- a/packages/core/cairo_alpha/src/print.ts +++ b/packages/core/cairo_alpha/src/print.ts @@ -219,11 +219,16 @@ function printImpl(impl: Impl): Lines[] { } function printStorage(contract: Contract): (string | string[])[] { + const memberLines = contract.storageMembers.map(m => `${m.name}: ${m.type},`); + if (contract.macros.withComponents || contract.components.length === 0) { // storage is required regardless of whether there are components - return ['#[storage]', 'struct Storage {}']; + if (memberLines.length === 0) { + return ['#[storage]', 'struct Storage {}']; + } + return ['#[storage]', 'struct Storage {', memberLines, '}']; } - const storageLines = []; + const storageLines = [...memberLines]; for (const component of contract.components) { storageLines.push(`#[substorage(v0)]`); storageLines.push(`${component.substorage.name}: ${component.substorage.type},`); diff --git a/packages/core/cairo_alpha/src/scripts/update-scarb-project.ts b/packages/core/cairo_alpha/src/scripts/update-scarb-project.ts index a76869aeb..73b2bbff1 100644 --- a/packages/core/cairo_alpha/src/scripts/update-scarb-project.ts +++ b/packages/core/cairo_alpha/src/scripts/update-scarb-project.ts @@ -5,6 +5,7 @@ import type { KindSubset } from '../generate/sources'; import type { AccessSubset } from '../set-access-control'; import type { UpgradeableSubset } from '../set-upgradeable'; import type { RoyaltyInfoSubset } from '../set-royalty-info'; +import type { FlashMintSubset } from '../erc20'; import type { MacrosSubset } from '../set-macros'; import { writeGeneratedSources } from '../generate/sources'; import { contractsVersion, edition, cairoVersion, scarbVersion } from '../utils/version'; @@ -14,6 +15,7 @@ type Arguments = { access: AccessSubset; upgradeable: UpgradeableSubset; royaltyInfo: RoyaltyInfoSubset; + flashmint: FlashMintSubset; macros: MacrosSubset; }; @@ -22,6 +24,7 @@ const defaults = { access: 'all', upgradeable: 'all', royaltyInfo: 'all', + flashmint: 'all', macros: 'all', } as const; @@ -33,6 +36,7 @@ export function resolveArguments(): Arguments { access: parseAccessSubset(args.access ?? defaults.access), upgradeable: parseUpgradeableSubset(args.upgradeable ?? defaults.upgradeable), royaltyInfo: parseRoyaltyInfoSubset(args.royalty ?? defaults.royaltyInfo), + flashmint: parseFlashMintSubset(args.flashmint ?? defaults.flashmint), macros: parseMacrosSubset(args.macros ?? defaults.macros), }; } @@ -42,7 +46,7 @@ export async function updateScarbProject() { await fs.rm(generatedSourcesPath, { force: true, recursive: true }); // Generate the contracts source code - const { kind, access, upgradeable, royaltyInfo, macros } = resolveArguments(); + const { kind, access, upgradeable, royaltyInfo, flashmint, macros } = resolveArguments(); const contractNames = await writeGeneratedSources({ dir: generatedSourcesPath, subset: 'all', @@ -51,6 +55,7 @@ export async function updateScarbProject() { access, upgradeable, royaltyInfo, + flashmint, macros, logsEnabled: true, }); @@ -189,6 +194,26 @@ function parseRoyaltyInfoSubset(value: string): RoyaltyInfoSubset { } } +function parseFlashMintSubset(value: string): FlashMintSubset { + switch (value.toLowerCase()) { + case 'all': + return 'all'; + case 'disabled': + return 'disabled'; + case 'enabled-default': + case 'enabled_default': + return 'enabled-default'; + case 'enabled-percent-fee': + case 'enabled_percent_fee': + return 'enabled-percent-fee'; + case 'enabled-custom-fee': + case 'enabled_custom_fee': + return 'enabled-custom-fee'; + default: + throw new Error(`Failed to resolve flashmint subset from '${value}' value.`); + } +} + function parseMacrosSubset(value: string): MacrosSubset { switch (value.toLowerCase()) { case 'all': diff --git a/packages/core/cairo_alpha/src/test.ts b/packages/core/cairo_alpha/src/test.ts index 66de81a77..6b58008e7 100644 --- a/packages/core/cairo_alpha/src/test.ts +++ b/packages/core/cairo_alpha/src/test.ts @@ -8,6 +8,7 @@ import type { KindSubset } from './generate/sources'; import type { AccessSubset } from './set-access-control'; import type { UpgradeableSubset } from './set-upgradeable'; import type { RoyaltyInfoSubset } from './set-royalty-info'; +import type { FlashMintSubset } from './erc20'; import type { GenericOptions } from './build-generic'; import type { MacrosSubset } from './set-macros'; import { generateSources, writeGeneratedSources } from './generate/sources'; @@ -20,7 +21,7 @@ interface Context { const test = _test as TestFn; test.serial('erc20 results generated', async ctx => { - await testGenerate({ ctx, kind: 'ERC20', access: 'all' }); + await testGenerate({ ctx, kind: 'ERC20', access: 'all', flashmint: 'all' }); }); test.serial('erc721 results generated', async ctx => { @@ -61,9 +62,10 @@ async function testGenerate(params: { access?: AccessSubset; upgradeable?: UpgradeableSubset; royaltyInfo?: RoyaltyInfoSubset; + flashmint?: FlashMintSubset; macros?: MacrosSubset; }) { - const { ctx, kind, access, upgradeable, royaltyInfo, macros } = params; + const { ctx, kind, access, upgradeable, royaltyInfo, flashmint, macros } = params; const generatedSourcesPath = path.join(os.tmpdir(), 'oz-wizard-cairo-alpha'); await fs.rm(generatedSourcesPath, { force: true, recursive: true }); await writeGeneratedSources({ @@ -74,6 +76,7 @@ async function testGenerate(params: { access: access || 'all', upgradeable: upgradeable || 'all', royaltyInfo: royaltyInfo || 'all', + flashmint: flashmint || 'all', macros: macros || 'all', logsEnabled: false, }); @@ -114,6 +117,7 @@ test('is access control required', async t => { access: 'all', upgradeable: 'all', royaltyInfo: 'all', + flashmint: 'all', macros: 'none', }); for (const contract of allSources) { diff --git a/packages/core/cairo_alpha/src/tests/with_components_off/erc20/erc20.test.ts b/packages/core/cairo_alpha/src/tests/with_components_off/erc20/erc20.test.ts index f723379c3..1b789eaf4 100644 --- a/packages/core/cairo_alpha/src/tests/with_components_off/erc20/erc20.test.ts +++ b/packages/core/cairo_alpha/src/tests/with_components_off/erc20/erc20.test.ts @@ -1,7 +1,7 @@ import test from 'ava'; import type { ERC20Options } from '../../../erc20'; -import { buildERC20, getInitialSupply } from '../../../erc20'; +import { buildERC20, flashMintDefaults, getInitialSupply } from '../../../erc20'; import { printContract } from '../../../print'; import { AccessControl, darDefaultOpts, darCustomOpts } from '../../../set-access-control'; @@ -76,6 +76,128 @@ testERC20('erc20 wrapper', { wrapper: true, }); +testERC20('erc20 flash mint', { + flashmint: { ...flashMintDefaults, enabled: true }, +}); + +testERC20('erc20 flash mint with custom max', { + flashmint: { ...flashMintDefaults, enabled: true, maxAmount: '1000000' }, +}); + +testERC20('erc20 flash mint with percent fee', { + flashmint: { ...flashMintDefaults, enabled: true, feeMode: 'percent', feePercent: '5' }, +}); + +testERC20('erc20 flash mint with fractional percent fee', { + flashmint: { ...flashMintDefaults, enabled: true, feeMode: 'percent', feePercent: '0.0013725' }, +}); + +testERC20('erc20 flash mint with percent fee and fee receiver', { + flashmint: { + ...flashMintDefaults, + enabled: true, + feeMode: 'percent', + feePercent: '5', + feeDestination: 'fee_receiver', + }, +}); + +testERC20('erc20 flash mint with custom fee impl', { + flashmint: { ...flashMintDefaults, enabled: true, feeMode: 'custom' }, +}); + +testERC20('erc20 flash mint with all custom config', { + flashmint: { + enabled: true, + maxAmount: '1000000', + feeMode: 'percent', + feePercent: '5', + feeDestination: 'fee_receiver', + }, +}); + +testERC20('erc20 flash mint with max amount of zero', { + flashmint: { ...flashMintDefaults, enabled: true, maxAmount: '0' }, +}); + +test('erc20 flash mint, max amount empty string', async t => { + const error = t.throws(() => + buildERC20({ + name: 'MyToken', + symbol: 'MTK', + flashmint: { ...flashMintDefaults, enabled: true, maxAmount: '' }, + }), + ); + t.is((error as OptionsError).messages.flashMintMaxAmount, 'Must be "max" or a non-negative number'); +}); + +test('erc20 flash mint, invalid max amount', async t => { + const error = t.throws(() => + buildERC20({ + name: 'MyToken', + symbol: 'MTK', + flashmint: { ...flashMintDefaults, enabled: true, maxAmount: 'abc' }, + }), + ); + t.is((error as OptionsError).messages.flashMintMaxAmount, 'Must be "max" or a non-negative number'); +}); + +test('erc20 flash mint, invalid percent fee', async t => { + const error = t.throws(() => + buildERC20({ + name: 'MyToken', + symbol: 'MTK', + flashmint: { ...flashMintDefaults, enabled: true, feeMode: 'percent', feePercent: 'abc' }, + }), + ); + t.is((error as OptionsError).messages.flashMintFeePercent, 'Must be a number between 0 and 100'); +}); + +test('erc20 flash mint, percent fee out of range', async t => { + const error = t.throws(() => + buildERC20({ + name: 'MyToken', + symbol: 'MTK', + flashmint: { ...flashMintDefaults, enabled: true, feeMode: 'percent', feePercent: '100.5' }, + }), + ); + t.is((error as OptionsError).messages.flashMintFeePercent, 'Must be a number between 0 and 100'); +}); + +test('erc20 flash mint, max amount overflows u256', async t => { + const error = t.throws(() => + buildERC20({ + name: 'MyToken', + symbol: 'MTK', + decimals: '0', + flashmint: { + ...flashMintDefaults, + enabled: true, + // 2^256 — one past u256::MAX with decimals: '0' so getInitialSupply preserves the value. + maxAmount: '115792089237316195423570985008687907853269984665640564039457584007913129639936', + }, + }), + ); + t.is((error as OptionsError).messages.flashMintMaxAmount, 'Value is greater than u256 max value'); +}); + +test('erc20 flash mint, percent denominator overflows u256', async t => { + const error = t.throws(() => + buildERC20({ + name: 'MyToken', + symbol: 'MTK', + flashmint: { + ...flashMintDefaults, + enabled: true, + feeMode: 'percent', + // 77 fractional digits — 100 * 10^77 > u256::MAX. + feePercent: '0.' + '0'.repeat(76) + '1', + }, + }), + ); + t.is((error as OptionsError).messages.flashMintFeePercent, 'Value is greater than u256 max value'); +}); + testERC20('erc20 preminted', { premint: '1000', }); diff --git a/packages/core/cairo_alpha/src/tests/with_components_off/erc20/erc20.test.ts.md b/packages/core/cairo_alpha/src/tests/with_components_off/erc20/erc20.test.ts.md index 6f03c9d88..6c1b7bc17 100644 --- a/packages/core/cairo_alpha/src/tests/with_components_off/erc20/erc20.test.ts.md +++ b/packages/core/cairo_alpha/src/tests/with_components_off/erc20/erc20.test.ts.md @@ -896,6 +896,780 @@ Generated by [AVA](https://avajs.dev). }␊ ` +## erc20 flash mint + +> Snapshot 1 + + `// SPDX-License-Identifier: MIT␊ + // Compatible with OpenZeppelin Contracts for Cairo ^4.0.1␊ + ␊ + #[starknet::contract]␊ + mod MyToken {␊ + use openzeppelin_access::ownable::OwnableComponent;␊ + use openzeppelin_interfaces::upgrades::IUpgradeable;␊ + use openzeppelin_token::erc20::{␊ + DefaultConfig as ERC20DefaultConfig, ERC20Component, ERC20HooksEmptyImpl␊ + };␊ + use openzeppelin_token::erc20::extensions::erc20_flash_mint::{␊ + DefaultConfig as ERC20FlashMintDefaultConfig, ERC20FlashMintComponent␊ + };␊ + use openzeppelin_upgrades::UpgradeableComponent;␊ + use starknet::{ClassHash, ContractAddress};␊ + ␊ + component!(path: ERC20Component, storage: erc20, event: ERC20Event);␊ + component!(path: ERC20FlashMintComponent, storage: erc20_flash_mint, event: ERC20FlashMintEvent);␊ + component!(path: UpgradeableComponent, storage: upgradeable, event: UpgradeableEvent);␊ + component!(path: OwnableComponent, storage: ownable, event: OwnableEvent);␊ + ␊ + // External␊ + #[abi(embed_v0)]␊ + impl ERC20MixinImpl = ERC20Component::ERC20MixinImpl;␊ + #[abi(embed_v0)]␊ + impl ERC20FlashMintImpl = ERC20FlashMintComponent::ERC20FlashMintImpl;␊ + #[abi(embed_v0)]␊ + impl OwnableMixinImpl = OwnableComponent::OwnableMixinImpl;␊ + ␊ + // Internal␊ + impl ERC20InternalImpl = ERC20Component::InternalImpl;␊ + impl UpgradeableInternalImpl = UpgradeableComponent::InternalImpl;␊ + impl OwnableInternalImpl = OwnableComponent::InternalImpl;␊ + ␊ + #[storage]␊ + struct Storage {␊ + #[substorage(v0)]␊ + erc20: ERC20Component::Storage,␊ + #[substorage(v0)]␊ + erc20_flash_mint: ERC20FlashMintComponent::Storage,␊ + #[substorage(v0)]␊ + upgradeable: UpgradeableComponent::Storage,␊ + #[substorage(v0)]␊ + ownable: OwnableComponent::Storage,␊ + }␊ + ␊ + #[event]␊ + #[derive(Drop, starknet::Event)]␊ + enum Event {␊ + #[flat]␊ + ERC20Event: ERC20Component::Event,␊ + #[flat]␊ + ERC20FlashMintEvent: ERC20FlashMintComponent::Event,␊ + #[flat]␊ + UpgradeableEvent: UpgradeableComponent::Event,␊ + #[flat]␊ + OwnableEvent: OwnableComponent::Event,␊ + }␊ + ␊ + #[constructor]␊ + fn constructor(ref self: ContractState, owner: ContractAddress) {␊ + self.erc20.initializer("MyToken", "MTK");␊ + self.ownable.initializer(owner);␊ + }␊ + ␊ + //␊ + // Upgradeable␊ + //␊ + ␊ + #[abi(embed_v0)]␊ + impl UpgradeableImpl of IUpgradeable {␊ + fn upgrade(ref self: ContractState, new_class_hash: ClassHash) {␊ + self.ownable.assert_only_owner();␊ + self.upgradeable.upgrade(new_class_hash);␊ + }␊ + }␊ + }␊ + ` + +## erc20 flash mint with custom max + +> Snapshot 1 + + `// SPDX-License-Identifier: MIT␊ + // Compatible with OpenZeppelin Contracts for Cairo ^4.0.1␊ + ␊ + #[starknet::contract]␊ + mod MyToken {␊ + use core::num::traits::Bounded;␊ + use openzeppelin_access::ownable::OwnableComponent;␊ + use openzeppelin_interfaces::upgrades::IUpgradeable;␊ + use openzeppelin_token::erc20::{␊ + DefaultConfig as ERC20DefaultConfig, ERC20Component, ERC20HooksEmptyImpl␊ + };␊ + use openzeppelin_token::erc20::extensions::erc20_flash_mint::ERC20FlashMintComponent;␊ + use openzeppelin_upgrades::UpgradeableComponent;␊ + use starknet::{ClassHash, ContractAddress, get_contract_address};␊ + ␊ + component!(path: ERC20Component, storage: erc20, event: ERC20Event);␊ + component!(path: ERC20FlashMintComponent, storage: erc20_flash_mint, event: ERC20FlashMintEvent);␊ + component!(path: UpgradeableComponent, storage: upgradeable, event: UpgradeableEvent);␊ + component!(path: OwnableComponent, storage: ownable, event: OwnableEvent);␊ + ␊ + // External␊ + #[abi(embed_v0)]␊ + impl ERC20MixinImpl = ERC20Component::ERC20MixinImpl;␊ + #[abi(embed_v0)]␊ + impl ERC20FlashMintImpl = ERC20FlashMintComponent::ERC20FlashMintImpl;␊ + #[abi(embed_v0)]␊ + impl OwnableMixinImpl = OwnableComponent::OwnableMixinImpl;␊ + ␊ + // Internal␊ + impl ERC20InternalImpl = ERC20Component::InternalImpl;␊ + impl UpgradeableInternalImpl = UpgradeableComponent::InternalImpl;␊ + impl OwnableInternalImpl = OwnableComponent::InternalImpl;␊ + ␊ + #[storage]␊ + struct Storage {␊ + #[substorage(v0)]␊ + erc20: ERC20Component::Storage,␊ + #[substorage(v0)]␊ + erc20_flash_mint: ERC20FlashMintComponent::Storage,␊ + #[substorage(v0)]␊ + upgradeable: UpgradeableComponent::Storage,␊ + #[substorage(v0)]␊ + ownable: OwnableComponent::Storage,␊ + }␊ + ␊ + #[event]␊ + #[derive(Drop, starknet::Event)]␊ + enum Event {␊ + #[flat]␊ + ERC20Event: ERC20Component::Event,␊ + #[flat]␊ + ERC20FlashMintEvent: ERC20FlashMintComponent::Event,␊ + #[flat]␊ + UpgradeableEvent: UpgradeableComponent::Event,␊ + #[flat]␊ + OwnableEvent: OwnableComponent::Event,␊ + }␊ + ␊ + #[constructor]␊ + fn constructor(ref self: ContractState, owner: ContractAddress) {␊ + self.erc20.initializer("MyToken", "MTK");␊ + self.ownable.initializer(owner);␊ + }␊ + ␊ + impl FlashMintConfigImpl of ERC20FlashMintComponent::FlashMintConfigTrait {␊ + fn max_flash_loan(␊ + self: @ERC20FlashMintComponent::ComponentState,␊ + token: ContractAddress,␊ + total_supply: u256,␊ + ) -> u256 {␊ + if token != get_contract_address() {␊ + return 0;␊ + }␊ + let headroom = Bounded::::MAX - total_supply;␊ + let cap: u256 = 1000000000000000000000000;␊ + if cap < headroom { cap } else { headroom }␊ + }␊ + }␊ + ␊ + //␊ + // Upgradeable␊ + //␊ + ␊ + #[abi(embed_v0)]␊ + impl UpgradeableImpl of IUpgradeable {␊ + fn upgrade(ref self: ContractState, new_class_hash: ClassHash) {␊ + self.ownable.assert_only_owner();␊ + self.upgradeable.upgrade(new_class_hash);␊ + }␊ + }␊ + }␊ + ` + +## erc20 flash mint with percent fee + +> Snapshot 1 + + `// SPDX-License-Identifier: MIT␊ + // Compatible with OpenZeppelin Contracts for Cairo ^4.0.1␊ + ␊ + #[starknet::contract]␊ + mod MyToken {␊ + use openzeppelin_access::ownable::OwnableComponent;␊ + use openzeppelin_interfaces::upgrades::IUpgradeable;␊ + use openzeppelin_token::erc20::{␊ + DefaultConfig as ERC20DefaultConfig, ERC20Component, ERC20HooksEmptyImpl␊ + };␊ + use openzeppelin_token::erc20::extensions::erc20_flash_mint::ERC20FlashMintComponent;␊ + use openzeppelin_upgrades::UpgradeableComponent;␊ + use starknet::{ClassHash, ContractAddress};␊ + ␊ + component!(path: ERC20Component, storage: erc20, event: ERC20Event);␊ + component!(path: ERC20FlashMintComponent, storage: erc20_flash_mint, event: ERC20FlashMintEvent);␊ + component!(path: UpgradeableComponent, storage: upgradeable, event: UpgradeableEvent);␊ + component!(path: OwnableComponent, storage: ownable, event: OwnableEvent);␊ + ␊ + // External␊ + #[abi(embed_v0)]␊ + impl ERC20MixinImpl = ERC20Component::ERC20MixinImpl;␊ + #[abi(embed_v0)]␊ + impl ERC20FlashMintImpl = ERC20FlashMintComponent::ERC20FlashMintImpl;␊ + #[abi(embed_v0)]␊ + impl OwnableMixinImpl = OwnableComponent::OwnableMixinImpl;␊ + ␊ + // Internal␊ + impl ERC20InternalImpl = ERC20Component::InternalImpl;␊ + impl UpgradeableInternalImpl = UpgradeableComponent::InternalImpl;␊ + impl OwnableInternalImpl = OwnableComponent::InternalImpl;␊ + ␊ + #[storage]␊ + struct Storage {␊ + #[substorage(v0)]␊ + erc20: ERC20Component::Storage,␊ + #[substorage(v0)]␊ + erc20_flash_mint: ERC20FlashMintComponent::Storage,␊ + #[substorage(v0)]␊ + upgradeable: UpgradeableComponent::Storage,␊ + #[substorage(v0)]␊ + ownable: OwnableComponent::Storage,␊ + }␊ + ␊ + #[event]␊ + #[derive(Drop, starknet::Event)]␊ + enum Event {␊ + #[flat]␊ + ERC20Event: ERC20Component::Event,␊ + #[flat]␊ + ERC20FlashMintEvent: ERC20FlashMintComponent::Event,␊ + #[flat]␊ + UpgradeableEvent: UpgradeableComponent::Event,␊ + #[flat]␊ + OwnableEvent: OwnableComponent::Event,␊ + }␊ + ␊ + #[constructor]␊ + fn constructor(ref self: ContractState, owner: ContractAddress) {␊ + self.erc20.initializer("MyToken", "MTK");␊ + self.ownable.initializer(owner);␊ + }␊ + ␊ + impl FlashMintConfigImpl of ERC20FlashMintComponent::FlashMintConfigTrait {␊ + fn flash_fee(␊ + self: @ERC20FlashMintComponent::ComponentState,␊ + token: ContractAddress,␊ + amount: u256,␊ + ) -> u256 {␊ + amount * 5 / 100␊ + }␊ + }␊ + ␊ + //␊ + // Upgradeable␊ + //␊ + ␊ + #[abi(embed_v0)]␊ + impl UpgradeableImpl of IUpgradeable {␊ + fn upgrade(ref self: ContractState, new_class_hash: ClassHash) {␊ + self.ownable.assert_only_owner();␊ + self.upgradeable.upgrade(new_class_hash);␊ + }␊ + }␊ + }␊ + ` + +## erc20 flash mint with fractional percent fee + +> Snapshot 1 + + `// SPDX-License-Identifier: MIT␊ + // Compatible with OpenZeppelin Contracts for Cairo ^4.0.1␊ + ␊ + #[starknet::contract]␊ + mod MyToken {␊ + use openzeppelin_access::ownable::OwnableComponent;␊ + use openzeppelin_interfaces::upgrades::IUpgradeable;␊ + use openzeppelin_token::erc20::{␊ + DefaultConfig as ERC20DefaultConfig, ERC20Component, ERC20HooksEmptyImpl␊ + };␊ + use openzeppelin_token::erc20::extensions::erc20_flash_mint::ERC20FlashMintComponent;␊ + use openzeppelin_upgrades::UpgradeableComponent;␊ + use starknet::{ClassHash, ContractAddress};␊ + ␊ + component!(path: ERC20Component, storage: erc20, event: ERC20Event);␊ + component!(path: ERC20FlashMintComponent, storage: erc20_flash_mint, event: ERC20FlashMintEvent);␊ + component!(path: UpgradeableComponent, storage: upgradeable, event: UpgradeableEvent);␊ + component!(path: OwnableComponent, storage: ownable, event: OwnableEvent);␊ + ␊ + // External␊ + #[abi(embed_v0)]␊ + impl ERC20MixinImpl = ERC20Component::ERC20MixinImpl;␊ + #[abi(embed_v0)]␊ + impl ERC20FlashMintImpl = ERC20FlashMintComponent::ERC20FlashMintImpl;␊ + #[abi(embed_v0)]␊ + impl OwnableMixinImpl = OwnableComponent::OwnableMixinImpl;␊ + ␊ + // Internal␊ + impl ERC20InternalImpl = ERC20Component::InternalImpl;␊ + impl UpgradeableInternalImpl = UpgradeableComponent::InternalImpl;␊ + impl OwnableInternalImpl = OwnableComponent::InternalImpl;␊ + ␊ + #[storage]␊ + struct Storage {␊ + #[substorage(v0)]␊ + erc20: ERC20Component::Storage,␊ + #[substorage(v0)]␊ + erc20_flash_mint: ERC20FlashMintComponent::Storage,␊ + #[substorage(v0)]␊ + upgradeable: UpgradeableComponent::Storage,␊ + #[substorage(v0)]␊ + ownable: OwnableComponent::Storage,␊ + }␊ + ␊ + #[event]␊ + #[derive(Drop, starknet::Event)]␊ + enum Event {␊ + #[flat]␊ + ERC20Event: ERC20Component::Event,␊ + #[flat]␊ + ERC20FlashMintEvent: ERC20FlashMintComponent::Event,␊ + #[flat]␊ + UpgradeableEvent: UpgradeableComponent::Event,␊ + #[flat]␊ + OwnableEvent: OwnableComponent::Event,␊ + }␊ + ␊ + #[constructor]␊ + fn constructor(ref self: ContractState, owner: ContractAddress) {␊ + self.erc20.initializer("MyToken", "MTK");␊ + self.ownable.initializer(owner);␊ + }␊ + ␊ + impl FlashMintConfigImpl of ERC20FlashMintComponent::FlashMintConfigTrait {␊ + fn flash_fee(␊ + self: @ERC20FlashMintComponent::ComponentState,␊ + token: ContractAddress,␊ + amount: u256,␊ + ) -> u256 {␊ + amount * 13725 / 1000000000␊ + }␊ + }␊ + ␊ + //␊ + // Upgradeable␊ + //␊ + ␊ + #[abi(embed_v0)]␊ + impl UpgradeableImpl of IUpgradeable {␊ + fn upgrade(ref self: ContractState, new_class_hash: ClassHash) {␊ + self.ownable.assert_only_owner();␊ + self.upgradeable.upgrade(new_class_hash);␊ + }␊ + }␊ + }␊ + ` + +## erc20 flash mint with percent fee and fee receiver + +> Snapshot 1 + + `// SPDX-License-Identifier: MIT␊ + // Compatible with OpenZeppelin Contracts for Cairo ^4.0.1␊ + ␊ + #[starknet::contract]␊ + mod MyToken {␊ + use core::num::traits::Zero;␊ + use openzeppelin_access::ownable::OwnableComponent;␊ + use openzeppelin_interfaces::upgrades::IUpgradeable;␊ + use openzeppelin_token::erc20::{␊ + DefaultConfig as ERC20DefaultConfig, ERC20Component, ERC20HooksEmptyImpl␊ + };␊ + use openzeppelin_token::erc20::extensions::erc20_flash_mint::ERC20FlashMintComponent;␊ + use openzeppelin_upgrades::UpgradeableComponent;␊ + use starknet::{ClassHash, ContractAddress};␊ + use starknet::storage::{StoragePointerReadAccess, StoragePointerWriteAccess};␊ + ␊ + component!(path: ERC20Component, storage: erc20, event: ERC20Event);␊ + component!(path: ERC20FlashMintComponent, storage: erc20_flash_mint, event: ERC20FlashMintEvent);␊ + component!(path: UpgradeableComponent, storage: upgradeable, event: UpgradeableEvent);␊ + component!(path: OwnableComponent, storage: ownable, event: OwnableEvent);␊ + ␊ + // External␊ + #[abi(embed_v0)]␊ + impl ERC20MixinImpl = ERC20Component::ERC20MixinImpl;␊ + #[abi(embed_v0)]␊ + impl ERC20FlashMintImpl = ERC20FlashMintComponent::ERC20FlashMintImpl;␊ + #[abi(embed_v0)]␊ + impl OwnableMixinImpl = OwnableComponent::OwnableMixinImpl;␊ + ␊ + // Internal␊ + impl ERC20InternalImpl = ERC20Component::InternalImpl;␊ + impl UpgradeableInternalImpl = UpgradeableComponent::InternalImpl;␊ + impl OwnableInternalImpl = OwnableComponent::InternalImpl;␊ + ␊ + #[storage]␊ + struct Storage {␊ + flash_fee_receiver: ContractAddress,␊ + #[substorage(v0)]␊ + erc20: ERC20Component::Storage,␊ + #[substorage(v0)]␊ + erc20_flash_mint: ERC20FlashMintComponent::Storage,␊ + #[substorage(v0)]␊ + upgradeable: UpgradeableComponent::Storage,␊ + #[substorage(v0)]␊ + ownable: OwnableComponent::Storage,␊ + }␊ + ␊ + #[event]␊ + #[derive(Drop, starknet::Event)]␊ + enum Event {␊ + #[flat]␊ + ERC20Event: ERC20Component::Event,␊ + #[flat]␊ + ERC20FlashMintEvent: ERC20FlashMintComponent::Event,␊ + #[flat]␊ + UpgradeableEvent: UpgradeableComponent::Event,␊ + #[flat]␊ + OwnableEvent: OwnableComponent::Event,␊ + }␊ + ␊ + #[constructor]␊ + fn constructor(␊ + ref self: ContractState,␊ + flash_fee_receiver: ContractAddress,␊ + owner: ContractAddress,␊ + ) {␊ + self.erc20.initializer("MyToken", "MTK");␊ + self.ownable.initializer(owner);␊ + ␊ + assert(!flash_fee_receiver.is_zero(), 'FlashMint: invalid receiver');␊ + self.flash_fee_receiver.write(flash_fee_receiver);␊ + }␊ + ␊ + impl FlashMintConfigImpl of ERC20FlashMintComponent::FlashMintConfigTrait {␊ + fn flash_fee(␊ + self: @ERC20FlashMintComponent::ComponentState,␊ + token: ContractAddress,␊ + amount: u256,␊ + ) -> u256 {␊ + amount * 5 / 100␊ + }␊ + ␊ + fn flash_fee_receiver(self: @ERC20FlashMintComponent::ComponentState) -> ContractAddress {␊ + self.get_contract().flash_fee_receiver.read()␊ + }␊ + }␊ + ␊ + //␊ + // Upgradeable␊ + //␊ + ␊ + #[abi(embed_v0)]␊ + impl UpgradeableImpl of IUpgradeable {␊ + fn upgrade(ref self: ContractState, new_class_hash: ClassHash) {␊ + self.ownable.assert_only_owner();␊ + self.upgradeable.upgrade(new_class_hash);␊ + }␊ + }␊ + }␊ + ` + +## erc20 flash mint with custom fee impl + +> Snapshot 1 + + `// SPDX-License-Identifier: MIT␊ + // Compatible with OpenZeppelin Contracts for Cairo ^4.0.1␊ + ␊ + #[starknet::contract]␊ + mod MyToken {␊ + use openzeppelin_access::ownable::OwnableComponent;␊ + use openzeppelin_interfaces::upgrades::IUpgradeable;␊ + use openzeppelin_token::erc20::{␊ + DefaultConfig as ERC20DefaultConfig, ERC20Component, ERC20HooksEmptyImpl␊ + };␊ + use openzeppelin_token::erc20::extensions::erc20_flash_mint::ERC20FlashMintComponent;␊ + use openzeppelin_upgrades::UpgradeableComponent;␊ + use starknet::{ClassHash, ContractAddress};␊ + ␊ + component!(path: ERC20Component, storage: erc20, event: ERC20Event);␊ + component!(path: ERC20FlashMintComponent, storage: erc20_flash_mint, event: ERC20FlashMintEvent);␊ + component!(path: UpgradeableComponent, storage: upgradeable, event: UpgradeableEvent);␊ + component!(path: OwnableComponent, storage: ownable, event: OwnableEvent);␊ + ␊ + // External␊ + #[abi(embed_v0)]␊ + impl ERC20MixinImpl = ERC20Component::ERC20MixinImpl;␊ + #[abi(embed_v0)]␊ + impl ERC20FlashMintImpl = ERC20FlashMintComponent::ERC20FlashMintImpl;␊ + #[abi(embed_v0)]␊ + impl OwnableMixinImpl = OwnableComponent::OwnableMixinImpl;␊ + ␊ + // Internal␊ + impl ERC20InternalImpl = ERC20Component::InternalImpl;␊ + impl UpgradeableInternalImpl = UpgradeableComponent::InternalImpl;␊ + impl OwnableInternalImpl = OwnableComponent::InternalImpl;␊ + ␊ + #[storage]␊ + struct Storage {␊ + #[substorage(v0)]␊ + erc20: ERC20Component::Storage,␊ + #[substorage(v0)]␊ + erc20_flash_mint: ERC20FlashMintComponent::Storage,␊ + #[substorage(v0)]␊ + upgradeable: UpgradeableComponent::Storage,␊ + #[substorage(v0)]␊ + ownable: OwnableComponent::Storage,␊ + }␊ + ␊ + #[event]␊ + #[derive(Drop, starknet::Event)]␊ + enum Event {␊ + #[flat]␊ + ERC20Event: ERC20Component::Event,␊ + #[flat]␊ + ERC20FlashMintEvent: ERC20FlashMintComponent::Event,␊ + #[flat]␊ + UpgradeableEvent: UpgradeableComponent::Event,␊ + #[flat]␊ + OwnableEvent: OwnableComponent::Event,␊ + }␊ + ␊ + #[constructor]␊ + fn constructor(ref self: ContractState, owner: ContractAddress) {␊ + self.erc20.initializer("MyToken", "MTK");␊ + self.ownable.initializer(owner);␊ + }␊ + ␊ + impl FlashMintConfigImpl of ERC20FlashMintComponent::FlashMintConfigTrait {␊ + fn flash_fee(␊ + self: @ERC20FlashMintComponent::ComponentState,␊ + token: ContractAddress,␊ + amount: u256,␊ + ) -> u256 {␊ + // TODO: Must be implemented according to the desired flash fee logic;␊ + 0␊ + }␊ + }␊ + ␊ + //␊ + // Upgradeable␊ + //␊ + ␊ + #[abi(embed_v0)]␊ + impl UpgradeableImpl of IUpgradeable {␊ + fn upgrade(ref self: ContractState, new_class_hash: ClassHash) {␊ + self.ownable.assert_only_owner();␊ + self.upgradeable.upgrade(new_class_hash);␊ + }␊ + }␊ + }␊ + ` + +## erc20 flash mint with all custom config + +> Snapshot 1 + + `// SPDX-License-Identifier: MIT␊ + // Compatible with OpenZeppelin Contracts for Cairo ^4.0.1␊ + ␊ + #[starknet::contract]␊ + mod MyToken {␊ + use core::num::traits::{Bounded, Zero};␊ + use openzeppelin_access::ownable::OwnableComponent;␊ + use openzeppelin_interfaces::upgrades::IUpgradeable;␊ + use openzeppelin_token::erc20::{␊ + DefaultConfig as ERC20DefaultConfig, ERC20Component, ERC20HooksEmptyImpl␊ + };␊ + use openzeppelin_token::erc20::extensions::erc20_flash_mint::ERC20FlashMintComponent;␊ + use openzeppelin_upgrades::UpgradeableComponent;␊ + use starknet::{ClassHash, ContractAddress, get_contract_address};␊ + use starknet::storage::{StoragePointerReadAccess, StoragePointerWriteAccess};␊ + ␊ + component!(path: ERC20Component, storage: erc20, event: ERC20Event);␊ + component!(path: ERC20FlashMintComponent, storage: erc20_flash_mint, event: ERC20FlashMintEvent);␊ + component!(path: UpgradeableComponent, storage: upgradeable, event: UpgradeableEvent);␊ + component!(path: OwnableComponent, storage: ownable, event: OwnableEvent);␊ + ␊ + // External␊ + #[abi(embed_v0)]␊ + impl ERC20MixinImpl = ERC20Component::ERC20MixinImpl;␊ + #[abi(embed_v0)]␊ + impl ERC20FlashMintImpl = ERC20FlashMintComponent::ERC20FlashMintImpl;␊ + #[abi(embed_v0)]␊ + impl OwnableMixinImpl = OwnableComponent::OwnableMixinImpl;␊ + ␊ + // Internal␊ + impl ERC20InternalImpl = ERC20Component::InternalImpl;␊ + impl UpgradeableInternalImpl = UpgradeableComponent::InternalImpl;␊ + impl OwnableInternalImpl = OwnableComponent::InternalImpl;␊ + ␊ + #[storage]␊ + struct Storage {␊ + flash_fee_receiver: ContractAddress,␊ + #[substorage(v0)]␊ + erc20: ERC20Component::Storage,␊ + #[substorage(v0)]␊ + erc20_flash_mint: ERC20FlashMintComponent::Storage,␊ + #[substorage(v0)]␊ + upgradeable: UpgradeableComponent::Storage,␊ + #[substorage(v0)]␊ + ownable: OwnableComponent::Storage,␊ + }␊ + ␊ + #[event]␊ + #[derive(Drop, starknet::Event)]␊ + enum Event {␊ + #[flat]␊ + ERC20Event: ERC20Component::Event,␊ + #[flat]␊ + ERC20FlashMintEvent: ERC20FlashMintComponent::Event,␊ + #[flat]␊ + UpgradeableEvent: UpgradeableComponent::Event,␊ + #[flat]␊ + OwnableEvent: OwnableComponent::Event,␊ + }␊ + ␊ + #[constructor]␊ + fn constructor(␊ + ref self: ContractState,␊ + flash_fee_receiver: ContractAddress,␊ + owner: ContractAddress,␊ + ) {␊ + self.erc20.initializer("MyToken", "MTK");␊ + self.ownable.initializer(owner);␊ + ␊ + assert(!flash_fee_receiver.is_zero(), 'FlashMint: invalid receiver');␊ + self.flash_fee_receiver.write(flash_fee_receiver);␊ + }␊ + ␊ + impl FlashMintConfigImpl of ERC20FlashMintComponent::FlashMintConfigTrait {␊ + fn max_flash_loan(␊ + self: @ERC20FlashMintComponent::ComponentState,␊ + token: ContractAddress,␊ + total_supply: u256,␊ + ) -> u256 {␊ + if token != get_contract_address() {␊ + return 0;␊ + }␊ + let headroom = Bounded::::MAX - total_supply;␊ + let cap: u256 = 1000000000000000000000000;␊ + if cap < headroom { cap } else { headroom }␊ + }␊ + ␊ + fn flash_fee(␊ + self: @ERC20FlashMintComponent::ComponentState,␊ + token: ContractAddress,␊ + amount: u256,␊ + ) -> u256 {␊ + amount * 5 / 100␊ + }␊ + ␊ + fn flash_fee_receiver(self: @ERC20FlashMintComponent::ComponentState) -> ContractAddress {␊ + self.get_contract().flash_fee_receiver.read()␊ + }␊ + }␊ + ␊ + //␊ + // Upgradeable␊ + //␊ + ␊ + #[abi(embed_v0)]␊ + impl UpgradeableImpl of IUpgradeable {␊ + fn upgrade(ref self: ContractState, new_class_hash: ClassHash) {␊ + self.ownable.assert_only_owner();␊ + self.upgradeable.upgrade(new_class_hash);␊ + }␊ + }␊ + }␊ + ` + +## erc20 flash mint with max amount of zero + +> Snapshot 1 + + `// SPDX-License-Identifier: MIT␊ + // Compatible with OpenZeppelin Contracts for Cairo ^4.0.1␊ + ␊ + #[starknet::contract]␊ + mod MyToken {␊ + use core::num::traits::Bounded;␊ + use openzeppelin_access::ownable::OwnableComponent;␊ + use openzeppelin_interfaces::upgrades::IUpgradeable;␊ + use openzeppelin_token::erc20::{␊ + DefaultConfig as ERC20DefaultConfig, ERC20Component, ERC20HooksEmptyImpl␊ + };␊ + use openzeppelin_token::erc20::extensions::erc20_flash_mint::ERC20FlashMintComponent;␊ + use openzeppelin_upgrades::UpgradeableComponent;␊ + use starknet::{ClassHash, ContractAddress, get_contract_address};␊ + ␊ + component!(path: ERC20Component, storage: erc20, event: ERC20Event);␊ + component!(path: ERC20FlashMintComponent, storage: erc20_flash_mint, event: ERC20FlashMintEvent);␊ + component!(path: UpgradeableComponent, storage: upgradeable, event: UpgradeableEvent);␊ + component!(path: OwnableComponent, storage: ownable, event: OwnableEvent);␊ + ␊ + // External␊ + #[abi(embed_v0)]␊ + impl ERC20MixinImpl = ERC20Component::ERC20MixinImpl;␊ + #[abi(embed_v0)]␊ + impl ERC20FlashMintImpl = ERC20FlashMintComponent::ERC20FlashMintImpl;␊ + #[abi(embed_v0)]␊ + impl OwnableMixinImpl = OwnableComponent::OwnableMixinImpl;␊ + ␊ + // Internal␊ + impl ERC20InternalImpl = ERC20Component::InternalImpl;␊ + impl UpgradeableInternalImpl = UpgradeableComponent::InternalImpl;␊ + impl OwnableInternalImpl = OwnableComponent::InternalImpl;␊ + ␊ + #[storage]␊ + struct Storage {␊ + #[substorage(v0)]␊ + erc20: ERC20Component::Storage,␊ + #[substorage(v0)]␊ + erc20_flash_mint: ERC20FlashMintComponent::Storage,␊ + #[substorage(v0)]␊ + upgradeable: UpgradeableComponent::Storage,␊ + #[substorage(v0)]␊ + ownable: OwnableComponent::Storage,␊ + }␊ + ␊ + #[event]␊ + #[derive(Drop, starknet::Event)]␊ + enum Event {␊ + #[flat]␊ + ERC20Event: ERC20Component::Event,␊ + #[flat]␊ + ERC20FlashMintEvent: ERC20FlashMintComponent::Event,␊ + #[flat]␊ + UpgradeableEvent: UpgradeableComponent::Event,␊ + #[flat]␊ + OwnableEvent: OwnableComponent::Event,␊ + }␊ + ␊ + #[constructor]␊ + fn constructor(ref self: ContractState, owner: ContractAddress) {␊ + self.erc20.initializer("MyToken", "MTK");␊ + self.ownable.initializer(owner);␊ + }␊ + ␊ + impl FlashMintConfigImpl of ERC20FlashMintComponent::FlashMintConfigTrait {␊ + fn max_flash_loan(␊ + self: @ERC20FlashMintComponent::ComponentState,␊ + token: ContractAddress,␊ + total_supply: u256,␊ + ) -> u256 {␊ + if token != get_contract_address() {␊ + return 0;␊ + }␊ + let headroom = Bounded::::MAX - total_supply;␊ + let cap: u256 = 0;␊ + if cap < headroom { cap } else { headroom }␊ + }␊ + }␊ + ␊ + //␊ + // Upgradeable␊ + //␊ + ␊ + #[abi(embed_v0)]␊ + impl UpgradeableImpl of IUpgradeable {␊ + fn upgrade(ref self: ContractState, new_class_hash: ClassHash) {␊ + self.ownable.assert_only_owner();␊ + self.upgradeable.upgrade(new_class_hash);␊ + }␊ + }␊ + }␊ + ` + ## erc20 preminted > Snapshot 1 diff --git a/packages/core/cairo_alpha/src/tests/with_components_off/erc20/erc20.test.ts.snap b/packages/core/cairo_alpha/src/tests/with_components_off/erc20/erc20.test.ts.snap index 42b7d04e4..9447a01a8 100644 Binary files a/packages/core/cairo_alpha/src/tests/with_components_off/erc20/erc20.test.ts.snap and b/packages/core/cairo_alpha/src/tests/with_components_off/erc20/erc20.test.ts.snap differ diff --git a/packages/core/cairo_alpha/src/tests/with_components_on/erc20/erc20.test.ts b/packages/core/cairo_alpha/src/tests/with_components_on/erc20/erc20.test.ts index c84a85015..ecd63988e 100644 --- a/packages/core/cairo_alpha/src/tests/with_components_on/erc20/erc20.test.ts +++ b/packages/core/cairo_alpha/src/tests/with_components_on/erc20/erc20.test.ts @@ -1,7 +1,7 @@ import test from 'ava'; import type { ERC20Options } from '../../../erc20'; -import { buildERC20, getInitialSupply, defaults } from '../../../erc20'; +import { buildERC20, defaults, flashMintDefaults, getInitialSupply } from '../../../erc20'; import { printContract } from '../../../print'; import { AccessControl, darDefaultOpts, darCustomOpts } from '../../../set-access-control'; @@ -71,6 +71,128 @@ testERC20('erc20 wrapper', { wrapper: true, }); +testERC20('erc20 flash mint', { + flashmint: { ...flashMintDefaults, enabled: true }, +}); + +testERC20('erc20 flash mint with custom max', { + flashmint: { ...flashMintDefaults, enabled: true, maxAmount: '1000000' }, +}); + +testERC20('erc20 flash mint with percent fee', { + flashmint: { ...flashMintDefaults, enabled: true, feeMode: 'percent', feePercent: '5' }, +}); + +testERC20('erc20 flash mint with fractional percent fee', { + flashmint: { ...flashMintDefaults, enabled: true, feeMode: 'percent', feePercent: '0.0013725' }, +}); + +testERC20('erc20 flash mint with percent fee and fee receiver', { + flashmint: { + ...flashMintDefaults, + enabled: true, + feeMode: 'percent', + feePercent: '5', + feeDestination: 'fee_receiver', + }, +}); + +testERC20('erc20 flash mint with custom fee impl', { + flashmint: { ...flashMintDefaults, enabled: true, feeMode: 'custom' }, +}); + +testERC20('erc20 flash mint with all custom config', { + flashmint: { + enabled: true, + maxAmount: '1000000', + feeMode: 'percent', + feePercent: '5', + feeDestination: 'fee_receiver', + }, +}); + +testERC20('erc20 flash mint with max amount of zero', { + flashmint: { ...flashMintDefaults, enabled: true, maxAmount: '0' }, +}); + +test('erc20 flash mint, max amount empty string', async t => { + const error = t.throws(() => + buildERC20({ + name: 'MyToken', + symbol: 'MTK', + flashmint: { ...flashMintDefaults, enabled: true, maxAmount: '' }, + }), + ); + t.is((error as OptionsError).messages.flashMintMaxAmount, 'Must be "max" or a non-negative number'); +}); + +test('erc20 flash mint, invalid max amount', async t => { + const error = t.throws(() => + buildERC20({ + name: 'MyToken', + symbol: 'MTK', + flashmint: { ...flashMintDefaults, enabled: true, maxAmount: 'abc' }, + }), + ); + t.is((error as OptionsError).messages.flashMintMaxAmount, 'Must be "max" or a non-negative number'); +}); + +test('erc20 flash mint, invalid percent fee', async t => { + const error = t.throws(() => + buildERC20({ + name: 'MyToken', + symbol: 'MTK', + flashmint: { ...flashMintDefaults, enabled: true, feeMode: 'percent', feePercent: 'abc' }, + }), + ); + t.is((error as OptionsError).messages.flashMintFeePercent, 'Must be a number between 0 and 100'); +}); + +test('erc20 flash mint, percent fee out of range', async t => { + const error = t.throws(() => + buildERC20({ + name: 'MyToken', + symbol: 'MTK', + flashmint: { ...flashMintDefaults, enabled: true, feeMode: 'percent', feePercent: '100.5' }, + }), + ); + t.is((error as OptionsError).messages.flashMintFeePercent, 'Must be a number between 0 and 100'); +}); + +test('erc20 flash mint, max amount overflows u256', async t => { + const error = t.throws(() => + buildERC20({ + name: 'MyToken', + symbol: 'MTK', + decimals: '0', + flashmint: { + ...flashMintDefaults, + enabled: true, + // 2^256 — one past u256::MAX with decimals: '0' so getInitialSupply preserves the value. + maxAmount: '115792089237316195423570985008687907853269984665640564039457584007913129639936', + }, + }), + ); + t.is((error as OptionsError).messages.flashMintMaxAmount, 'Value is greater than u256 max value'); +}); + +test('erc20 flash mint, percent denominator overflows u256', async t => { + const error = t.throws(() => + buildERC20({ + name: 'MyToken', + symbol: 'MTK', + flashmint: { + ...flashMintDefaults, + enabled: true, + feeMode: 'percent', + // 77 fractional digits — 100 * 10^77 > u256::MAX. + feePercent: '0.' + '0'.repeat(76) + '1', + }, + }), + ); + t.is((error as OptionsError).messages.flashMintFeePercent, 'Value is greater than u256 max value'); +}); + testERC20('erc20 preminted', { premint: '1000', }); diff --git a/packages/core/cairo_alpha/src/tests/with_components_on/erc20/erc20.test.ts.md b/packages/core/cairo_alpha/src/tests/with_components_on/erc20/erc20.test.ts.md index 27b5372e2..8e75ac0df 100644 --- a/packages/core/cairo_alpha/src/tests/with_components_on/erc20/erc20.test.ts.md +++ b/packages/core/cairo_alpha/src/tests/with_components_on/erc20/erc20.test.ts.md @@ -589,6 +589,493 @@ Generated by [AVA](https://avajs.dev). }␊ ` +## erc20 flash mint + +> Snapshot 1 + + `// SPDX-License-Identifier: MIT␊ + // Compatible with OpenZeppelin Contracts for Cairo ^4.0.1␊ + ␊ + #[starknet::contract]␊ + #[with_components(ERC20, ERC20FlashMint, Upgradeable, Ownable)]␊ + mod MyToken {␊ + use openzeppelin_interfaces::upgrades::IUpgradeable;␊ + use openzeppelin_token::erc20::{DefaultConfig as ERC20DefaultConfig, ERC20HooksEmptyImpl};␊ + use openzeppelin_token::erc20::extensions::erc20_flash_mint::DefaultConfig as ERC20FlashMintDefaultConfig;␊ + use starknet::{ClassHash, ContractAddress};␊ + ␊ + // External␊ + #[abi(embed_v0)]␊ + impl ERC20MixinImpl = ERC20Component::ERC20MixinImpl;␊ + #[abi(embed_v0)]␊ + impl ERC20FlashMintImpl = ERC20FlashMintComponent::ERC20FlashMintImpl;␊ + #[abi(embed_v0)]␊ + impl OwnableMixinImpl = OwnableComponent::OwnableMixinImpl;␊ + ␊ + #[storage]␊ + struct Storage {}␊ + ␊ + #[constructor]␊ + fn constructor(ref self: ContractState, owner: ContractAddress) {␊ + self.erc20.initializer("MyToken", "MTK");␊ + self.ownable.initializer(owner);␊ + }␊ + ␊ + //␊ + // Upgradeable␊ + //␊ + ␊ + #[abi(embed_v0)]␊ + impl UpgradeableImpl of IUpgradeable {␊ + fn upgrade(ref self: ContractState, new_class_hash: ClassHash) {␊ + self.ownable.assert_only_owner();␊ + self.upgradeable.upgrade(new_class_hash);␊ + }␊ + }␊ + }␊ + ` + +## erc20 flash mint with custom max + +> Snapshot 1 + + `// SPDX-License-Identifier: MIT␊ + // Compatible with OpenZeppelin Contracts for Cairo ^4.0.1␊ + ␊ + #[starknet::contract]␊ + #[with_components(ERC20, ERC20FlashMint, Upgradeable, Ownable)]␊ + mod MyToken {␊ + use core::num::traits::Bounded;␊ + use openzeppelin_interfaces::upgrades::IUpgradeable;␊ + use openzeppelin_token::erc20::{DefaultConfig as ERC20DefaultConfig, ERC20HooksEmptyImpl};␊ + use starknet::{ClassHash, ContractAddress, get_contract_address};␊ + ␊ + // External␊ + #[abi(embed_v0)]␊ + impl ERC20MixinImpl = ERC20Component::ERC20MixinImpl;␊ + #[abi(embed_v0)]␊ + impl ERC20FlashMintImpl = ERC20FlashMintComponent::ERC20FlashMintImpl;␊ + #[abi(embed_v0)]␊ + impl OwnableMixinImpl = OwnableComponent::OwnableMixinImpl;␊ + ␊ + #[storage]␊ + struct Storage {}␊ + ␊ + #[constructor]␊ + fn constructor(ref self: ContractState, owner: ContractAddress) {␊ + self.erc20.initializer("MyToken", "MTK");␊ + self.ownable.initializer(owner);␊ + }␊ + ␊ + impl FlashMintConfigImpl of ERC20FlashMintComponent::FlashMintConfigTrait {␊ + fn max_flash_loan(␊ + self: @ERC20FlashMintComponent::ComponentState,␊ + token: ContractAddress,␊ + total_supply: u256,␊ + ) -> u256 {␊ + if token != get_contract_address() {␊ + return 0;␊ + }␊ + let headroom = Bounded::::MAX - total_supply;␊ + let cap: u256 = 1000000000000000000000000;␊ + if cap < headroom { cap } else { headroom }␊ + }␊ + }␊ + ␊ + //␊ + // Upgradeable␊ + //␊ + ␊ + #[abi(embed_v0)]␊ + impl UpgradeableImpl of IUpgradeable {␊ + fn upgrade(ref self: ContractState, new_class_hash: ClassHash) {␊ + self.ownable.assert_only_owner();␊ + self.upgradeable.upgrade(new_class_hash);␊ + }␊ + }␊ + }␊ + ` + +## erc20 flash mint with percent fee + +> Snapshot 1 + + `// SPDX-License-Identifier: MIT␊ + // Compatible with OpenZeppelin Contracts for Cairo ^4.0.1␊ + ␊ + #[starknet::contract]␊ + #[with_components(ERC20, ERC20FlashMint, Upgradeable, Ownable)]␊ + mod MyToken {␊ + use openzeppelin_interfaces::upgrades::IUpgradeable;␊ + use openzeppelin_token::erc20::{DefaultConfig as ERC20DefaultConfig, ERC20HooksEmptyImpl};␊ + use starknet::{ClassHash, ContractAddress};␊ + ␊ + // External␊ + #[abi(embed_v0)]␊ + impl ERC20MixinImpl = ERC20Component::ERC20MixinImpl;␊ + #[abi(embed_v0)]␊ + impl ERC20FlashMintImpl = ERC20FlashMintComponent::ERC20FlashMintImpl;␊ + #[abi(embed_v0)]␊ + impl OwnableMixinImpl = OwnableComponent::OwnableMixinImpl;␊ + ␊ + #[storage]␊ + struct Storage {}␊ + ␊ + #[constructor]␊ + fn constructor(ref self: ContractState, owner: ContractAddress) {␊ + self.erc20.initializer("MyToken", "MTK");␊ + self.ownable.initializer(owner);␊ + }␊ + ␊ + impl FlashMintConfigImpl of ERC20FlashMintComponent::FlashMintConfigTrait {␊ + fn flash_fee(␊ + self: @ERC20FlashMintComponent::ComponentState,␊ + token: ContractAddress,␊ + amount: u256,␊ + ) -> u256 {␊ + amount * 5 / 100␊ + }␊ + }␊ + ␊ + //␊ + // Upgradeable␊ + //␊ + ␊ + #[abi(embed_v0)]␊ + impl UpgradeableImpl of IUpgradeable {␊ + fn upgrade(ref self: ContractState, new_class_hash: ClassHash) {␊ + self.ownable.assert_only_owner();␊ + self.upgradeable.upgrade(new_class_hash);␊ + }␊ + }␊ + }␊ + ` + +## erc20 flash mint with fractional percent fee + +> Snapshot 1 + + `// SPDX-License-Identifier: MIT␊ + // Compatible with OpenZeppelin Contracts for Cairo ^4.0.1␊ + ␊ + #[starknet::contract]␊ + #[with_components(ERC20, ERC20FlashMint, Upgradeable, Ownable)]␊ + mod MyToken {␊ + use openzeppelin_interfaces::upgrades::IUpgradeable;␊ + use openzeppelin_token::erc20::{DefaultConfig as ERC20DefaultConfig, ERC20HooksEmptyImpl};␊ + use starknet::{ClassHash, ContractAddress};␊ + ␊ + // External␊ + #[abi(embed_v0)]␊ + impl ERC20MixinImpl = ERC20Component::ERC20MixinImpl;␊ + #[abi(embed_v0)]␊ + impl ERC20FlashMintImpl = ERC20FlashMintComponent::ERC20FlashMintImpl;␊ + #[abi(embed_v0)]␊ + impl OwnableMixinImpl = OwnableComponent::OwnableMixinImpl;␊ + ␊ + #[storage]␊ + struct Storage {}␊ + ␊ + #[constructor]␊ + fn constructor(ref self: ContractState, owner: ContractAddress) {␊ + self.erc20.initializer("MyToken", "MTK");␊ + self.ownable.initializer(owner);␊ + }␊ + ␊ + impl FlashMintConfigImpl of ERC20FlashMintComponent::FlashMintConfigTrait {␊ + fn flash_fee(␊ + self: @ERC20FlashMintComponent::ComponentState,␊ + token: ContractAddress,␊ + amount: u256,␊ + ) -> u256 {␊ + amount * 13725 / 1000000000␊ + }␊ + }␊ + ␊ + //␊ + // Upgradeable␊ + //␊ + ␊ + #[abi(embed_v0)]␊ + impl UpgradeableImpl of IUpgradeable {␊ + fn upgrade(ref self: ContractState, new_class_hash: ClassHash) {␊ + self.ownable.assert_only_owner();␊ + self.upgradeable.upgrade(new_class_hash);␊ + }␊ + }␊ + }␊ + ` + +## erc20 flash mint with percent fee and fee receiver + +> Snapshot 1 + + `// SPDX-License-Identifier: MIT␊ + // Compatible with OpenZeppelin Contracts for Cairo ^4.0.1␊ + ␊ + #[starknet::contract]␊ + #[with_components(ERC20, ERC20FlashMint, Upgradeable, Ownable)]␊ + mod MyToken {␊ + use core::num::traits::Zero;␊ + use openzeppelin_interfaces::upgrades::IUpgradeable;␊ + use openzeppelin_token::erc20::{DefaultConfig as ERC20DefaultConfig, ERC20HooksEmptyImpl};␊ + use starknet::{ClassHash, ContractAddress};␊ + use starknet::storage::{StoragePointerReadAccess, StoragePointerWriteAccess};␊ + ␊ + // External␊ + #[abi(embed_v0)]␊ + impl ERC20MixinImpl = ERC20Component::ERC20MixinImpl;␊ + #[abi(embed_v0)]␊ + impl ERC20FlashMintImpl = ERC20FlashMintComponent::ERC20FlashMintImpl;␊ + #[abi(embed_v0)]␊ + impl OwnableMixinImpl = OwnableComponent::OwnableMixinImpl;␊ + ␊ + #[storage]␊ + struct Storage {␊ + flash_fee_receiver: ContractAddress,␊ + }␊ + ␊ + #[constructor]␊ + fn constructor(␊ + ref self: ContractState,␊ + flash_fee_receiver: ContractAddress,␊ + owner: ContractAddress,␊ + ) {␊ + self.erc20.initializer("MyToken", "MTK");␊ + self.ownable.initializer(owner);␊ + ␊ + assert(!flash_fee_receiver.is_zero(), 'FlashMint: invalid receiver');␊ + self.flash_fee_receiver.write(flash_fee_receiver);␊ + }␊ + ␊ + impl FlashMintConfigImpl of ERC20FlashMintComponent::FlashMintConfigTrait {␊ + fn flash_fee(␊ + self: @ERC20FlashMintComponent::ComponentState,␊ + token: ContractAddress,␊ + amount: u256,␊ + ) -> u256 {␊ + amount * 5 / 100␊ + }␊ + ␊ + fn flash_fee_receiver(self: @ERC20FlashMintComponent::ComponentState) -> ContractAddress {␊ + self.get_contract().flash_fee_receiver.read()␊ + }␊ + }␊ + ␊ + //␊ + // Upgradeable␊ + //␊ + ␊ + #[abi(embed_v0)]␊ + impl UpgradeableImpl of IUpgradeable {␊ + fn upgrade(ref self: ContractState, new_class_hash: ClassHash) {␊ + self.ownable.assert_only_owner();␊ + self.upgradeable.upgrade(new_class_hash);␊ + }␊ + }␊ + }␊ + ` + +## erc20 flash mint with custom fee impl + +> Snapshot 1 + + `// SPDX-License-Identifier: MIT␊ + // Compatible with OpenZeppelin Contracts for Cairo ^4.0.1␊ + ␊ + #[starknet::contract]␊ + #[with_components(ERC20, ERC20FlashMint, Upgradeable, Ownable)]␊ + mod MyToken {␊ + use openzeppelin_interfaces::upgrades::IUpgradeable;␊ + use openzeppelin_token::erc20::{DefaultConfig as ERC20DefaultConfig, ERC20HooksEmptyImpl};␊ + use starknet::{ClassHash, ContractAddress};␊ + ␊ + // External␊ + #[abi(embed_v0)]␊ + impl ERC20MixinImpl = ERC20Component::ERC20MixinImpl;␊ + #[abi(embed_v0)]␊ + impl ERC20FlashMintImpl = ERC20FlashMintComponent::ERC20FlashMintImpl;␊ + #[abi(embed_v0)]␊ + impl OwnableMixinImpl = OwnableComponent::OwnableMixinImpl;␊ + ␊ + #[storage]␊ + struct Storage {}␊ + ␊ + #[constructor]␊ + fn constructor(ref self: ContractState, owner: ContractAddress) {␊ + self.erc20.initializer("MyToken", "MTK");␊ + self.ownable.initializer(owner);␊ + }␊ + ␊ + impl FlashMintConfigImpl of ERC20FlashMintComponent::FlashMintConfigTrait {␊ + fn flash_fee(␊ + self: @ERC20FlashMintComponent::ComponentState,␊ + token: ContractAddress,␊ + amount: u256,␊ + ) -> u256 {␊ + // TODO: Must be implemented according to the desired flash fee logic;␊ + 0␊ + }␊ + }␊ + ␊ + //␊ + // Upgradeable␊ + //␊ + ␊ + #[abi(embed_v0)]␊ + impl UpgradeableImpl of IUpgradeable {␊ + fn upgrade(ref self: ContractState, new_class_hash: ClassHash) {␊ + self.ownable.assert_only_owner();␊ + self.upgradeable.upgrade(new_class_hash);␊ + }␊ + }␊ + }␊ + ` + +## erc20 flash mint with all custom config + +> Snapshot 1 + + `// SPDX-License-Identifier: MIT␊ + // Compatible with OpenZeppelin Contracts for Cairo ^4.0.1␊ + ␊ + #[starknet::contract]␊ + #[with_components(ERC20, ERC20FlashMint, Upgradeable, Ownable)]␊ + mod MyToken {␊ + use core::num::traits::{Bounded, Zero};␊ + use openzeppelin_interfaces::upgrades::IUpgradeable;␊ + use openzeppelin_token::erc20::{DefaultConfig as ERC20DefaultConfig, ERC20HooksEmptyImpl};␊ + use starknet::{ClassHash, ContractAddress, get_contract_address};␊ + use starknet::storage::{StoragePointerReadAccess, StoragePointerWriteAccess};␊ + ␊ + // External␊ + #[abi(embed_v0)]␊ + impl ERC20MixinImpl = ERC20Component::ERC20MixinImpl;␊ + #[abi(embed_v0)]␊ + impl ERC20FlashMintImpl = ERC20FlashMintComponent::ERC20FlashMintImpl;␊ + #[abi(embed_v0)]␊ + impl OwnableMixinImpl = OwnableComponent::OwnableMixinImpl;␊ + ␊ + #[storage]␊ + struct Storage {␊ + flash_fee_receiver: ContractAddress,␊ + }␊ + ␊ + #[constructor]␊ + fn constructor(␊ + ref self: ContractState,␊ + flash_fee_receiver: ContractAddress,␊ + owner: ContractAddress,␊ + ) {␊ + self.erc20.initializer("MyToken", "MTK");␊ + self.ownable.initializer(owner);␊ + ␊ + assert(!flash_fee_receiver.is_zero(), 'FlashMint: invalid receiver');␊ + self.flash_fee_receiver.write(flash_fee_receiver);␊ + }␊ + ␊ + impl FlashMintConfigImpl of ERC20FlashMintComponent::FlashMintConfigTrait {␊ + fn max_flash_loan(␊ + self: @ERC20FlashMintComponent::ComponentState,␊ + token: ContractAddress,␊ + total_supply: u256,␊ + ) -> u256 {␊ + if token != get_contract_address() {␊ + return 0;␊ + }␊ + let headroom = Bounded::::MAX - total_supply;␊ + let cap: u256 = 1000000000000000000000000;␊ + if cap < headroom { cap } else { headroom }␊ + }␊ + ␊ + fn flash_fee(␊ + self: @ERC20FlashMintComponent::ComponentState,␊ + token: ContractAddress,␊ + amount: u256,␊ + ) -> u256 {␊ + amount * 5 / 100␊ + }␊ + ␊ + fn flash_fee_receiver(self: @ERC20FlashMintComponent::ComponentState) -> ContractAddress {␊ + self.get_contract().flash_fee_receiver.read()␊ + }␊ + }␊ + ␊ + //␊ + // Upgradeable␊ + //␊ + ␊ + #[abi(embed_v0)]␊ + impl UpgradeableImpl of IUpgradeable {␊ + fn upgrade(ref self: ContractState, new_class_hash: ClassHash) {␊ + self.ownable.assert_only_owner();␊ + self.upgradeable.upgrade(new_class_hash);␊ + }␊ + }␊ + }␊ + ` + +## erc20 flash mint with max amount of zero + +> Snapshot 1 + + `// SPDX-License-Identifier: MIT␊ + // Compatible with OpenZeppelin Contracts for Cairo ^4.0.1␊ + ␊ + #[starknet::contract]␊ + #[with_components(ERC20, ERC20FlashMint, Upgradeable, Ownable)]␊ + mod MyToken {␊ + use core::num::traits::Bounded;␊ + use openzeppelin_interfaces::upgrades::IUpgradeable;␊ + use openzeppelin_token::erc20::{DefaultConfig as ERC20DefaultConfig, ERC20HooksEmptyImpl};␊ + use starknet::{ClassHash, ContractAddress, get_contract_address};␊ + ␊ + // External␊ + #[abi(embed_v0)]␊ + impl ERC20MixinImpl = ERC20Component::ERC20MixinImpl;␊ + #[abi(embed_v0)]␊ + impl ERC20FlashMintImpl = ERC20FlashMintComponent::ERC20FlashMintImpl;␊ + #[abi(embed_v0)]␊ + impl OwnableMixinImpl = OwnableComponent::OwnableMixinImpl;␊ + ␊ + #[storage]␊ + struct Storage {}␊ + ␊ + #[constructor]␊ + fn constructor(ref self: ContractState, owner: ContractAddress) {␊ + self.erc20.initializer("MyToken", "MTK");␊ + self.ownable.initializer(owner);␊ + }␊ + ␊ + impl FlashMintConfigImpl of ERC20FlashMintComponent::FlashMintConfigTrait {␊ + fn max_flash_loan(␊ + self: @ERC20FlashMintComponent::ComponentState,␊ + token: ContractAddress,␊ + total_supply: u256,␊ + ) -> u256 {␊ + if token != get_contract_address() {␊ + return 0;␊ + }␊ + let headroom = Bounded::::MAX - total_supply;␊ + let cap: u256 = 0;␊ + if cap < headroom { cap } else { headroom }␊ + }␊ + }␊ + ␊ + //␊ + // Upgradeable␊ + //␊ + ␊ + #[abi(embed_v0)]␊ + impl UpgradeableImpl of IUpgradeable {␊ + fn upgrade(ref self: ContractState, new_class_hash: ClassHash) {␊ + self.ownable.assert_only_owner();␊ + self.upgradeable.upgrade(new_class_hash);␊ + }␊ + }␊ + }␊ + ` + ## erc20 preminted > Snapshot 1 diff --git a/packages/core/cairo_alpha/src/tests/with_components_on/erc20/erc20.test.ts.snap b/packages/core/cairo_alpha/src/tests/with_components_on/erc20/erc20.test.ts.snap index f064e2142..ffc3117e0 100644 Binary files a/packages/core/cairo_alpha/src/tests/with_components_on/erc20/erc20.test.ts.snap and b/packages/core/cairo_alpha/src/tests/with_components_on/erc20/erc20.test.ts.snap differ diff --git a/packages/core/solidity/CHANGELOG.md b/packages/core/solidity/CHANGELOG.md index 7609fa9c0..1369f2802 100644 --- a/packages/core/solidity/CHANGELOG.md +++ b/packages/core/solidity/CHANGELOG.md @@ -1,6 +1,10 @@ # Changelog +## 0.10.16 (2026-09-08) + +- Improve performance of premint amount parsing. ([#872](https://github.com/OpenZeppelin/contracts-wizard/pull/872)) + ## 0.10.15 (2026-09-03) - Update `@openzeppelin/tron-contracts` and `@openzeppelin/tron-contracts-upgradeable` dependencies to 5.6.0. ([#865](https://github.com/OpenZeppelin/contracts-wizard/pull/865)) diff --git a/packages/core/solidity/package.json b/packages/core/solidity/package.json index 24eee9912..ee5039efb 100644 --- a/packages/core/solidity/package.json +++ b/packages/core/solidity/package.json @@ -1,6 +1,6 @@ { "name": "@openzeppelin/wizard", - "version": "0.10.15", + "version": "0.10.16", "description": "A boilerplate generator to get started with OpenZeppelin Contracts", "license": "AGPL-3.0-only", "repository": "https://github.com/OpenZeppelin/contracts-wizard", diff --git a/packages/core/solidity/src/erc20.test.ts b/packages/core/solidity/src/erc20.test.ts index 99c533077..a462a966c 100644 --- a/packages/core/solidity/src/erc20.test.ts +++ b/packages/core/solidity/src/erc20.test.ts @@ -3,7 +3,7 @@ import type { OptionsError } from '.'; import { erc20 } from '.'; import type { ERC20Options } from './erc20'; -import { buildERC20 } from './erc20'; +import { buildERC20, calculatePremint } from './erc20'; import { printContract } from './print'; function testERC20(title: string, opts: Partial) { @@ -159,6 +159,12 @@ testPremint( testPremint('e notation', '1e59'); testPremint('e notation arithmetic overflow', '1e60', 'Amount would overflow uint256 after applying decimals'); +test('calculatePremint strips redundant zeros', t => { + t.deepEqual(calculatePremint('1.5000'), calculatePremint('1.5')); + t.deepEqual(calculatePremint('001.5'), calculatePremint('1.5')); + t.deepEqual(calculatePremint('0.0'), calculatePremint('0')); +}); + testERC20('erc20 mintable', { mintable: true, access: 'ownable', diff --git a/packages/core/solidity/src/erc20.ts b/packages/core/solidity/src/erc20.ts index b4b070826..d3324f534 100644 --- a/packages/core/solidity/src/erc20.ts +++ b/packages/core/solidity/src/erc20.ts @@ -219,6 +219,22 @@ export interface PremintCalculation { decimalPlace: number; } +function stripLeadingZeros(value: string): string { + let i = 0; + while (i < value.length && value[i] === '0') { + i++; + } + return value.slice(i); +} + +function stripTrailingZeros(value: string): string { + let i = value.length; + while (i > 0 && value[i - 1] === '0') { + i--; + } + return value.slice(0, i); +} + export function calculatePremint(amount: string): PremintCalculation | undefined { const m = amount.match(premintPattern); if (!m) { @@ -227,8 +243,8 @@ export function calculatePremint(amount: string): PremintCalculation | undefined }); } - const integer = m[1]?.replace(/^0+/, '') ?? ''; - const decimals = m[2]?.replace(/0+$/, '') ?? ''; + const integer = m[1] !== undefined ? stripLeadingZeros(m[1]) : ''; + const decimals = m[2] !== undefined ? stripTrailingZeros(m[2]) : ''; const exponent = Number(m[3] ?? 0); if (Number(integer + decimals) > 0) { diff --git a/packages/core/stellar/CHANGELOG.md b/packages/core/stellar/CHANGELOG.md index aab55819e..1ada67934 100644 --- a/packages/core/stellar/CHANGELOG.md +++ b/packages/core/stellar/CHANGELOG.md @@ -1,6 +1,10 @@ # Changelog +## 0.6.5 (2026-09-08) + +- Improve performance of premint amount parsing. ([#872](https://github.com/OpenZeppelin/contracts-wizard/pull/872)) + ## 0.6.4 (2026-08-17) - Add Stellar `Account` contract type, a smart account combining Delegated, Ed25519 and WebAuthn (passkey) signers with an optional simple or weighted threshold policy, and support for execution entry point and upgradeable options. ([#826](https://github.com/OpenZeppelin/contracts-wizard/pull/826)) diff --git a/packages/core/stellar/package.json b/packages/core/stellar/package.json index e3e153088..d8e2bce80 100644 --- a/packages/core/stellar/package.json +++ b/packages/core/stellar/package.json @@ -1,6 +1,6 @@ { "name": "@openzeppelin/wizard-stellar", - "version": "0.6.4", + "version": "0.6.5", "description": "A boilerplate generator to get started with OpenZeppelin Stellar Soroban Contracts", "license": "AGPL-3.0-only", "repository": "https://github.com/OpenZeppelin/contracts-wizard", diff --git a/packages/core/stellar/src/fungible.ts b/packages/core/stellar/src/fungible.ts index f6ddf3505..f4bc3b639 100644 --- a/packages/core/stellar/src/fungible.ts +++ b/packages/core/stellar/src/fungible.ts @@ -250,7 +250,7 @@ function addBurnable(c: ContractBuilder, votes: boolean, pausable: boolean, expl } } -export const premintPattern = /^(\d*\.?\d*)$/; +export const premintPattern = /^\d*(?:\.\d*)?$/; function addPremint(c: ContractBuilder, amount: string, decimals: bigint, votes: boolean) { if (amount !== undefined && amount !== '0') { diff --git a/packages/mcp/CHANGELOG.md b/packages/mcp/CHANGELOG.md index ecbdc5ceb..e24b35695 100644 --- a/packages/mcp/CHANGELOG.md +++ b/packages/mcp/CHANGELOG.md @@ -1,6 +1,17 @@ # Changelog +## 0.7.1 (2026-09-08) + +- Cairo: Add ERC20FlashMint extension for the ERC20 token kind. ([#870](https://github.com/OpenZeppelin/contracts-wizard/pull/870)) + - Configurable max flash loan (default or custom cap), flash fee (percent of the loan amount or custom stub), and fee destination (burn or fee receiver). + - Add `flashmint` options to the `cairo-erc20` MCP tool and CLI command. +- Updated dependencies [[`5ca9bbd`](https://github.com/OpenZeppelin/contracts-wizard/commit/5ca9bbd13725dc7baf74ca8d8ca8e1bdf5e0a94f), [`df1fa2f`](https://github.com/OpenZeppelin/contracts-wizard/commit/df1fa2ffb82bc7ee3652b165e7ef045b17b3664a)]: + - @openzeppelin/wizard-cairo@4.1.0 + - @openzeppelin/wizard-common@0.5.9 + - @openzeppelin/wizard-stellar@0.6.5 + - @openzeppelin/wizard@0.10.16 + ## 0.7.0 (2026-09-01) - Cairo: support OpenZeppelin Contracts for Cairo v4.0.1. ([#861](https://github.com/OpenZeppelin/contracts-wizard/pull/861)) diff --git a/packages/mcp/package.json b/packages/mcp/package.json index 91d014229..c0b02ef85 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -1,6 +1,6 @@ { "name": "@openzeppelin/contracts-mcp", - "version": "0.7.0", + "version": "0.7.1", "description": "OpenZeppelin Contracts MCP Server", "license": "AGPL-3.0-only", "repository": "https://github.com/OpenZeppelin/contracts-wizard", @@ -27,11 +27,11 @@ }, "dependencies": { "@modelcontextprotocol/sdk": "^1.29.0", - "@openzeppelin/wizard-common": "^0.5.8", - "@openzeppelin/wizard": "^0.10.14", + "@openzeppelin/wizard-common": "^0.5.9", + "@openzeppelin/wizard": "^0.10.16", "@openzeppelin/wizard-stylus": "^0.3.0", - "@openzeppelin/wizard-stellar": "^0.6.4", - "@openzeppelin/wizard-cairo": "^4.0.0", + "@openzeppelin/wizard-stellar": "^0.6.5", + "@openzeppelin/wizard-cairo": "^4.1.0", "@openzeppelin/wizard-confidential": "^0.1.2", "@openzeppelin/wizard-uniswap-hooks": "^0.1.1" }, diff --git a/packages/mcp/src/cairo/tools/erc20.test.ts b/packages/mcp/src/cairo/tools/erc20.test.ts index af1a1eddf..1a7857231 100644 --- a/packages/mcp/src/cairo/tools/erc20.test.ts +++ b/packages/mcp/src/cairo/tools/erc20.test.ts @@ -57,6 +57,13 @@ test('all', async t => { votes: true, appName: 'MyToken', appVersion: 'v1', + flashmint: { + enabled: true, + maxAmount: '1000000', + feeMode: 'percent', + feePercent: '0.5', + feeDestination: 'fee_receiver', + }, access: { type: 'roles', darInitialDelay: '0', diff --git a/packages/mcp/src/cairo/tools/erc20.ts b/packages/mcp/src/cairo/tools/erc20.ts index dd01f8f6e..12725978d 100644 --- a/packages/mcp/src/cairo/tools/erc20.ts +++ b/packages/mcp/src/cairo/tools/erc20.ts @@ -27,6 +27,7 @@ export function registerCairoERC20(server: McpServer): RegisteredTool { votes, appName, appVersion, + flashmint, access, upgradeable, info, @@ -44,6 +45,7 @@ export function registerCairoERC20(server: McpServer): RegisteredTool { votes, appName, appVersion, + flashmint, access, upgradeable, info, diff --git a/packages/ui/CHANGELOG.md b/packages/ui/CHANGELOG.md index 86f686726..a8b630e83 100644 --- a/packages/ui/CHANGELOG.md +++ b/packages/ui/CHANGELOG.md @@ -1,6 +1,12 @@ # Changelog +## 0.0.5 (2026-09-08) + +- Cairo: Add ERC20FlashMint extension for the ERC20 token kind. ([#870](https://github.com/OpenZeppelin/contracts-wizard/pull/870)) + - Configurable max flash loan (default or custom cap), flash fee (percent of the loan amount or custom stub), and fee destination (burn or fee receiver). + - Add `flashmint` options to the `cairo-erc20` MCP tool and CLI command. + ## 0.0.4 (2026-09-01) - Cairo: support OpenZeppelin Contracts for Cairo v4.0.1. ([#861](https://github.com/OpenZeppelin/contracts-wizard/pull/861)) diff --git a/packages/ui/api/ai-assistant/function-definitions/cairo-alpha.ts b/packages/ui/api/ai-assistant/function-definitions/cairo-alpha.ts index d75ca185d..2ff13b9af 100644 --- a/packages/ui/api/ai-assistant/function-definitions/cairo-alpha.ts +++ b/packages/ui/api/ai-assistant/function-definitions/cairo-alpha.ts @@ -52,6 +52,34 @@ export const cairoAlphaERC20AIFunctionDefinition = { type: 'boolean', description: cairoERC20Descriptions.votes, }, + flashmint: { + type: 'object', + description: cairoERC20Descriptions.flashmint, + properties: { + enabled: { + type: 'boolean', + description: cairoERC20Descriptions.flashMintEnabled, + }, + maxAmount: { + type: 'string', + description: cairoERC20Descriptions.flashMintMaxAmount, + }, + feeMode: { + type: 'string', + enum: ['percent', 'custom'], + description: cairoERC20Descriptions.flashMintFeeMode, + }, + feePercent: { + type: 'string', + description: cairoERC20Descriptions.flashMintFeePercent, + }, + feeDestination: { + type: 'string', + enum: ['burn', 'fee_receiver'], + description: cairoERC20Descriptions.flashMintFeeDestination, + }, + }, + }, }, required: contractExactRequiredKeys<'cairoAlpha', 'ERC20'>()(['name', 'symbol']), additionalProperties: false, diff --git a/packages/ui/api/ai-assistant/function-definitions/cairo.ts b/packages/ui/api/ai-assistant/function-definitions/cairo.ts index 87223f53e..3e9844363 100644 --- a/packages/ui/api/ai-assistant/function-definitions/cairo.ts +++ b/packages/ui/api/ai-assistant/function-definitions/cairo.ts @@ -52,6 +52,34 @@ export const cairoERC20AIFunctionDefinition = { type: 'boolean', description: cairoERC20Descriptions.votes, }, + flashmint: { + type: 'object', + description: cairoERC20Descriptions.flashmint, + properties: { + enabled: { + type: 'boolean', + description: cairoERC20Descriptions.flashMintEnabled, + }, + maxAmount: { + type: 'string', + description: cairoERC20Descriptions.flashMintMaxAmount, + }, + feeMode: { + type: 'string', + enum: ['percent', 'custom'], + description: cairoERC20Descriptions.flashMintFeeMode, + }, + feePercent: { + type: 'string', + description: cairoERC20Descriptions.flashMintFeePercent, + }, + feeDestination: { + type: 'string', + enum: ['burn', 'fee_receiver'], + description: cairoERC20Descriptions.flashMintFeeDestination, + }, + }, + }, }, required: contractExactRequiredKeys<'cairo', 'ERC20'>()(['name', 'symbol']), additionalProperties: false, diff --git a/packages/ui/package.json b/packages/ui/package.json index 2a088a23b..c0b4461ae 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "ui", - "version": "0.0.4", + "version": "0.0.5", "private": true, "scripts": { "prepare": "echo \"Nothing to prepare\"", diff --git a/packages/ui/src/cairo/ERC20Controls.svelte b/packages/ui/src/cairo/ERC20Controls.svelte index adbc2d014..6722aefdf 100644 --- a/packages/ui/src/cairo/ERC20Controls.svelte +++ b/packages/ui/src/cairo/ERC20Controls.svelte @@ -1,8 +1,8 @@
@@ -126,6 +158,139 @@ + +
+ + Max Flash Loan + Maximum amount of tokens that can be flash-loaned in a single call. + +
+ + +
+
+ +
+ + Flash Fee + Fee charged for each flash loan. + +
+ + +
+
+ + {#if hasPositiveFlashFee} +
+ + Flash Fee Destination + Where the flash loan fee goes. + +
+ + +
+
+ {/if} +
+ import HelpTooltip from '../common/HelpTooltip.svelte'; - import type { KindedOptions, OptionsErrorMessages } from '@openzeppelin/wizard-cairo-alpha'; - import { premintPattern, erc20, infoDefaults, macrosDefaults } from '@openzeppelin/wizard-cairo-alpha'; + import type { FlashMintOptions, KindedOptions, OptionsErrorMessages } from '@openzeppelin/wizard-cairo-alpha'; + import { + premintPattern, + erc20, + flashMintDefaults, + infoDefaults, + macrosDefaults, + } from '@openzeppelin/wizard-cairo-alpha'; import AccessControlSection from './AccessControlSection.svelte'; import UpgradeabilityField from './UpgradeabilityField.svelte'; @@ -10,6 +16,7 @@ import MacrosSection from './MacrosSection.svelte'; import ExpandableCheckbox from '../common/ExpandableCheckbox.svelte'; import { error } from '../common/error-tooltip'; + import { resizeToFit } from '../common/resize-to-fit'; export let opts: Required = { kind: 'ERC20', @@ -18,11 +25,42 @@ info: { ...infoDefaults }, // create new object since Info is nested macros: { ...macrosDefaults }, // create new object since MacrosOptions is nested access: { ...erc20.defaults.access }, // create new object since Access is nested + flashmint: { ...flashMintDefaults }, // create new object since FlashMintOptions is nested }; export let errors: undefined | OptionsErrorMessages; $: requireAccessControl = erc20.isAccessControlRequired(opts); + $: hasPositiveFlashFee = opts.flashmint.enabled && computeHasPositiveFlashFee(opts.flashmint); + + function computeHasPositiveFlashFee(o: FlashMintOptions): boolean { + switch (o.feeMode) { + case 'percent': + return parseFloat(o.feePercent || '0') > 0; + case 'custom': + return true; + } + } + + // Stash the last custom value so toggling Max → Custom restores what the user previously typed. + let lastCustomMax: string = opts.flashmint.maxAmount === 'max' ? '0' : opts.flashmint.maxAmount; + $: if (opts.flashmint.maxAmount !== 'max') { + lastCustomMax = opts.flashmint.maxAmount; + } + + function selectMaxAmountMax() { + opts.flashmint.maxAmount = 'max'; + } + function selectMaxAmountCustom() { + opts.flashmint.maxAmount = lastCustomMax; + } + + // The fee is a percentage of the loan amount, so cap typed values at 100 instead of surfacing an error. + function clampFlashFeePercent(event: Event) { + if (parseFloat((event.currentTarget as HTMLInputElement).value) > 100) { + opts.flashmint.feePercent = '100'; + } + }
@@ -126,6 +164,139 @@ + +
+ + Max Flash Loan + Maximum amount of tokens that can be flash-loaned in a single call. + +
+ + +
+
+ +
+ + Flash Fee + Fee charged for each flash loan. + +
+ + +
+
+ + {#if hasPositiveFlashFee} +
+ + Flash Fee Destination + Where the flash loan fee goes. + +
+ + +
+
+ {/if} +
+