diff --git a/.changeset/erc20-flash-mint.md b/.changeset/erc20-flash-mint.md new file mode 100644 index 000000000..0358aa104 --- /dev/null +++ b/.changeset/erc20-flash-mint.md @@ -0,0 +1,5 @@ +--- +'@openzeppelin/wizard-common': patch +--- + +Cairo: Add ERC20FlashMint extension and AI descriptions for ERC20 token kind. 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/packages/common/src/ai/descriptions/cairo.ts b/packages/common/src/ai/descriptions/cairo.ts index b2c18ab42..facbe2ffe 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/core/cairo_alpha/CHANGELOG.md b/packages/core/cairo_alpha/CHANGELOG.md index 1c59ef218..3bc2122ee 100644 --- a/packages/core/cairo_alpha/CHANGELOG.md +++ b/packages/core/cairo_alpha/CHANGELOG.md @@ -11,6 +11,7 @@ - Add ERC721URIStorage extension ([#772](https://github.com/OpenZeppelin/contracts-wizard/pull/772)) - Add ERC721Wrapper extension ([#764](https://github.com/OpenZeppelin/contracts-wizard/pull/764)) - Add ERC20Wrapper extension ([#763](https://github.com/OpenZeppelin/contracts-wizard/pull/763)) +- Add ERC20FlashMint extension ([#801](https://github.com/OpenZeppelin/contracts-wizard/pull/801)) - **Breaking changes**: - Use OpenZeppelin Contracts for Cairo v4.0.0-alpha.0. 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 8a02a534f..cd3769e26 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); @@ -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 dcf6bb2a3..63b0dbfda 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.0-alpha.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.0-alpha.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.0-alpha.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.0-alpha.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.0-alpha.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.0-alpha.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.0-alpha.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.0-alpha.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 fd269e168..2a8f47d6e 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 3041c00d1..66b501d24 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.0-alpha.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.0-alpha.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.0-alpha.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.0-alpha.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.0-alpha.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.0-alpha.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.0-alpha.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.0-alpha.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 abc75e9e7..ec9168dc4 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/ui/api/ai-assistant/function-definitions/cairo-alpha.ts b/packages/ui/api/ai-assistant/function-definitions/cairo-alpha.ts index 051ff5750..38dc32ab8 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/src/cairo_alpha/ERC20Controls.svelte b/packages/ui/src/cairo_alpha/ERC20Controls.svelte index 3f4737344..dac56ea07 100644 --- a/packages/ui/src/cairo_alpha/ERC20Controls.svelte +++ b/packages/ui/src/cairo_alpha/ERC20Controls.svelte @@ -1,8 +1,14 @@
@@ -126,6 +157,127 @@ + +
+ + Max Flash Loan + + Maximum amount of tokens that can be flash-loaned in a single call. Max inherits the default (the + maximum representable amount minus the current total supply). Custom lets you set a non-negative cap; + setting it to 0 effectively disables flash loans. + + +
+ + +
+
+ +
+ + Flash Fee + + Fee charged for each flash loan. Choose Percent to charge a percentage of the loan amount, or + Custom to emit a TODO stub you can fill in manually. + + +
+ + +
+
+ + {#if hasPositiveFlashFee} +
+ + Flash Fee Destination + + Where the flash loan fee goes. Burn it (default) or send it to a fee receiver address set at deploy time. + + +
+ + +
+
+ {/if} +
+