diff --git a/packages/core-cairo/CHANGELOG.md b/packages/core-cairo/CHANGELOG.md index 4b6d5c18b..a62296b4f 100644 --- a/packages/core-cairo/CHANGELOG.md +++ b/packages/core-cairo/CHANGELOG.md @@ -1,7 +1,8 @@ # Changelog -## Unreleased +## 0.21.0 (2025-01-09) +- Add Vesting tab. ([#425](https://github.com/OpenZeppelin/contracts-wizard/pull/425)) - Update Contracts Wizard license to AGPLv3. ([#424](https://github.com/OpenZeppelin/contracts-wizard/pull/424)) ## 0.20.1 (2024-12-17) diff --git a/packages/core-cairo/package.json b/packages/core-cairo/package.json index 0793295a9..1c85b0bf2 100644 --- a/packages/core-cairo/package.json +++ b/packages/core-cairo/package.json @@ -1,6 +1,6 @@ { "name": "@openzeppelin/wizard-cairo", - "version": "0.20.1", + "version": "0.21.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/api.ts b/packages/core-cairo/src/api.ts index d5634fb5c..a061e6993 100644 --- a/packages/core-cairo/src/api.ts +++ b/packages/core-cairo/src/api.ts @@ -5,6 +5,7 @@ import { printERC1155, defaults as erc1155defaults, isAccessControlRequired as e import { printAccount, defaults as accountDefaults, AccountOptions } from './account'; import { printGovernor, defaults as governorDefaults, isAccessControlRequired as governorIsAccessControlRequired, GovernorOptions } from './governor'; import { printCustom, defaults as customDefaults, isAccessControlRequired as customIsAccessControlRequired, CustomOptions } from './custom'; +import { printVesting, defaults as vestingDefaults, isAccessControlRequired as vestingIsAccessControlRequired, VestingOptions } from './vesting'; export interface WizardAccountAPI{ /** @@ -41,6 +42,7 @@ export type ERC721 = WizardContractAPI; export type ERC1155 = WizardContractAPI; export type Account = WizardAccountAPI; export type Governor = WizardContractAPI; +export type Vesting = WizardContractAPI; export type Custom = WizardContractAPI; export const erc20: ERC20 = { @@ -67,6 +69,11 @@ export const governor: Governor = { defaults: governorDefaults, isAccessControlRequired: governorIsAccessControlRequired } +export const vesting: Vesting = { + print: printVesting, + defaults: vestingDefaults, + isAccessControlRequired: vestingIsAccessControlRequired +} export const custom: Custom = { print: printCustom, defaults: customDefaults, diff --git a/packages/core-cairo/src/build-generic.ts b/packages/core-cairo/src/build-generic.ts index 27fec9d27..fc04c30dc 100644 --- a/packages/core-cairo/src/build-generic.ts +++ b/packages/core-cairo/src/build-generic.ts @@ -4,12 +4,15 @@ import { ERC1155Options, buildERC1155 } from './erc1155'; import { CustomOptions, buildCustom } from './custom'; import { AccountOptions, buildAccount } from './account'; import { GovernorOptions, buildGovernor } from './governor'; +import { VestingOptions, buildVesting } from './vesting'; + export interface KindedOptions { ERC20: { kind: 'ERC20' } & ERC20Options; ERC721: { kind: 'ERC721' } & ERC721Options; ERC1155: { kind: 'ERC1155' } & ERC1155Options; Account: { kind: 'Account' } & AccountOptions; Governor: { kind: 'Governor' } & GovernorOptions; + Vesting: { kind: 'Vesting' } & VestingOptions; Custom: { kind: 'Custom' } & CustomOptions; } @@ -32,6 +35,9 @@ export function buildGeneric(opts: GenericOptions) { case 'Governor': return buildGovernor(opts); + case 'Vesting': + return buildVesting(opts); + case 'Custom': return buildCustom(opts); diff --git a/packages/core-cairo/src/generate/sources.ts b/packages/core-cairo/src/generate/sources.ts index 3ff9365ce..5be28487c 100644 --- a/packages/core-cairo/src/generate/sources.ts +++ b/packages/core-cairo/src/generate/sources.ts @@ -8,6 +8,7 @@ import { generateERC1155Options } from './erc1155'; import { generateAccountOptions } from './account'; import { generateCustomOptions } from './custom'; import { generateGovernorOptions } from './governor'; +import { generateVestingOptions } from './vesting'; import { buildGeneric, GenericOptions, KindedOptions } from '../build-generic'; import { printContract } from '../print'; import { OptionsError } from '../error'; @@ -54,6 +55,12 @@ export function* generateOptions(kind?: Kind): Generator { yield { kind: 'Governor', ...kindOpts }; } } + + if (!kind || kind === 'Vesting') { + for (const kindOpts of generateVestingOptions()) { + yield { kind: 'Vesting', ...kindOpts }; + } + } } interface GeneratedContract { @@ -92,9 +99,27 @@ function generateContractSubset(subset: Subset, kind?: Kind): GeneratedContract[ return contracts; } else { const getParents = (c: GeneratedContract) => c.contract.components.map(p => p.path); + function filterByUpgradeableSetTo(isUpgradeable: boolean) { + return (c: GeneratedContract) => { + switch (c.options.kind) { + case 'Vesting': + return isUpgradeable === false; + case 'Account': + case 'ERC20': + case 'ERC721': + case 'ERC1155': + case 'Governor': + case 'Custom': + return c.options.upgradeable === isUpgradeable; + default: + const _: never = c.options; + throw new Error('Unknown kind'); + } + } + } return [ - ...findCover(contracts.filter(c => c.options.upgradeable), getParents), - ...findCover(contracts.filter(c => !c.options.upgradeable), getParents), + ...findCover(contracts.filter(filterByUpgradeableSetTo(true)), getParents), + ...findCover(contracts.filter(filterByUpgradeableSetTo(false)), getParents), ]; } } diff --git a/packages/core-cairo/src/generate/vesting.ts b/packages/core-cairo/src/generate/vesting.ts new file mode 100644 index 000000000..45de06143 --- /dev/null +++ b/packages/core-cairo/src/generate/vesting.ts @@ -0,0 +1,16 @@ +import { infoOptions } from '../set-info'; +import type { VestingOptions } from '../vesting'; +import { generateAlternatives } from './alternatives'; + +const blueprint = { + name: ['MyVesting'], + startDate: ['2024-12-31T23:59'], + duration: ['90 days', '1 year'], + cliffDuration: ['0 seconds', '30 day'], + schedule: ['linear', 'custom'] as const, + info: infoOptions +}; + +export function* generateVestingOptions(): Generator> { + yield* generateAlternatives(blueprint); +} diff --git a/packages/core-cairo/src/governor.test.ts b/packages/core-cairo/src/governor.test.ts index 5b0e0422d..e3b3f0c22 100644 --- a/packages/core-cairo/src/governor.test.ts +++ b/packages/core-cairo/src/governor.test.ts @@ -125,9 +125,9 @@ testAPIEquivalence('API erc721 votes + timelock', { }); testAPIEquivalence('API custom name', { + name: 'CustomGovernor', delay: '1 day', period: '1 week', - name: 'CustomGovernor', }); testAPIEquivalence('API custom settings', { @@ -146,7 +146,8 @@ testAPIEquivalence('API quorum mode absolute', { quorumAbsolute: '200', }); -testAPIEquivalence('API quorum mode percent', { name: NAME, +testAPIEquivalence('API quorum mode percent', { + name: NAME, delay: '1 day', period: '1 week', quorumMode: 'percent', diff --git a/packages/core-cairo/src/index.ts b/packages/core-cairo/src/index.ts index c799d08f5..5f102fabb 100644 --- a/packages/core-cairo/src/index.ts +++ b/packages/core-cairo/src/index.ts @@ -25,4 +25,4 @@ export { sanitizeKind } from './kind'; export { contractsVersion, contractsVersionTag, compatibleContractsSemver } from './utils/version'; -export { erc20, erc721, erc1155, account, governor, custom } from './api'; +export { erc20, erc721, erc1155, account, governor, vesting, custom } from './api'; diff --git a/packages/core-cairo/src/kind.ts b/packages/core-cairo/src/kind.ts index 0f6ee0683..d61180216 100644 --- a/packages/core-cairo/src/kind.ts +++ b/packages/core-cairo/src/kind.ts @@ -19,6 +19,7 @@ function isKind(value: Kind | T): value is Kind { case 'ERC1155': case 'Account': case 'Governor': + case 'Vesting': case 'Custom': return true; diff --git a/packages/core-cairo/src/set-upgradeable.ts b/packages/core-cairo/src/set-upgradeable.ts index 30793da20..65a944128 100644 --- a/packages/core-cairo/src/set-upgradeable.ts +++ b/packages/core-cairo/src/set-upgradeable.ts @@ -11,7 +11,7 @@ export type Upgradeable = typeof upgradeableOptions[number]; function setUpgradeableBase(c: ContractBuilder, upgradeable: Upgradeable): BaseImplementedTrait | undefined { if (upgradeable === false) { - return; + return undefined; } c.upgradeable = true; diff --git a/packages/core-cairo/src/test.ts b/packages/core-cairo/src/test.ts index a6d7fce7c..967e17ece 100644 --- a/packages/core-cairo/src/test.ts +++ b/packages/core-cairo/src/test.ts @@ -7,7 +7,6 @@ import { generateSources, writeGeneratedSources } from './generate/sources'; import type { GenericOptions, KindedOptions } from './build-generic'; import { custom, erc20, erc721, erc1155 } from './api'; - interface Context { generatedSourcesPath: string } @@ -63,12 +62,27 @@ test('is access control required', async t => { for (const contract of generateSources('all')) { const regexOwnable = /(use openzeppelin::access::ownable::OwnableComponent)/gm; - if (contract.options.kind !== 'Account' && contract.options.kind !== 'Governor' && !contract.options.access) { - if (isAccessControlRequired(contract.options)) { - t.regex(contract.source, regexOwnable, JSON.stringify(contract.options)); - } else { - t.notRegex(contract.source, regexOwnable, JSON.stringify(contract.options)); - } + switch (contract.options.kind) { + case 'Account': + case 'Governor': + case 'Vesting': + // These contracts have no access control option + break; + case 'ERC20': + case 'ERC721': + case 'ERC1155': + case 'Custom': + if (!contract.options.access) { + if (isAccessControlRequired(contract.options)) { + t.regex(contract.source, regexOwnable, JSON.stringify(contract.options)); + } else { + t.notRegex(contract.source, regexOwnable, JSON.stringify(contract.options)); + } + } + break; + default: + const _: never = contract.options; + throw new Error('Unknown kind'); } } -}); \ No newline at end of file +}); diff --git a/packages/core-cairo/src/utils/convert-strings.ts b/packages/core-cairo/src/utils/convert-strings.ts index 75cc1758a..d1681ec09 100644 --- a/packages/core-cairo/src/utils/convert-strings.ts +++ b/packages/core-cairo/src/utils/convert-strings.ts @@ -70,16 +70,17 @@ const UINT_MAX_VALUES = { export type UintType = keyof typeof UINT_MAX_VALUES; /** - * Validates a string value to be a valid uint and converts it to bigint + * Checks that a string/number value is a valid `uint` value and converts it to bigint */ -export function toUint(str: string, field: string, type: UintType): bigint { - const isValidNumber = /^\d+$/.test(str); +export function toUint(value: number | string, field: string, type: UintType): bigint { + const valueAsStr = value.toString(); + const isValidNumber = /^\d+$/.test(valueAsStr); if (!isValidNumber) { throw new OptionsError({ [field]: 'Not a valid number' }); } - const numValue = BigInt(str); + const numValue = BigInt(valueAsStr); if (numValue > UINT_MAX_VALUES[type]) { throw new OptionsError({ [field]: `Value is greater than ${type} max value` @@ -87,3 +88,10 @@ export function toUint(str: string, field: string, type: UintType): bigint { } return numValue; } + +/** + * Checks that a string/number value is a valid `uint` value + */ +export function validateUint(value: number | string, field: string, type: UintType): void { + const _ = toUint(value, field, type); +} diff --git a/packages/core-cairo/src/utils/duration.ts b/packages/core-cairo/src/utils/duration.ts index de24b28c2..ffc2fe5b5 100644 --- a/packages/core-cairo/src/utils/duration.ts +++ b/packages/core-cairo/src/utils/duration.ts @@ -14,7 +14,7 @@ const secondsForUnit = { second, minute, hour, day, week, month, year }; export function durationToTimestamp(duration: string): number { const match = duration.trim().match(durationPattern); - if (!match) { + if (!match || match.length < 2) { throw new Error('Bad duration format'); } diff --git a/packages/core-cairo/src/vesting.test.ts b/packages/core-cairo/src/vesting.test.ts new file mode 100644 index 000000000..6a931f275 --- /dev/null +++ b/packages/core-cairo/src/vesting.test.ts @@ -0,0 +1,123 @@ +import test from 'ava'; +import { OptionsError, vesting } from '.'; +import { buildVesting, VestingOptions } from './vesting'; +import { printContract } from './print'; + +const defaults: VestingOptions = { + name: 'MyVesting', + startDate: '', + duration: '0 day', + cliffDuration: '0 day', + schedule: 'linear' +}; + +const CUSTOM_NAME = 'CustomVesting'; +const CUSTOM_DATE = '2024-12-31T23:59'; +const CUSTOM_DURATION = '36 months'; +const CUSTOM_CLIFF = '90 days'; + +// +// Test helpers +// + +function testVesting(title: string, opts: Partial) { + test(title, t => { + const c = buildVesting({ + ...defaults, + ...opts + }); + t.snapshot(printContract(c)); + }); +} + +function testAPIEquivalence(title: string, opts?: VestingOptions) { + test(title, t => { + t.is(vesting.print(opts), printContract(buildVesting({ + ...defaults, + ...opts + }))); + }); +} + +// +// Snapshot tests +// + +testVesting('custom name', { + name: CUSTOM_NAME, +}); + +testVesting('custom start date', { + startDate: CUSTOM_DATE +}); + +testVesting('custom duration', { + duration: CUSTOM_DURATION +}); + +testVesting('custom cliff', { + duration: CUSTOM_DURATION, + cliffDuration: CUSTOM_CLIFF +}); + +testVesting('custom schedule', { + schedule: 'custom' +}); + +testVesting('all custom settings', { + startDate: CUSTOM_DATE, + duration: CUSTOM_DURATION, + cliffDuration: CUSTOM_CLIFF, + schedule: 'custom' +}); + +// +// API tests +// + +testAPIEquivalence('API custom name', { + ...defaults, + name: CUSTOM_NAME +}); + +testAPIEquivalence('API custom start date', { + ...defaults, + startDate: CUSTOM_DATE +}); + +testAPIEquivalence('API custom duration', { + ...defaults, + duration: CUSTOM_DURATION +}); + +testAPIEquivalence('API custom cliff', { + ...defaults, + duration: CUSTOM_DURATION, + cliffDuration: CUSTOM_CLIFF +}); + +testAPIEquivalence('API custom schedule', { + ...defaults, + schedule: 'custom' +}); + +testAPIEquivalence('API all custom settings', { + ...defaults, + startDate: CUSTOM_DATE, + duration: CUSTOM_DURATION, + cliffDuration: CUSTOM_CLIFF, + schedule: 'custom' +}); + +test('Vesting API isAccessControlRequired', async t => { + t.is(vesting.isAccessControlRequired({}), true); +}); + +test('cliff too high', async t => { + const error = t.throws(() => buildVesting({ + ...defaults, + duration: '20 days', + cliffDuration: '21 days' + })); + t.is((error as OptionsError).messages.cliffDuration, 'Cliff duration must be less than or equal to the total duration'); +}); diff --git a/packages/core-cairo/src/vesting.test.ts.md b/packages/core-cairo/src/vesting.test.ts.md new file mode 100644 index 000000000..6c35d8c65 --- /dev/null +++ b/packages/core-cairo/src/vesting.test.ts.md @@ -0,0 +1,365 @@ +# Snapshot report for `src/vesting.test.ts` + +The actual snapshot is saved in `vesting.test.ts.snap`. + +Generated by [AVA](https://avajs.dev). + +## custom name + +> Snapshot 1 + + `// SPDX-License-Identifier: MIT␊ + // Compatible with OpenZeppelin Contracts for Cairo ^0.20.0␊ + ␊ + #[starknet::contract]␊ + mod CustomVesting {␊ + use openzeppelin::access::ownable::OwnableComponent;␊ + use openzeppelin::finance::vesting::{LinearVestingSchedule, VestingComponent};␊ + use starknet::ContractAddress;␊ + ␊ + const START: u64 = 0;␊ + const DURATION: u64 = 0; // 0 day␊ + const CLIFF_DURATION: u64 = 0; // 0 day␊ + ␊ + component!(path: VestingComponent, storage: vesting, event: VestingEvent);␊ + component!(path: OwnableComponent, storage: ownable, event: OwnableEvent);␊ + ␊ + // External␊ + #[abi(embed_v0)]␊ + impl VestingImpl = VestingComponent::VestingImpl;␊ + #[abi(embed_v0)]␊ + impl OwnableMixinImpl = OwnableComponent::OwnableMixinImpl;␊ + ␊ + // Internal␊ + impl VestingInternalImpl = VestingComponent::InternalImpl;␊ + impl OwnableInternalImpl = OwnableComponent::InternalImpl;␊ + ␊ + #[storage]␊ + struct Storage {␊ + #[substorage(v0)]␊ + vesting: VestingComponent::Storage,␊ + #[substorage(v0)]␊ + ownable: OwnableComponent::Storage,␊ + }␊ + ␊ + #[event]␊ + #[derive(Drop, starknet::Event)]␊ + enum Event {␊ + #[flat]␊ + VestingEvent: VestingComponent::Event,␊ + #[flat]␊ + OwnableEvent: OwnableComponent::Event,␊ + }␊ + ␊ + #[constructor]␊ + fn constructor(ref self: ContractState, owner: ContractAddress) {␊ + self.vesting.initializer(START, DURATION, CLIFF_DURATION);␊ + self.ownable.initializer(owner);␊ + }␊ + }␊ + ` + +## custom start date + +> Snapshot 1 + + `// SPDX-License-Identifier: MIT␊ + // Compatible with OpenZeppelin Contracts for Cairo ^0.20.0␊ + ␊ + #[starknet::contract]␊ + mod MyVesting {␊ + use openzeppelin::access::ownable::OwnableComponent;␊ + use openzeppelin::finance::vesting::{LinearVestingSchedule, VestingComponent};␊ + use starknet::ContractAddress;␊ + ␊ + const START: u64 = 1735689540; // 31 Dec 2024, 23:59 (UTC)␊ + const DURATION: u64 = 0; // 0 day␊ + const CLIFF_DURATION: u64 = 0; // 0 day␊ + ␊ + component!(path: VestingComponent, storage: vesting, event: VestingEvent);␊ + component!(path: OwnableComponent, storage: ownable, event: OwnableEvent);␊ + ␊ + // External␊ + #[abi(embed_v0)]␊ + impl VestingImpl = VestingComponent::VestingImpl;␊ + #[abi(embed_v0)]␊ + impl OwnableMixinImpl = OwnableComponent::OwnableMixinImpl;␊ + ␊ + // Internal␊ + impl VestingInternalImpl = VestingComponent::InternalImpl;␊ + impl OwnableInternalImpl = OwnableComponent::InternalImpl;␊ + ␊ + #[storage]␊ + struct Storage {␊ + #[substorage(v0)]␊ + vesting: VestingComponent::Storage,␊ + #[substorage(v0)]␊ + ownable: OwnableComponent::Storage,␊ + }␊ + ␊ + #[event]␊ + #[derive(Drop, starknet::Event)]␊ + enum Event {␊ + #[flat]␊ + VestingEvent: VestingComponent::Event,␊ + #[flat]␊ + OwnableEvent: OwnableComponent::Event,␊ + }␊ + ␊ + #[constructor]␊ + fn constructor(ref self: ContractState, owner: ContractAddress) {␊ + self.vesting.initializer(START, DURATION, CLIFF_DURATION);␊ + self.ownable.initializer(owner);␊ + }␊ + }␊ + ` + +## custom duration + +> Snapshot 1 + + `// SPDX-License-Identifier: MIT␊ + // Compatible with OpenZeppelin Contracts for Cairo ^0.20.0␊ + ␊ + #[starknet::contract]␊ + mod MyVesting {␊ + use openzeppelin::access::ownable::OwnableComponent;␊ + use openzeppelin::finance::vesting::{LinearVestingSchedule, VestingComponent};␊ + use starknet::ContractAddress;␊ + ␊ + const START: u64 = 0;␊ + const DURATION: u64 = 93312000; // 36 months␊ + const CLIFF_DURATION: u64 = 0; // 0 day␊ + ␊ + component!(path: VestingComponent, storage: vesting, event: VestingEvent);␊ + component!(path: OwnableComponent, storage: ownable, event: OwnableEvent);␊ + ␊ + // External␊ + #[abi(embed_v0)]␊ + impl VestingImpl = VestingComponent::VestingImpl;␊ + #[abi(embed_v0)]␊ + impl OwnableMixinImpl = OwnableComponent::OwnableMixinImpl;␊ + ␊ + // Internal␊ + impl VestingInternalImpl = VestingComponent::InternalImpl;␊ + impl OwnableInternalImpl = OwnableComponent::InternalImpl;␊ + ␊ + #[storage]␊ + struct Storage {␊ + #[substorage(v0)]␊ + vesting: VestingComponent::Storage,␊ + #[substorage(v0)]␊ + ownable: OwnableComponent::Storage,␊ + }␊ + ␊ + #[event]␊ + #[derive(Drop, starknet::Event)]␊ + enum Event {␊ + #[flat]␊ + VestingEvent: VestingComponent::Event,␊ + #[flat]␊ + OwnableEvent: OwnableComponent::Event,␊ + }␊ + ␊ + #[constructor]␊ + fn constructor(ref self: ContractState, owner: ContractAddress) {␊ + self.vesting.initializer(START, DURATION, CLIFF_DURATION);␊ + self.ownable.initializer(owner);␊ + }␊ + }␊ + ` + +## custom cliff + +> Snapshot 1 + + `// SPDX-License-Identifier: MIT␊ + // Compatible with OpenZeppelin Contracts for Cairo ^0.20.0␊ + ␊ + #[starknet::contract]␊ + mod MyVesting {␊ + use openzeppelin::access::ownable::OwnableComponent;␊ + use openzeppelin::finance::vesting::{LinearVestingSchedule, VestingComponent};␊ + use starknet::ContractAddress;␊ + ␊ + const START: u64 = 0;␊ + const DURATION: u64 = 93312000; // 36 months␊ + const CLIFF_DURATION: u64 = 7776000; // 90 days␊ + ␊ + component!(path: VestingComponent, storage: vesting, event: VestingEvent);␊ + component!(path: OwnableComponent, storage: ownable, event: OwnableEvent);␊ + ␊ + // External␊ + #[abi(embed_v0)]␊ + impl VestingImpl = VestingComponent::VestingImpl;␊ + #[abi(embed_v0)]␊ + impl OwnableMixinImpl = OwnableComponent::OwnableMixinImpl;␊ + ␊ + // Internal␊ + impl VestingInternalImpl = VestingComponent::InternalImpl;␊ + impl OwnableInternalImpl = OwnableComponent::InternalImpl;␊ + ␊ + #[storage]␊ + struct Storage {␊ + #[substorage(v0)]␊ + vesting: VestingComponent::Storage,␊ + #[substorage(v0)]␊ + ownable: OwnableComponent::Storage,␊ + }␊ + ␊ + #[event]␊ + #[derive(Drop, starknet::Event)]␊ + enum Event {␊ + #[flat]␊ + VestingEvent: VestingComponent::Event,␊ + #[flat]␊ + OwnableEvent: OwnableComponent::Event,␊ + }␊ + ␊ + #[constructor]␊ + fn constructor(ref self: ContractState, owner: ContractAddress) {␊ + self.vesting.initializer(START, DURATION, CLIFF_DURATION);␊ + self.ownable.initializer(owner);␊ + }␊ + }␊ + ` + +## custom schedule + +> Snapshot 1 + + `// SPDX-License-Identifier: MIT␊ + // Compatible with OpenZeppelin Contracts for Cairo ^0.20.0␊ + ␊ + #[starknet::contract]␊ + mod MyVesting {␊ + use openzeppelin::access::ownable::OwnableComponent;␊ + use openzeppelin::finance::vesting::VestingComponent;␊ + use starknet::ContractAddress;␊ + ␊ + const START: u64 = 0;␊ + const DURATION: u64 = 0; // 0 day␊ + const CLIFF_DURATION: u64 = 0; // 0 day␊ + ␊ + component!(path: VestingComponent, storage: vesting, event: VestingEvent);␊ + component!(path: OwnableComponent, storage: ownable, event: OwnableEvent);␊ + ␊ + // External␊ + #[abi(embed_v0)]␊ + impl VestingImpl = VestingComponent::VestingImpl;␊ + #[abi(embed_v0)]␊ + impl OwnableMixinImpl = OwnableComponent::OwnableMixinImpl;␊ + ␊ + // Internal␊ + impl VestingInternalImpl = VestingComponent::InternalImpl;␊ + impl OwnableInternalImpl = OwnableComponent::InternalImpl;␊ + ␊ + #[storage]␊ + struct Storage {␊ + #[substorage(v0)]␊ + vesting: VestingComponent::Storage,␊ + #[substorage(v0)]␊ + ownable: OwnableComponent::Storage,␊ + }␊ + ␊ + #[event]␊ + #[derive(Drop, starknet::Event)]␊ + enum Event {␊ + #[flat]␊ + VestingEvent: VestingComponent::Event,␊ + #[flat]␊ + OwnableEvent: OwnableComponent::Event,␊ + }␊ + ␊ + #[constructor]␊ + fn constructor(ref self: ContractState, owner: ContractAddress) {␊ + self.vesting.initializer(START, DURATION, CLIFF_DURATION);␊ + self.ownable.initializer(owner);␊ + }␊ + ␊ + impl VestingSchedule of VestingComponent::VestingScheduleTrait {␊ + fn calculate_vested_amount(␊ + self: @VestingComponent::ComponentState,␊ + token: ContractAddress,␊ + total_allocation: u256,␊ + timestamp: u64,␊ + start: u64,␊ + duration: u64,␊ + cliff: u64,␊ + ) -> u256 {␊ + // TODO: Must be implemented according to the desired vesting schedule;␊ + 0␊ + }␊ + }␊ + }␊ + ` + +## all custom settings + +> Snapshot 1 + + `// SPDX-License-Identifier: MIT␊ + // Compatible with OpenZeppelin Contracts for Cairo ^0.20.0␊ + ␊ + #[starknet::contract]␊ + mod MyVesting {␊ + use openzeppelin::access::ownable::OwnableComponent;␊ + use openzeppelin::finance::vesting::VestingComponent;␊ + use starknet::ContractAddress;␊ + ␊ + const START: u64 = 1735689540; // 31 Dec 2024, 23:59 (UTC)␊ + const DURATION: u64 = 93312000; // 36 months␊ + const CLIFF_DURATION: u64 = 7776000; // 90 days␊ + ␊ + component!(path: VestingComponent, storage: vesting, event: VestingEvent);␊ + component!(path: OwnableComponent, storage: ownable, event: OwnableEvent);␊ + ␊ + // External␊ + #[abi(embed_v0)]␊ + impl VestingImpl = VestingComponent::VestingImpl;␊ + #[abi(embed_v0)]␊ + impl OwnableMixinImpl = OwnableComponent::OwnableMixinImpl;␊ + ␊ + // Internal␊ + impl VestingInternalImpl = VestingComponent::InternalImpl;␊ + impl OwnableInternalImpl = OwnableComponent::InternalImpl;␊ + ␊ + #[storage]␊ + struct Storage {␊ + #[substorage(v0)]␊ + vesting: VestingComponent::Storage,␊ + #[substorage(v0)]␊ + ownable: OwnableComponent::Storage,␊ + }␊ + ␊ + #[event]␊ + #[derive(Drop, starknet::Event)]␊ + enum Event {␊ + #[flat]␊ + VestingEvent: VestingComponent::Event,␊ + #[flat]␊ + OwnableEvent: OwnableComponent::Event,␊ + }␊ + ␊ + #[constructor]␊ + fn constructor(ref self: ContractState, owner: ContractAddress) {␊ + self.vesting.initializer(START, DURATION, CLIFF_DURATION);␊ + self.ownable.initializer(owner);␊ + }␊ + ␊ + impl VestingSchedule of VestingComponent::VestingScheduleTrait {␊ + fn calculate_vested_amount(␊ + self: @VestingComponent::ComponentState,␊ + token: ContractAddress,␊ + total_allocation: u256,␊ + timestamp: u64,␊ + start: u64,␊ + duration: u64,␊ + cliff: u64,␊ + ) -> u256 {␊ + // TODO: Must be implemented according to the desired vesting schedule;␊ + 0␊ + }␊ + }␊ + }␊ + ` diff --git a/packages/core-cairo/src/vesting.test.ts.snap b/packages/core-cairo/src/vesting.test.ts.snap new file mode 100644 index 000000000..7051100b2 Binary files /dev/null and b/packages/core-cairo/src/vesting.test.ts.snap differ diff --git a/packages/core-cairo/src/vesting.ts b/packages/core-cairo/src/vesting.ts new file mode 100644 index 000000000..543ff0534 --- /dev/null +++ b/packages/core-cairo/src/vesting.ts @@ -0,0 +1,223 @@ +import { BaseImplementedTrait, Contract, ContractBuilder } from './contract'; +import { contractDefaults as commonDefaults } from './common-options'; +import { setAccessControl } from './set-access-control'; +import { setUpgradeable } from './set-upgradeable'; +import { Info, setInfo } from './set-info'; +import { defineComponents } from './utils/define-components'; +import { printContract } from './print'; +import { OptionsError } from './error'; +import { durationToTimestamp } from './utils/duration'; +import { toUint, validateUint } from './utils/convert-strings'; + +export type VestingSchedule = 'linear' | 'custom'; + +export const defaults: Required = { + name: 'VestingWallet', + startDate: '', + duration: '0 day', + cliffDuration: '0 day', + schedule: 'custom', + info: commonDefaults.info +} as const; + +export function printVesting(opts: VestingOptions = defaults): string { + return printContract(buildVesting(opts)); +} + +export interface VestingOptions { + name: string; + startDate: string; + duration: string; + cliffDuration: string; + schedule: VestingSchedule; + info?: Info; +} + +function withDefaults(opts: VestingOptions): Required { + return { + name: opts.name ?? defaults.name, + startDate: opts.startDate ?? defaults.startDate, + duration: opts.duration ?? defaults.duration, + cliffDuration: opts.cliffDuration ?? defaults.cliffDuration, + schedule: opts.schedule ?? defaults.schedule, + info: opts.info ?? defaults.info + }; +} + +export function isAccessControlRequired(_: Partial): boolean { + return true; +} + +export function buildVesting(opts: VestingOptions): Contract { + const c = new ContractBuilder(opts.name); + const allOpts = withDefaults(opts); + + addBase(c, opts); + addSchedule(c, opts); + setInfo(c, allOpts.info); + + // Vesting component depends on Ownable component + const access = 'ownable'; + setAccessControl(c, access); + + // Must be non-upgradable to guarantee vesting according to the schedule + setUpgradeable(c, false, access); + + return c; +} + +function addBase(c: ContractBuilder, opts: VestingOptions) { + c.addUseClause('starknet', 'ContractAddress'); + const startDate = getVestingStart(opts); + const totalDuration = getVestingDuration(opts); + const cliffDuration = getCliffDuration(opts); + validateDurations(totalDuration, cliffDuration); + if (startDate !== undefined) { + c.addConstant({ + name: 'START', + type: 'u64', + value: startDate.timestampInSec.toString(), + comment: startDate.formattedDate, + inlineComment: true + }); + } else { + c.addConstant({ + name: 'START', + type: 'u64', + value: '0' + }); + } + c.addConstant({ + name: 'DURATION', + type: 'u64', + value: totalDuration.toString(), + comment: opts.duration, + inlineComment: true + }); + c.addConstant({ + name: 'CLIFF_DURATION', + type: 'u64', + value: cliffDuration.toString(), + comment: opts.cliffDuration, + inlineComment: true + }); + const initParams = [{ lit: 'START' }, { lit: 'DURATION' }, { lit: 'CLIFF_DURATION' }]; + c.addComponent(components.VestingComponent, initParams, true); +} + +function addSchedule(c: ContractBuilder, opts: VestingOptions) { + switch (opts.schedule) { + case 'linear': + c.addUseClause('openzeppelin::finance::vesting', 'LinearVestingSchedule'); + return; + case 'custom': + const scheduleTrait: BaseImplementedTrait = { + name: `VestingSchedule`, + of: 'VestingComponent::VestingScheduleTrait', + tags: [], + priority: 0, + }; + c.addImplementedTrait(scheduleTrait); + c.addFunction(scheduleTrait, { + name: 'calculate_vested_amount', + returns: 'u256', + args: [ + { name: 'self', type: `@VestingComponent::ComponentState` }, + { name: 'token', type: 'ContractAddress' }, + { name: 'total_allocation', type: 'u256' }, + { name: 'timestamp', type: 'u64' }, + { name: 'start', type: 'u64' }, + { name: 'duration', type: 'u64' }, + { name: 'cliff', type: 'u64' } + ], + code: [ + '// TODO: Must be implemented according to the desired vesting schedule', + '0', + ], + }); + return; + } +} + +function getVestingStart(opts: VestingOptions): { timestampInSec: bigint, formattedDate: string } | undefined { + if (opts.startDate === '' || opts.startDate === 'NaN') { + return undefined; + } + const startDate = new Date(`${opts.startDate}Z`); + const timestampInMillis = startDate.getTime(); + const timestampInSec = toUint( + Math.floor(timestampInMillis / 1000), + 'startDate', + 'u64' + ); + const formattedDate = startDate.toLocaleString('en-GB', { + day: '2-digit', + month: 'short', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + hour12: false, + timeZone: 'UTC' + }); + return { timestampInSec, formattedDate: `${formattedDate} (UTC)` }; +} + +function getVestingDuration(opts: VestingOptions): number { + try { + return durationToTimestamp(opts.duration); + } catch (e) { + if (e instanceof Error) { + throw new OptionsError({ + duration: e.message + }); + } else { + throw e; + } + } +} + +function getCliffDuration(opts: VestingOptions): number { + try { + return durationToTimestamp(opts.cliffDuration); + } catch (e) { + if (e instanceof Error) { + throw new OptionsError({ + cliffDuration: e.message + }); + } else { + throw e; + } + } +} + +function validateDurations(duration: number, cliffDuration: number): void { + validateUint(duration, 'duration', 'u64'); + validateUint(cliffDuration, 'cliffDuration', 'u64'); + if (cliffDuration > duration) { + throw new OptionsError({ + cliffDuration: `Cliff duration must be less than or equal to the total duration` + }); + } +} + +const components = defineComponents({ + VestingComponent: { + path: 'openzeppelin::finance::vesting', + substorage: { + name: 'vesting', + type: 'VestingComponent::Storage', + }, + event: { + name: 'VestingEvent', + type: 'VestingComponent::Event', + }, + impls: [{ + name: 'VestingImpl', + value: 'VestingComponent::VestingImpl' + }, { + name: 'VestingInternalImpl', + embed: false, + value: 'VestingComponent::InternalImpl', + }], + } +}); diff --git a/packages/ui/src/cairo/App.svelte b/packages/ui/src/cairo/App.svelte index baa35ebe4..ade81e466 100644 --- a/packages/ui/src/cairo/App.svelte +++ b/packages/ui/src/cairo/App.svelte @@ -23,6 +23,7 @@ import { saveAs } from 'file-saver'; import { injectHyperlinks } from './inject-hyperlinks'; import { InitialOptions } from '../initial-options'; + import VestingControls from './VestingControls.svelte'; const dispatch = createEventDispatcher(); @@ -56,6 +57,7 @@ break; case 'Account': case 'Governor': + case 'Vesting': case 'ERC1155': case 'Custom': } @@ -121,6 +123,9 @@ + @@ -170,6 +175,9 @@
+
+ +
diff --git a/packages/ui/src/cairo/VestingControls.svelte b/packages/ui/src/cairo/VestingControls.svelte new file mode 100644 index 000000000..06fe80f81 --- /dev/null +++ b/packages/ui/src/cairo/VestingControls.svelte @@ -0,0 +1,68 @@ + + +
+

Settings

+ + + + + + + +
+ +
+

Vesting Schedule

+
+ + +
+
+ + diff --git a/packages/ui/src/styles/global.css b/packages/ui/src/styles/global.css index 4f1336cde..3ccefb126 100644 --- a/packages/ui/src/styles/global.css +++ b/packages/ui/src/styles/global.css @@ -21,7 +21,7 @@ svg.icon { fill: currentColor; } -input:not([type]), input[type="text"], input[type="number"] { +input:not([type]), input[type="text"], input[type="number"], input[type="datetime-local"] { border: 1px solid var(--gray-3); padding: var(--size-2) var(--size-3); border-radius: 6px;