Skip to content

Commit dd503eb

Browse files
authored
Cairo: Add ERC721Consecutive (#800)
1 parent 603a84c commit dd503eb

13 files changed

Lines changed: 292 additions & 7 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@openzeppelin/wizard-common': patch
3+
---
4+
5+
Cairo: Add ERC721Consecutive extension.

packages/common/src/ai/descriptions/cairo.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,8 @@ export const cairoERC721Descriptions = {
6565
uriStorage: 'Allows updating token URIs for individual token IDs.',
6666
votes:
6767
'Whether to keep track of individual units for voting in on-chain governance. Voting durations can be expressed as block numbers or timestamps.',
68+
consecutive:
69+
'Enables gas-efficient batch minting of consecutive token IDs during construction (ERC-2309). The contract owner must call `mint_consecutive` from the constructor to issue the initial batch. Cannot be combined with enumerable.',
6870
};
6971

7072
export const cairoERC1155Descriptions = {

packages/core/cairo_alpha/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
- Add ERC1155Supply extension ([#765](https://github.com/OpenZeppelin/contracts-wizard/pull/765))
1111
- Add ERC721URIStorage extension ([#772](https://github.com/OpenZeppelin/contracts-wizard/pull/772))
1212
- Add ERC721Wrapper extension ([#764](https://github.com/OpenZeppelin/contracts-wizard/pull/764))
13+
- Add ERC721Consecutive extension ([#800](https://github.com/OpenZeppelin/contracts-wizard/pull/800))
1314
- Add ERC20Wrapper extension ([#763](https://github.com/OpenZeppelin/contracts-wizard/pull/763))
1415

1516
- **Breaking changes**:

packages/core/cairo_alpha/src/erc721.ts

Lines changed: 51 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ export const defaults: Required<ERC721Options> = {
2929
wrapper: false,
3030
uriStorage: false,
3131
votes: false,
32+
consecutive: false,
3233
royaltyInfo: royaltyInfoDefaults,
3334
appName: '', // Defaults to empty string, but user must provide a non-empty value if votes are enabled
3435
appVersion: 'v1',
@@ -53,6 +54,7 @@ export interface ERC721Options extends CommonContractOptions {
5354
wrapper?: boolean;
5455
uriStorage?: boolean;
5556
votes?: boolean;
57+
consecutive?: boolean;
5658
royaltyInfo?: RoyaltyInfoOptions;
5759
appName?: string;
5860
appVersion?: string;
@@ -71,6 +73,7 @@ function withDefaults(opts: ERC721Options): Required<ERC721Options> {
7173
uriStorage: opts.uriStorage ?? defaults.uriStorage,
7274
royaltyInfo: opts.royaltyInfo ?? defaults.royaltyInfo,
7375
votes: opts.votes ?? defaults.votes,
76+
consecutive: opts.consecutive ?? defaults.consecutive,
7477
appName: opts.appName ?? defaults.appName,
7578
appVersion: opts.appVersion ?? defaults.appVersion,
7679
};
@@ -89,11 +92,20 @@ export function isAccessControlRequired(opts: Partial<ERC721Options>): boolean {
8992
export function buildERC721(opts: ERC721Options): Contract {
9093
const allOpts = withDefaults(opts);
9194

95+
if (allOpts.consecutive && allOpts.enumerable) {
96+
throw new OptionsError({
97+
consecutive:
98+
'Consecutive cannot be combined with Enumerable: batch mints bypass the update hook that Enumerable relies on to track ownership.',
99+
});
100+
}
101+
92102
const c = new ContractBuilder(allOpts.name, allOpts.macros);
93103

94104
addBase(c, toByteArray(allOpts.name), toByteArray(allOpts.symbol), toByteArray(allOpts.baseUri));
95105
addERC721Mixin(c);
96-
c.addUseClause('openzeppelin_token::erc721', 'ERC721OwnerOfDefaultImpl');
106+
if (!allOpts.consecutive) {
107+
c.addUseClause('openzeppelin_token::erc721', 'ERC721OwnerOfDefaultImpl');
108+
}
97109

98110
if (allOpts.pausable) {
99111
addPausable(c, allOpts.access);
@@ -111,6 +123,10 @@ export function buildERC721(opts: ERC721Options): Contract {
111123
addEnumerable(c);
112124
}
113125

126+
if (allOpts.consecutive) {
127+
addConsecutive(c);
128+
}
129+
114130
if (allOpts.wrapper) {
115131
addWrapper(c);
116132
}
@@ -151,8 +167,8 @@ function addERC721Mixin(c: ContractBuilder) {
151167
/* Hooks */
152168

153169
function addHooks(c: ContractBuilder, opts: Required<ERC721Options>) {
154-
const hasBeforeUpdateHook = opts.pausable || opts.enumerable || opts.votes;
155-
const hasAfterUpdateHook = opts.uriStorage;
170+
const hasBeforeUpdateHook = opts.pausable || opts.enumerable || opts.votes || opts.consecutive;
171+
const hasAfterUpdateHook = opts.uriStorage || opts.consecutive;
156172
const hasCustomHooks = hasBeforeUpdateHook || hasAfterUpdateHook;
157173
if (hasCustomHooks) {
158174
const ERC721HooksTrait: BaseImplementedTrait = {
@@ -176,10 +192,10 @@ function addHooks(c: ContractBuilder, opts: Required<ERC721Options>) {
176192
}
177193

178194
function addBeforeUpdateHook(c: ContractBuilder, trait: BaseImplementedTrait, opts: Required<ERC721Options>) {
179-
if (!opts.pausable && !opts.enumerable && !opts.votes) {
195+
if (!opts.pausable && !opts.enumerable && !opts.votes && !opts.consecutive) {
180196
return;
181197
}
182-
const requiresMutState = opts.enumerable || opts.votes;
198+
const requiresMutState = opts.enumerable || opts.votes || opts.consecutive;
183199
const initStateLine = requiresMutState
184200
? 'let mut contract_state = self.get_contract_mut()'
185201
: 'let contract_state = self.get_contract()';
@@ -210,6 +226,9 @@ function addBeforeUpdateHook(c: ContractBuilder, trait: BaseImplementedTrait, op
210226
beforeUpdateCode.push('let previous_owner = self._owner_of(token_id);');
211227
beforeUpdateCode.push('contract_state.votes.transfer_voting_units(previous_owner, to, 1);');
212228
}
229+
if (opts.consecutive) {
230+
beforeUpdateCode.push('contract_state.erc721_consecutive.before_update(to, token_id, auth)');
231+
}
213232
c.addFunction(trait, {
214233
name: 'before_update',
215234
args: [
@@ -226,14 +245,17 @@ function addBeforeUpdateHook(c: ContractBuilder, trait: BaseImplementedTrait, op
226245
}
227246

228247
function addAfterUpdateHook(c: ContractBuilder, trait: BaseImplementedTrait, opts: Required<ERC721Options>) {
229-
if (!opts.uriStorage) {
248+
if (!opts.uriStorage && !opts.consecutive) {
230249
return;
231250
}
232251
const initStateLine = 'let mut contract_state = self.get_contract_mut()';
233252
const afterUpdateCode = [initStateLine];
234253
if (opts.uriStorage) {
235254
afterUpdateCode.push('contract_state.erc721_uri_storage.after_update(to, token_id, auth)');
236255
}
256+
if (opts.consecutive) {
257+
afterUpdateCode.push('contract_state.erc721_consecutive.after_update(to, token_id, auth)');
258+
}
237259
c.addFunction(trait, {
238260
name: 'after_update',
239261
args: [
@@ -255,6 +277,11 @@ function addEnumerable(c: ContractBuilder) {
255277
c.addComponent(components.ERC721EnumerableComponent, [], true);
256278
}
257279

280+
function addConsecutive(c: ContractBuilder) {
281+
c.addUseClause('openzeppelin_token::erc721::extensions::erc721_consecutive', 'DefaultConfig');
282+
c.addComponent(components.ERC721ConsecutiveComponent, [], false);
283+
}
284+
258285
function addBurnable(c: ContractBuilder) {
259286
c.addUseClause('core::num::traits', 'Zero');
260287
c.addUseClause('starknet', 'get_caller_address');
@@ -370,6 +397,24 @@ const components = defineComponents({
370397
},
371398
],
372399
},
400+
ERC721ConsecutiveComponent: {
401+
path: 'openzeppelin_token::erc721::extensions',
402+
substorage: {
403+
name: 'erc721_consecutive',
404+
type: 'ERC721ConsecutiveComponent::Storage',
405+
},
406+
event: {
407+
name: 'ERC721ConsecutiveEvent',
408+
type: 'ERC721ConsecutiveComponent::Event',
409+
},
410+
impls: [
411+
{
412+
name: 'ERC721ConsecutiveInternalImpl',
413+
embed: false,
414+
value: 'ERC721ConsecutiveComponent::InternalImpl<ContractState>',
415+
},
416+
],
417+
},
373418
});
374419

375420
const functions = defineFunctions({

packages/core/cairo_alpha/src/generate/erc721.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ function prepareBlueprint(opts: GeneratorOptions) {
2929
wrapper: booleans,
3030
uriStorage: booleans,
3131
votes: booleans,
32+
consecutive: booleans,
3233
appName: ['MyApp'],
3334
appVersion: ['v1'],
3435
pausable: booleans,
@@ -43,5 +44,10 @@ function prepareBlueprint(opts: GeneratorOptions) {
4344

4445
export function* generateERC721Options(opts: GeneratorOptions): Generator<Required<ERC721Options>> {
4546
const blueprint = prepareBlueprint(opts);
46-
yield* generateAlternatives(blueprint);
47+
for (const option of generateAlternatives(blueprint)) {
48+
// Consecutive batch mints bypass the ERC721Component update hook that Enumerable depends on,
49+
// so the combination is forbidden by buildERC721. Skip these to avoid invalid generated sources.
50+
if (option.consecutive && option.enumerable) continue;
51+
yield option;
52+
}
4753
}

packages/core/cairo_alpha/src/tests/with_components_off/erc721/erc721.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,10 @@ testERC721('enumerable', {
8585
enumerable: true,
8686
});
8787

88+
testERC721('consecutive', {
89+
consecutive: true,
90+
});
91+
8892
testERC721('wrapper', {
8993
wrapper: true,
9094
});
@@ -248,6 +252,21 @@ test('erc721 votes, empty version', async t => {
248252
t.is((error as OptionsError).messages.appVersion, 'Application Version is required when Votes are enabled');
249253
});
250254

255+
test('erc721 consecutive + enumerable is not allowed', async t => {
256+
const error = t.throws(() =>
257+
buildERC721({
258+
name: NAME,
259+
symbol: SYMBOL,
260+
consecutive: true,
261+
enumerable: true,
262+
}),
263+
);
264+
t.is(
265+
(error as OptionsError).messages.consecutive,
266+
'Consecutive cannot be combined with Enumerable: batch mints bypass the update hook that Enumerable relies on to track ownership.',
267+
);
268+
});
269+
251270
testERC721('erc721 votes, non-upgradeable', {
252271
votes: true,
253272
appName: APP_NAME,
@@ -295,4 +314,5 @@ test('API isAccessControlRequired', async t => {
295314
);
296315
t.is(erc721.isAccessControlRequired({ burnable: true }), false);
297316
t.is(erc721.isAccessControlRequired({ enumerable: true }), false);
317+
t.is(erc721.isAccessControlRequired({ consecutive: true }), false);
298318
});

packages/core/cairo_alpha/src/tests/with_components_off/erc721/erc721.test.ts.md

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -620,6 +620,113 @@ Generated by [AVA](https://avajs.dev).
620620
}␊
621621
`
622622

623+
## consecutive
624+
625+
> Snapshot 1
626+
627+
`// SPDX-License-Identifier: MIT␊
628+
// Compatible with OpenZeppelin Contracts for Cairo 4.0.0-alpha.1␊
629+
630+
#[starknet::contract]␊
631+
mod MyToken {␊
632+
use openzeppelin_access::ownable::OwnableComponent;␊
633+
use openzeppelin_interfaces::upgrades::IUpgradeable;␊
634+
use openzeppelin_introspection::src5::SRC5Component;␊
635+
use openzeppelin_token::erc721::{ERC721Component, ERC721TokenURIDefaultImpl};␊
636+
use openzeppelin_token::erc721::extensions::erc721_consecutive::DefaultConfig;␊
637+
use openzeppelin_token::erc721::extensions::ERC721ConsecutiveComponent;␊
638+
use openzeppelin_upgrades::UpgradeableComponent;␊
639+
use starknet::{ClassHash, ContractAddress};␊
640+
641+
component!(path: ERC721Component, storage: erc721, event: ERC721Event);␊
642+
component!(path: SRC5Component, storage: src5, event: SRC5Event);␊
643+
component!(path: ERC721ConsecutiveComponent, storage: erc721_consecutive, event: ERC721ConsecutiveEvent);␊
644+
component!(path: UpgradeableComponent, storage: upgradeable, event: UpgradeableEvent);␊
645+
component!(path: OwnableComponent, storage: ownable, event: OwnableEvent);␊
646+
647+
// External␊
648+
#[abi(embed_v0)]␊
649+
impl ERC721MixinImpl = ERC721Component::ERC721MixinImpl<ContractState>;␊
650+
#[abi(embed_v0)]␊
651+
impl OwnableMixinImpl = OwnableComponent::OwnableMixinImpl<ContractState>;␊
652+
653+
// Internal␊
654+
impl ERC721InternalImpl = ERC721Component::InternalImpl<ContractState>;␊
655+
impl ERC721ConsecutiveInternalImpl = ERC721ConsecutiveComponent::InternalImpl<ContractState>;␊
656+
impl UpgradeableInternalImpl = UpgradeableComponent::InternalImpl<ContractState>;␊
657+
impl OwnableInternalImpl = OwnableComponent::InternalImpl<ContractState>;␊
658+
659+
#[storage]␊
660+
struct Storage {␊
661+
#[substorage(v0)]␊
662+
erc721: ERC721Component::Storage,␊
663+
#[substorage(v0)]␊
664+
src5: SRC5Component::Storage,␊
665+
#[substorage(v0)]␊
666+
erc721_consecutive: ERC721ConsecutiveComponent::Storage,␊
667+
#[substorage(v0)]␊
668+
upgradeable: UpgradeableComponent::Storage,␊
669+
#[substorage(v0)]␊
670+
ownable: OwnableComponent::Storage,␊
671+
}␊
672+
673+
#[event]␊
674+
#[derive(Drop, starknet::Event)]␊
675+
enum Event {␊
676+
#[flat]␊
677+
ERC721Event: ERC721Component::Event,␊
678+
#[flat]␊
679+
SRC5Event: SRC5Component::Event,␊
680+
#[flat]␊
681+
ERC721ConsecutiveEvent: ERC721ConsecutiveComponent::Event,␊
682+
#[flat]␊
683+
UpgradeableEvent: UpgradeableComponent::Event,␊
684+
#[flat]␊
685+
OwnableEvent: OwnableComponent::Event,␊
686+
}␊
687+
688+
#[constructor]␊
689+
fn constructor(ref self: ContractState, owner: ContractAddress) {␊
690+
self.erc721.initializer("MyToken", "MTK", "");␊
691+
self.ownable.initializer(owner);␊
692+
}␊
693+
694+
impl ERC721HooksImpl of ERC721Component::ERC721HooksTrait<ContractState> {␊
695+
fn before_update(␊
696+
ref self: ERC721Component::ComponentState<ContractState>,␊
697+
to: ContractAddress,␊
698+
token_id: u256,␊
699+
auth: ContractAddress,␊
700+
) {␊
701+
let mut contract_state = self.get_contract_mut();␊
702+
contract_state.erc721_consecutive.before_update(to, token_id, auth);␊
703+
}␊
704+
705+
fn after_update(␊
706+
ref self: ERC721Component::ComponentState<ContractState>,␊
707+
to: ContractAddress,␊
708+
token_id: u256,␊
709+
auth: ContractAddress,␊
710+
) {␊
711+
let mut contract_state = self.get_contract_mut();␊
712+
contract_state.erc721_consecutive.after_update(to, token_id, auth);␊
713+
}␊
714+
}␊
715+
716+
//␊
717+
// Upgradeable␊
718+
//␊
719+
720+
#[abi(embed_v0)]␊
721+
impl UpgradeableImpl of IUpgradeable<ContractState> {␊
722+
fn upgrade(ref self: ContractState, new_class_hash: ClassHash) {␊
723+
self.ownable.assert_only_owner();␊
724+
self.upgradeable.upgrade(new_class_hash);␊
725+
}␊
726+
}␊
727+
}␊
728+
`
729+
623730
## wrapper
624731

625732
> Snapshot 1
Binary file not shown.

packages/core/cairo_alpha/src/tests/with_components_on/erc721/erc721.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,10 @@ testERC721('enumerable', {
8080
enumerable: true,
8181
});
8282

83+
testERC721('consecutive', {
84+
consecutive: true,
85+
});
86+
8387
testERC721('wrapper', {
8488
wrapper: true,
8589
});
@@ -244,6 +248,21 @@ test('erc721 votes, empty version', async t => {
244248
t.is((error as OptionsError).messages.appVersion, 'Application Version is required when Votes are enabled');
245249
});
246250

251+
test('erc721 consecutive + enumerable is not allowed', async t => {
252+
const error = t.throws(() =>
253+
buildERC721({
254+
name: NAME,
255+
symbol: SYMBOL,
256+
consecutive: true,
257+
enumerable: true,
258+
}),
259+
);
260+
t.is(
261+
(error as OptionsError).messages.consecutive,
262+
'Consecutive cannot be combined with Enumerable: batch mints bypass the update hook that Enumerable relies on to track ownership.',
263+
);
264+
});
265+
247266
testERC721('erc721 votes, non-upgradeable', {
248267
votes: true,
249268
appName: APP_NAME,
@@ -290,4 +309,5 @@ test('API isAccessControlRequired', async t => {
290309
);
291310
t.is(erc721.isAccessControlRequired({ burnable: true }), false);
292311
t.is(erc721.isAccessControlRequired({ enumerable: true }), false);
312+
t.is(erc721.isAccessControlRequired({ consecutive: true }), false);
293313
});

0 commit comments

Comments
 (0)