From 8fcd1649c6744bd1aa76581d76a659c49d4849f1 Mon Sep 17 00:00:00 2001 From: Eric Nordelo Date: Tue, 18 Aug 2026 15:07:56 +0200 Subject: [PATCH 1/2] feat: add multisig account and preset --- CHANGELOG.md | 2 + docs/modules/ROOT/pages/api/account.adoc | 522 ++++++++++++ docs/modules/ROOT/pages/presets.adoc | 16 +- .../ROOT/pages/utils/_class_hashes.adoc | 19 +- packages/account/README.md | 40 +- packages/account/Scarb.toml | 2 + packages/account/src/lib.cairo | 2 + packages/account/src/multisig_account.cairo | 4 + .../multisig_account/multisig_account.cairo | 600 ++++++++++++++ .../src/multisig_account/storage_utils.cairo | 32 + packages/account/src/tests.cairo | 1 + .../src/tests/test_multisig_account.cairo | 774 ++++++++++++++++++ packages/interfaces/CHANGELOG.md | 9 +- packages/interfaces/README.md | 7 +- .../interfaces/src/account/accounts.cairo | 84 ++ .../attribute/with_components/components.rs | 11 + ...ith_components__with_multisig_account.snap | 41 + ..._with_multisig_account_no_initializer.snap | 44 + .../macros/src/tests/test_with_components.rs | 33 + packages/presets/README.md | 15 +- packages/presets/Scarb.toml | 1 + packages/presets/src/interfaces.cairo | 5 + .../src/interfaces/multisig_account.cairo | 47 ++ packages/presets/src/lib.cairo | 2 + packages/presets/src/multisig_account.cairo | 78 ++ packages/presets/src/tests.cairo | 1 + .../src/tests/test_multisig_account.cairo | 170 ++++ packages/test_common/src/lib.cairo | 1 + packages/test_common/src/mocks.cairo | 1 + .../src/mocks/multisig_account.cairo | 65 ++ .../test_common/src/multisig_account.cairo | 40 + scripts/get_hashes_page.py | 1 + sncast_scripts/Scarb.toml | 1 + sncast_scripts/src/declare_presets.cairo | 4 +- 34 files changed, 2649 insertions(+), 26 deletions(-) create mode 100644 packages/account/src/multisig_account.cairo create mode 100644 packages/account/src/multisig_account/multisig_account.cairo create mode 100644 packages/account/src/multisig_account/storage_utils.cairo create mode 100644 packages/account/src/tests/test_multisig_account.cairo create mode 100644 packages/macros/src/tests/snapshots/openzeppelin_macros__tests__test_with_components__with_multisig_account.snap create mode 100644 packages/macros/src/tests/snapshots/openzeppelin_macros__tests__test_with_components__with_multisig_account_no_initializer.snap create mode 100644 packages/presets/src/interfaces/multisig_account.cairo create mode 100644 packages/presets/src/multisig_account.cairo create mode 100644 packages/presets/src/tests/test_multisig_account.cairo create mode 100644 packages/test_common/src/mocks/multisig_account.cairo create mode 100644 packages/test_common/src/multisig_account.cairo diff --git a/CHANGELOG.md b/CHANGELOG.md index f7925ec18..988a3b7ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- `MultisigAccountComponent`, its signer-management interfaces, and the SRC9-enabled + `MultisigAccountUpgradeable` preset for quorum-based STARK-curve account authorization - `SafeERC20DispatcherTrait` in `openzeppelin_token::erc20::utils` with `assert_transfer`, `assert_transfer_from`, `assert_increase_allowance`, and `assert_decrease_allowance` (#1683) ### Changed (Breaking) diff --git a/docs/modules/ROOT/pages/api/account.adoc b/docs/modules/ROOT/pages/api/account.adoc index 941515eda..9ec7d13b1 100644 --- a/docs/modules/ROOT/pages/api/account.adoc +++ b/docs/modules/ROOT/pages/api/account.adoc @@ -64,6 +64,130 @@ Returns the short string `'VALID'` if valid, otherwise it reverts. Validates whether a signature is valid or not for the given message hash. +Returns the short string `'VALID'` if valid, otherwise returns `0`. + +[.contract] +[[IMultisigAccount]] +=== `++IMultisigAccount++` link:https://github.com/OpenZeppelin/cairo-contracts/blob/release-v4.0.0-alpha.1/packages/interfaces/src/account/accounts.cairo[{github-icon},role=heading-link] + +```cairo +use openzeppelin_interfaces::accounts::IMultisigAccount; +``` + +Interface for reading and managing a multisig account's registered STARK-curve signers and quorum. +Configuration changes are authorized by the account calling itself. + +[.contract-index] +.Functions +-- +* xref:#IMultisigAccount-get_quorum[`++get_quorum()++`] +* xref:#IMultisigAccount-is_signer[`++is_signer(signer)++`] +* xref:#IMultisigAccount-get_signers[`++get_signers()++`] +* xref:#IMultisigAccount-add_signers[`++add_signers(new_quorum, signers_to_add)++`] +* xref:#IMultisigAccount-remove_signers[`++remove_signers(new_quorum, signers_to_remove)++`] +* xref:#IMultisigAccount-replace_signer[`++replace_signer(signer_to_remove, signer_to_add)++`] +* xref:#IMultisigAccount-change_quorum[`++change_quorum(new_quorum)++`] +-- + +[#IMultisigAccount-Functions] +==== Functions + +[.contract-item] +[[IMultisigAccount-get_quorum]] +==== `[.contract-item-name]#++get_quorum++#++() → u32++` [.item-kind]#external# + +Returns the minimum number of signer records required to authorize an operation. + +[.contract-item] +[[IMultisigAccount-is_signer]] +==== `[.contract-item-name]#++is_signer++#++(signer: felt252) → bool++` [.item-kind]#external# + +Returns whether `signer` is a registered signer public key. + +[.contract-item] +[[IMultisigAccount-get_signers]] +==== `[.contract-item-name]#++get_signers++#++() → Span++` [.item-kind]#external# + +Returns the registered signer public keys. + +The function exposes registry order, which is not sorted and can change when signers are removed. +Sort selected public keys numerically in ascending order when encoding a multisig signature. + +[.contract-item] +[[IMultisigAccount-add_signers]] +==== `[.contract-item-name]#++add_signers++#++(new_quorum: u32, signers_to_add: Span)++` [.item-kind]#external# + +Registers the nonzero public keys in `signers_to_add` and sets the account quorum to +`new_quorum`. Public keys that are already registered are left unchanged. + +Requirements: + +- The caller must be the account itself. +- Every newly registered signer must be nonzero. +- `new_quorum` must be nonzero and no greater than the resulting signer count. + +[.contract-item] +[[IMultisigAccount-remove_signers]] +==== `[.contract-item-name]#++remove_signers++#++(new_quorum: u32, signers_to_remove: Span)++` [.item-kind]#external# + +Unregisters the public keys in `signers_to_remove` and sets the account quorum to +`new_quorum`. Public keys that are not registered are left unchanged. + +Requirements: + +- The caller must be the account itself. +- `new_quorum` must be nonzero and no greater than the resulting signer count. + +[.contract-item] +[[IMultisigAccount-replace_signer]] +==== `[.contract-item-name]#++replace_signer++#++(signer_to_remove: felt252, signer_to_add: felt252)++` [.item-kind]#external# + +Replaces `signer_to_remove` with `signer_to_add` while preserving the current quorum. + +Requirements: + +- The caller must be the account itself. +- `signer_to_remove` must be registered. +- `signer_to_add` must be nonzero and unregistered. + +[.contract-item] +[[IMultisigAccount-change_quorum]] +==== `[.contract-item-name]#++change_quorum++#++(new_quorum: u32)++` [.item-kind]#external# + +Sets the minimum number of signer records required to authorize an operation. + +Requirements: + +- The caller must be the account itself. +- `new_quorum` must be nonzero and no greater than the registered signer count. + +[.contract] +[[IMultisigDeployable]] +=== `++IMultisigDeployable++` link:https://github.com/OpenZeppelin/cairo-contracts/blob/release-v4.0.0-alpha.1/packages/interfaces/src/account/accounts.cairo[{github-icon},role=heading-link] + +```cairo +use openzeppelin_interfaces::accounts::IMultisigDeployable; +``` + +Interface for validating a multisig `DeployAccount` transaction whose constructor configures a +quorum and signer set. + +[.contract-index] +.Functions +-- +* xref:#IMultisigDeployable-\\__validate_deploy__[`++__validate_deploy__(class_hash, contract_address_salt, quorum, signers)++`] +-- + +[#IMultisigDeployable-Functions] +==== Functions + +[.contract-item] +[[IMultisigDeployable-__validate_deploy__]] +==== `[.contract-item-name]#++__validate_deploy__++#++(class_hash: felt252, contract_address_salt: felt252, quorum: u32, signers: Span) → felt252++` [.item-kind]#external# + +Validates a `DeployAccount` transaction using the signer quorum configured by `quorum` and +`signers`. + Returns the short string `'VALID'` if valid, otherwise it reverts. [.contract] @@ -368,6 +492,328 @@ Emitted when a `public_key` is added. Emitted when a `public_key` is removed. +[.contract] +[[MultisigAccountComponent]] +=== `++MultisigAccountComponent++` link:https://github.com/OpenZeppelin/cairo-contracts/blob/release-v4.0.0-alpha.1/packages/account/src/multisig_account/multisig_account.cairo[{github-icon},role=heading-link] + +:SignerAdded: xref:MultisigAccountComponent-SignerAdded[SignerAdded] +:SignerRemoved: xref:MultisigAccountComponent-SignerRemoved[SignerRemoved] +:QuorumUpdated: xref:MultisigAccountComponent-QuorumUpdated[QuorumUpdated] + +```cairo +use openzeppelin_account::MultisigAccountComponent; +``` + +Account component implementing xref:ISRC6[`ISRC6`] with quorum-based authorization from registered +STARK-curve signer keys. + +The canonical signature encoding is: + +```text +[1, n, public_key_1, r_1, s_1, ..., public_key_n, r_n, s_n] +``` + +The first felt is the signature format version and `n` is the number of signer records. A valid +signature satisfies all of the following conditions: + +- The array contains exactly two header felts followed by `n` three-felt signer records. +- The format version is `1`, and the encoded `n` matches the number of records. +- `n` is at least the account quorum and no greater than the registered signer count. +- Public keys are in strictly increasing numeric order and registered with the account. +- Every `(r, s)` pair is a valid STARK-curve signature for the same hash under its associated +public key. + +`is_valid_signature` returns the short string `'VALID'` when every condition holds and `0` +otherwise. The `invoke`, `declare`, and `deploy_account` validation entry points return `'VALID'` +for a valid transaction signature and revert for an invalid signature. + +CAUTION: Signature validation verifies every supplied signer record. Its execution cost grows +linearly with `n`, so callers can minimize validation work by supplying the smallest valid quorum. + +CAUTION: Configure a quorum that fits within Starknet account-validation resource limits. An +impractically high required quorum can prevent the account from authorizing a configuration +recovery. + +Signer and quorum changes are authorized through account self-calls. The current signer quorum +therefore authorizes configuration changes as account transactions. + +NOTE: {src5-component-required-note} + +[.contract-index#MultisigAccountComponent-Embeddable-Mixin-Impl] +.{mixin-impls} + +-- +.MultisigAccountMixinImpl + +* xref:#MultisigAccountComponent-Embeddable-Impls-SRC6Impl[`++SRC6Impl++`] +* xref:#MultisigAccountComponent-Embeddable-Impls-DeclarerImpl[`++DeclarerImpl++`] +* xref:#MultisigAccountComponent-Embeddable-Impls-DeployableImpl[`++DeployableImpl++`] +* xref:#MultisigAccountComponent-Embeddable-Impls-MultisigImpl[`++MultisigImpl++`] +* xref:#MultisigAccountComponent-Embeddable-Impls-SRC6CamelOnlyImpl[`++SRC6CamelOnlyImpl++`] +* xref:api/introspection.adoc#SRC5Component-Embeddable-Impls[`++SRC5Impl++`] +-- + +[.contract-index#MultisigAccountComponent-Embeddable-Impls] +.Embeddable Implementations +-- +[.sub-index#MultisigAccountComponent-Embeddable-Impls-SRC6Impl] +.SRC6Impl + +* xref:#MultisigAccountComponent-\\__execute__[`++__execute__(self, calls)++`] +* xref:#MultisigAccountComponent-\\__validate__[`++__validate__(self, calls)++`] +* xref:#MultisigAccountComponent-is_valid_signature[`++is_valid_signature(self, hash, signature)++`] + +[.sub-index#MultisigAccountComponent-Embeddable-Impls-DeclarerImpl] +.DeclarerImpl + +* xref:#MultisigAccountComponent-\\__validate_declare__[`++__validate_declare__(self, class_hash)++`] + +[.sub-index#MultisigAccountComponent-Embeddable-Impls-DeployableImpl] +.DeployableImpl + +* xref:#MultisigAccountComponent-\\__validate_deploy__[`++__validate_deploy__(self, class_hash, contract_address_salt, quorum, signers)++`] + +[.sub-index#MultisigAccountComponent-Embeddable-Impls-MultisigImpl] +.MultisigImpl + +* xref:#MultisigAccountComponent-get_quorum[`++get_quorum(self)++`] +* xref:#MultisigAccountComponent-is_signer[`++is_signer(self, signer)++`] +* xref:#MultisigAccountComponent-get_signers[`++get_signers(self)++`] +* xref:#MultisigAccountComponent-add_signers[`++add_signers(self, new_quorum, signers_to_add)++`] +* xref:#MultisigAccountComponent-remove_signers[`++remove_signers(self, new_quorum, signers_to_remove)++`] +* xref:#MultisigAccountComponent-replace_signer[`++replace_signer(self, signer_to_remove, signer_to_add)++`] +* xref:#MultisigAccountComponent-change_quorum[`++change_quorum(self, new_quorum)++`] + +[.sub-index#MultisigAccountComponent-Embeddable-Impls-SRC6CamelOnlyImpl] +.SRC6CamelOnlyImpl + +* xref:#MultisigAccountComponent-isValidSignature[`++isValidSignature(self, hash, signature)++`] + +.SRC5Impl +* xref:api/introspection.adoc#ISRC5-supports_interface[`supports_interface(self, interface_id: felt252)`] +-- + +[.contract-index] +.Internal Implementations +-- +.InternalImpl + +* xref:#MultisigAccountComponent-initializer[`++initializer(self, quorum, signers)++`] +* xref:#MultisigAccountComponent-assert_only_self[`++assert_only_self(self)++`] +* xref:#MultisigAccountComponent-validate_transaction[`++validate_transaction(self)++`] +* xref:#MultisigAccountComponent-_is_valid_signature[`++_is_valid_signature(self, hash, signature)++`] +* xref:#MultisigAccountComponent-_add_signers[`++_add_signers(self, new_quorum, signers_to_add)++`] +* xref:#MultisigAccountComponent-_remove_signers[`++_remove_signers(self, new_quorum, signers_to_remove)++`] +* xref:#MultisigAccountComponent-_replace_signer[`++_replace_signer(self, signer_to_remove, signer_to_add)++`] +* xref:#MultisigAccountComponent-_change_quorum[`++_change_quorum(self, new_quorum)++`] +-- + +[.contract-index] +.Events +-- +* xref:#MultisigAccountComponent-SignerAdded[`++SignerAdded(signer)++`] +* xref:#MultisigAccountComponent-SignerRemoved[`++SignerRemoved(signer)++`] +* xref:#MultisigAccountComponent-QuorumUpdated[`++QuorumUpdated(old_quorum, new_quorum)++`] +-- + +[#MultisigAccountComponent-Embeddable-Functions] +==== Embeddable functions + +[.contract-item] +[[MultisigAccountComponent-__execute__]] +==== `[.contract-item-name]#++__execute__++#++(self: @ContractState, calls: Array)++` [.item-kind]#external# + +Executes `calls` as an account transaction. + +Requirements: + +- The caller must be the Starknet protocol. +- The transaction version must be supported. + +[.contract-item] +[[MultisigAccountComponent-__validate__]] +==== `[.contract-item-name]#++__validate__++#++(self: @ContractState, calls: Array) → felt252++` [.item-kind]#external# + +Validates the current `invoke` transaction using its transaction hash and multisig signature. + +Returns the short string `'VALID'` if valid, otherwise it reverts. + +[.contract-item] +[[MultisigAccountComponent-is_valid_signature]] +==== `[.contract-item-name]#++is_valid_signature++#++(self: @ContractState, hash: felt252, signature: Array) → felt252++` [.item-kind]#external# + +Validates the canonical multisig `signature` for `hash` against the current signer set and quorum. +This function can be called by contracts that use SRC6 signature validation. + +Returns the short string `'VALID'` if valid, otherwise returns `0`. + +[.contract-item] +[[MultisigAccountComponent-__validate_declare__]] +==== `[.contract-item-name]#++__validate_declare__++#++(self: @ContractState, class_hash: felt252) → felt252++` [.item-kind]#external# + +Validates the current `Declare` transaction using its transaction hash and multisig signature. + +Returns the short string `'VALID'` if valid, otherwise it reverts. + +[.contract-item] +[[MultisigAccountComponent-__validate_deploy__]] +==== `[.contract-item-name]#++__validate_deploy__++#++(self: @ContractState, class_hash: felt252, contract_address_salt: felt252, quorum: u32, signers: Span) → felt252++` [.item-kind]#external# + +Validates the current `DeployAccount` transaction using its transaction hash and the signer +configuration supplied to the constructor. + +Returns the short string `'VALID'` if valid, otherwise it reverts. + +[.contract-item] +[[MultisigAccountComponent-get_quorum]] +==== `[.contract-item-name]#++get_quorum++#++(self: @ContractState) → u32++` [.item-kind]#external# + +See xref:IMultisigAccount-get_quorum[IMultisigAccount::get_quorum]. + +[.contract-item] +[[MultisigAccountComponent-is_signer]] +==== `[.contract-item-name]#++is_signer++#++(self: @ContractState, signer: felt252) → bool++` [.item-kind]#external# + +See xref:IMultisigAccount-is_signer[IMultisigAccount::is_signer]. + +[.contract-item] +[[MultisigAccountComponent-get_signers]] +==== `[.contract-item-name]#++get_signers++#++(self: @ContractState) → Span++` [.item-kind]#external# + +See xref:IMultisigAccount-get_signers[IMultisigAccount::get_signers]. + +[.contract-item] +[[MultisigAccountComponent-add_signers]] +==== `[.contract-item-name]#++add_signers++#++(ref self: ContractState, new_quorum: u32, signers_to_add: Span)++` [.item-kind]#external# + +See xref:IMultisigAccount-add_signers[IMultisigAccount::add_signers]. + +Emits a {SignerAdded} event for each newly registered signer and a {QuorumUpdated} event when the +quorum changes. + +[.contract-item] +[[MultisigAccountComponent-remove_signers]] +==== `[.contract-item-name]#++remove_signers++#++(ref self: ContractState, new_quorum: u32, signers_to_remove: Span)++` [.item-kind]#external# + +See xref:IMultisigAccount-remove_signers[IMultisigAccount::remove_signers]. + +Emits a {SignerRemoved} event for each removed signer and a {QuorumUpdated} event when the +quorum changes. + +[.contract-item] +[[MultisigAccountComponent-replace_signer]] +==== `[.contract-item-name]#++replace_signer++#++(ref self: ContractState, signer_to_remove: felt252, signer_to_add: felt252)++` [.item-kind]#external# + +See xref:IMultisigAccount-replace_signer[IMultisigAccount::replace_signer]. + +Emits a {SignerRemoved} event followed by a {SignerAdded} event. + +[.contract-item] +[[MultisigAccountComponent-change_quorum]] +==== `[.contract-item-name]#++change_quorum++#++(ref self: ContractState, new_quorum: u32)++` [.item-kind]#external# + +See xref:IMultisigAccount-change_quorum[IMultisigAccount::change_quorum]. + +Emits a {QuorumUpdated} event when the quorum changes. + +[.contract-item] +[[MultisigAccountComponent-isValidSignature]] +==== `[.contract-item-name]#++isValidSignature++#++(self: @ContractState, hash: felt252, signature: Array) → felt252++` [.item-kind]#external# + +See xref:MultisigAccountComponent-is_valid_signature[is_valid_signature]. + +[#MultisigAccountComponent-Internal-Functions] +==== Internal functions + +[.contract-item] +[[MultisigAccountComponent-initializer]] +==== `[.contract-item-name]#++initializer++#++(ref self: ComponentState, quorum: u32, signers: Span)++` [.item-kind]#internal# + +Registers each unique signer, sets the account quorum, and registers the `ISRC6` interface ID. + +Requirements: + +- Every signer must be nonzero. +- `quorum` must be nonzero and no greater than the number of unique signers. + +Emits a {SignerAdded} event for each unique signer and a {QuorumUpdated} event when setting the +initial quorum. + +[.contract-item] +[[MultisigAccountComponent-assert_only_self]] +==== `[.contract-item-name]#++assert_only_self++#++(self: @ComponentState)++` [.item-kind]#internal# + +Validates that the caller is the account itself. Otherwise it reverts. + +[.contract-item] +[[MultisigAccountComponent-validate_transaction]] +==== `[.contract-item-name]#++validate_transaction++#++(self: @ComponentState) → felt252++` [.item-kind]#internal# + +Validates the canonical multisig signature from the transaction context against the transaction +hash. + +Returns the short string `'VALID'` if valid, otherwise it reverts. + +[.contract-item] +[[MultisigAccountComponent-_is_valid_signature]] +==== `[.contract-item-name]#++_is_valid_signature++#++(self: @ComponentState, hash: felt252, signature: Span) → bool++` [.item-kind]#internal# + +Returns whether `signature` uses the canonical encoding and contains a valid signer quorum for +`hash`. + +[.contract-item] +[[MultisigAccountComponent-_add_signers]] +==== `[.contract-item-name]#++_add_signers++#++(ref self: ComponentState, new_quorum: u32, signers_to_add: Span)++` [.item-kind]#internal# + +Registers each nonzero, unregistered signer and sets the quorum to `new_quorum`. + +The caller embedding this internal function is responsible for enforcing authorization. + +[.contract-item] +[[MultisigAccountComponent-_remove_signers]] +==== `[.contract-item-name]#++_remove_signers++#++(ref self: ComponentState, new_quorum: u32, signers_to_remove: Span)++` [.item-kind]#internal# + +Unregisters each registered signer and sets the quorum to `new_quorum`. + +The caller embedding this internal function is responsible for enforcing authorization. + +[.contract-item] +[[MultisigAccountComponent-_replace_signer]] +==== `[.contract-item-name]#++_replace_signer++#++(ref self: ComponentState, signer_to_remove: felt252, signer_to_add: felt252)++` [.item-kind]#internal# + +Replaces a registered signer with a nonzero, unregistered signer while preserving the quorum. + +The caller embedding this internal function is responsible for enforcing authorization. + +[.contract-item] +[[MultisigAccountComponent-_change_quorum]] +==== `[.contract-item-name]#++_change_quorum++#++(ref self: ComponentState, new_quorum: u32)++` [.item-kind]#internal# + +Sets the quorum to a nonzero value no greater than the registered signer count. + +The caller embedding this internal function is responsible for enforcing authorization. + +[#MultisigAccountComponent-Events] +==== Events + +[.contract-item] +[[MultisigAccountComponent-SignerAdded]] +==== `[.contract-item-name]#++SignerAdded++#++(signer: felt252)++` [.item-kind]#event# + +Emitted when `signer` is registered with the account. + +[.contract-item] +[[MultisigAccountComponent-SignerRemoved]] +==== `[.contract-item-name]#++SignerRemoved++#++(signer: felt252)++` [.item-kind]#event# + +Emitted when `signer` is unregistered from the account. + +[.contract-item] +[[MultisigAccountComponent-QuorumUpdated]] +==== `[.contract-item-name]#++QuorumUpdated++#++(old_quorum: u32, new_quorum: u32)++` [.item-kind]#event# + +Emitted when the account quorum changes from `old_quorum` to `new_quorum`. + [.contract] [[EthAccountComponent]] === `++EthAccountComponent++` link:https://github.com/OpenZeppelin/cairo-contracts/blob/release-v4.0.0-alpha.1/packages/account/src/eth_account.cairo[{github-icon},role=heading-link] @@ -763,6 +1209,82 @@ Requirements: - The caller is the account contract itself. - `new_class_hash` cannot be zero. +[.contract] +[[MultisigAccountUpgradeable]] +=== `++MultisigAccountUpgradeable++` link:https://github.com/OpenZeppelin/cairo-contracts/blob/release-v4.0.0-alpha.1/packages/presets/src/multisig_account.cairo[{github-icon},role=heading-link] + +```cairo +use openzeppelin_presets::MultisigAccountUpgradeable; +``` + +Upgradeable account authorized by a quorum of registered STARK-curve signer keys. The preset can +manage its signer set and quorum, declare and deploy contracts, execute calls, and perform outside +execution through xref:#SRC9Component[SRC9]. Class upgrades are authorized through account +self-calls. + +Signatures use the canonical +`[1, n, public_key_1, r_1, s_1, ..., public_key_n, r_n, s_n]` encoding documented by +xref:#MultisigAccountComponent[MultisigAccountComponent]. + +include::../utils/_class_hashes.adoc[] + +[.contract-index] +.{presets-page} +-- +{MultisigAccountUpgradeable-class-hash} +-- + +[.contract-index] +.Constructor +-- +* xref:#MultisigAccountUpgradeable-constructor[`++constructor(self, quorum, signers)++`] +-- + +[.contract-index] +.Embedded Implementations +-- +.MultisigAccountComponent +* xref:#MultisigAccountComponent-Embeddable-Mixin-Impl[`++MultisigAccountMixinImpl++`] + +.SRC9Component +* xref:#SRC9Component-Embeddable-Impls-OutsideExecutionV2Impl[`++OutsideExecutionV2Impl++`] +-- + +[.contract-index] +.External Functions +-- +* xref:#MultisigAccountUpgradeable-upgrade[`++upgrade(self, new_class_hash)++`] +-- + +[#MultisigAccountUpgradeable-constructor-section] +==== Constructor + +[.contract-item] +[[MultisigAccountUpgradeable-constructor]] +==== `[.contract-item-name]#++constructor++#++(ref self: ContractState, quorum: u32, signers: Span)++` [.item-kind]#constructor# + +Registers each unique signer, sets the account quorum, and registers the `ISRC6` and `ISRC9_V2` +interface IDs. + +Requirements: + +- Every signer must be nonzero. +- `quorum` must be nonzero and no greater than the number of unique signers. + +[#MultisigAccountUpgradeable-external-functions] +==== External functions + +[.contract-item] +[[MultisigAccountUpgradeable-upgrade]] +==== `[.contract-item-name]#++upgrade++#++(ref self: ContractState, new_class_hash: ClassHash)++` [.item-kind]#external# + +Upgrades the contract to the implementation identified by `new_class_hash`. + +Requirements: + +- The caller is the account contract itself. +- `new_class_hash` cannot be zero. + [.contract] [[EthAccountUpgradeable]] === `++EthAccountUpgradeable++` link:https://github.com/OpenZeppelin/cairo-contracts/blob/release-v4.0.0-alpha.1/packages/presets/src/eth_account.cairo[{github-icon},role=heading-link] diff --git a/docs/modules/ROOT/pages/presets.adoc b/docs/modules/ROOT/pages/presets.adoc index 6437590f5..4bcfc8920 100644 --- a/docs/modules/ROOT/pages/presets.adoc +++ b/docs/modules/ROOT/pages/presets.adoc @@ -3,6 +3,7 @@ :erc721-upgradeable: xref:/api/erc721.adoc#ERC721Upgradeable[ERC721Upgradeable] :erc1155-upgradeable: xref:/api/erc1155.adoc#ERC1155Upgradeable[ERC1155Upgradeable] :eth-account-upgradeable: xref:/api/account.adoc#EthAccountUpgradeable[EthAccountUpgradeable] +:multisig-account-upgradeable: xref:/api/account.adoc#MultisigAccountUpgradeable[MultisigAccountUpgradeable] :udc: xref:/api/udc.adoc#UniversalDeployer[UniversalDeployer] :vesting-wallet: xref:/api/finance.adoc#VestingWallet[VestingWallet] :sierra-class-hashes: https://docs.starknet.io/architecture-and-concepts/smart-contracts/class-hash/[Sierra class hashes] @@ -14,10 +15,10 @@ include::utils/_class_hashes.adoc[] -Presets are ready-to-deploy contracts provided by the library. Since presets are intended to be very simple -and as generic as possible, there's no support for custom or complex contracts such as `ERC20Pausable` or `ERC721Mintable`. +Presets are ready-to-deploy contracts that combine widely used components into simple, +general-purpose configurations. -TIP: For contract customization and combination of modules you can use {wizard}, our code-generation tool. +TIP: Use {wizard}, our code-generation tool, to build custom combinations of components. == Available presets @@ -36,6 +37,9 @@ CAUTION: Before version 4.0.0-alpha.1, class hashes were computed using the `sca | `{account-upgradeable}` | `{AccountUpgradeable-class-hash}` +| `{multisig-account-upgradeable}` +| `{MultisigAccountUpgradeable-class-hash}` + | `{erc20-upgradeable}` | `{ERC20Upgradeable-class-hash}` @@ -55,6 +59,12 @@ CAUTION: Before version 4.0.0-alpha.1, class hashes were computed using the `sca | `{VestingWallet-class-hash}` |=== +The {multisig-account-upgradeable} preset provides quorum-based STARK-curve authorization through +SRC6, outside execution through SRC9, and self-authorized class upgrades. Its constructor accepts +a quorum and a signer span, and account signatures use the canonical +`[1, n, public_key_1, r_1, s_1, ..., public_key_n, r_n, s_n]` encoding documented by +xref:/api/account.adoc#MultisigAccountComponent[MultisigAccountComponent]. + TIP: {starkli} class-hash command can be used to compute the class hash from a Sierra artifact. == Usage diff --git a/docs/modules/ROOT/pages/utils/_class_hashes.adoc b/docs/modules/ROOT/pages/utils/_class_hashes.adoc index 2396a7ea8..ef2654550 100644 --- a/docs/modules/ROOT/pages/utils/_class_hashes.adoc +++ b/docs/modules/ROOT/pages/utils/_class_hashes.adoc @@ -1,15 +1,16 @@ // Version -:class-hash-cairo-version: https://crates.io/crates/cairo-lang-compiler/2.17.0[cairo 2.17.0] +:class-hash-cairo-version: https://crates.io/crates/cairo-lang-compiler/2.18.0[cairo 2.18.0] // Class Hashes -:ERC20Upgradeable-class-hash: 0x02306d411d9061591d2a661f177ea92e53918a474803c33a901bace86dfaea6c -:ERC721Upgradeable-class-hash: 0x0735400adfb617a215a0eadd009e84ecebf10b258b7ce7583d4131f7fa793bf1 -:ERC1155Upgradeable-class-hash: 0x02f81498862c308981a544e7da3e65c715c375c97a881da2d157b2f3a3dcdd93 -:AccountUpgradeable-class-hash: 0x0342f3c683f708fd920e625e425b77726aa4c834da47ec92b4da5c207f025207 -:EthAccountUpgradeable-class-hash: 0x0216f6773aef13b25e5f8ecc989c4a4668d45ba118b41bbdca10d08fc6fda878 -:UniversalDeployer-class-hash: 0x00ce766f9026176e6796a13720059301c4caa1b24cc55d964afb62bed07b0158 -:MetaTransactionV0-class-hash: 0x051d6576154ab74933e521cd0d8e3367d14ff1713fd2b2ce982af1f555564134 -:VestingWallet-class-hash: 0x051be0e6c0a6904b496c77f991f423e766ff465752cb18e93b6eb5ca525cf219 +:ERC20Upgradeable-class-hash: 0x06d081c70145f14c3e0d05eacbfaa01620a27fde89430e6c227a8f4a573e3727 +:ERC721Upgradeable-class-hash: 0x05f3d201fc5c9de757ca6c42ec753d514ab1ea011b60bd139b6167cd069109d2 +:ERC1155Upgradeable-class-hash: 0x075829cba5eea49a998988967e6a97de68824830118f7f008f3ac07d407cb99a +:AccountUpgradeable-class-hash: 0x03013bf2edcf93ac575367eee41cd07bb50e809c78925817b61878db29826144 +:MultisigAccountUpgradeable-class-hash: 0x05e016dd4ace8b826345a13d90929f149658b7562373c86397c4af3c1cc7bc61 +:EthAccountUpgradeable-class-hash: 0x000be1cf8839442d664572712fd36554c4d259f86549ad02e22353d10729b900 +:UniversalDeployer-class-hash: 0x05412c7a3e145fdc79862e65df612324d83b0169c071dd564021155bd8cfd29e +:MetaTransactionV0-class-hash: 0x059b5a7a062db4a9fa1ec4b96ea74cac3d55477971d68ac9fba877c087e59808 +:VestingWallet-class-hash: 0x05613fb6c8f729fdcbef50d25c9dd72260762543d48254c59e6851dd3d46595a // Presets page :presets-page: xref:presets.adoc[Sierra class hash] diff --git a/packages/account/README.md b/packages/account/README.md index c7e1dfa09..0b3d28c2e 100644 --- a/packages/account/README.md +++ b/packages/account/README.md @@ -2,21 +2,51 @@ > **NOTE:** This document is better viewed at [https://docs.openzeppelin.com/contracts-cairo/api/account](https://docs.openzeppelin.com/contracts-cairo/3.x/api/account) -This crate provides components to implement account contracts that can be used for interacting with the network. +This crate provides components for building account contracts that interact with the network. -- `Account` validates transactions from signatures over the -[STARK Curve](https://docs.starknet.io/architecture-and-concepts/cryptography/#the_stark_curve). +- `AccountComponent` validates transactions from signatures over the + [STARK Curve](https://docs.starknet.io/architecture-and-concepts/cryptography/#the_stark_curve). -- `EthAccount` validates transactions from signatures over the -[Secp256k1 curve](https://en.bitcoin.it/wiki/Secp256k1). +- `EthAccountComponent` validates transactions from signatures over the + [Secp256k1 curve](https://en.bitcoin.it/wiki/Secp256k1). + +- `MultisigAccountComponent` validates transactions when a configured quorum of registered + STARK-curve signers authorizes the same hash. + +### Multisig signatures + +`MultisigAccountComponent` accepts the canonical felt-array encoding +`[1, n, public_key_1, r_1, s_1, ..., public_key_n, r_n, s_n]`. The first felt is the signature +format version, and `n` is the number of signer records that follow. Signer public keys must be +strictly increasing, which makes every signer distinct, and every `(r, s)` pair must be a valid +STARK-curve signature for the same hash from its associated registered signer. + +A signature is valid when its header matches the encoded records, `n` is at least the account +quorum and no greater than the registered signer count, and every supplied record is valid. +`is_valid_signature` returns `starknet::VALIDATED` for a valid signature and `0` otherwise. The +`invoke`, `declare`, and `deploy_account` validation entry points return `starknet::VALIDATED` for +a valid signature and revert when validation fails. + +Signature validation verifies every supplied signer record, so its execution cost grows linearly +with `n`. Callers can minimize validation work by supplying the smallest valid signer quorum. +Configure a quorum that fits within Starknet account-validation resource limits: an impractically +high required quorum can prevent the account from authorizing a configuration recovery. + +Signer additions, removals, replacements, and quorum changes accept calls only from the account +itself. The current quorum can therefore manage the account configuration through an authorized +account transaction. The governance multisig coordinates proposals and confirmations, while +`MultisigAccountComponent` authenticates account transactions during SRC6 validation. ### Interfaces - [`ISRC6`](https://docs.openzeppelin.com/contracts-cairo/3.x/api/account#ISRC6) +- [`IMultisigAccount`](https://docs.openzeppelin.com/contracts-cairo/3.x/api/account#IMultisigAccount) +- [`IMultisigDeployable`](https://docs.openzeppelin.com/contracts-cairo/3.x/api/account#IMultisigDeployable) - [`ISRC9_V2`](https://docs.openzeppelin.com/contracts-cairo/3.x/api/account#ISRC9_V2) ### Components - [`AccountComponent`](https://docs.openzeppelin.com/contracts-cairo/3.x/api/account#AccountComponent) - [`EthAccountComponent`](https://docs.openzeppelin.com/contracts-cairo/3.x/api/account#EthAccountComponent) +- [`MultisigAccountComponent`](https://docs.openzeppelin.com/contracts-cairo/3.x/api/account#MultisigAccountComponent) - [`SRC9Component`](https://docs.openzeppelin.com/contracts-cairo/3.x/api/account#SRC9Component) diff --git a/packages/account/Scarb.toml b/packages/account/Scarb.toml index c0e826d07..ce97a7d91 100644 --- a/packages/account/Scarb.toml +++ b/packages/account/Scarb.toml @@ -49,6 +49,8 @@ name = "openzeppelin_account_unittest" build-external-contracts = [ "openzeppelin_test_common::mocks::account::DualCaseAccountMock", "openzeppelin_test_common::mocks::account::DualCaseEthAccountMock", + "openzeppelin_test_common::mocks::multisig_account::MultisigAccountMock", + "openzeppelin_test_common::mocks::multisig_account::SignatureCallerMock", "openzeppelin_test_common::mocks::src9::SRC9AccountMock", "openzeppelin_test_common::mocks::simple::SimpleMock", ] diff --git a/packages/account/src/lib.cairo b/packages/account/src/lib.cairo index 93ec3545e..48dbc207e 100644 --- a/packages/account/src/lib.cairo +++ b/packages/account/src/lib.cairo @@ -1,6 +1,7 @@ pub mod account; pub mod eth_account; pub mod extensions; +pub mod multisig_account; #[cfg(test)] mod tests; @@ -8,3 +9,4 @@ pub mod utils; pub use account::AccountComponent; pub use eth_account::EthAccountComponent; +pub use multisig_account::MultisigAccountComponent; diff --git a/packages/account/src/multisig_account.cairo b/packages/account/src/multisig_account.cairo new file mode 100644 index 000000000..a2ac01d25 --- /dev/null +++ b/packages/account/src/multisig_account.cairo @@ -0,0 +1,4 @@ +pub mod multisig_account; +pub mod storage_utils; + +pub use multisig_account::MultisigAccountComponent; diff --git a/packages/account/src/multisig_account/multisig_account.cairo b/packages/account/src/multisig_account/multisig_account.cairo new file mode 100644 index 000000000..96629e2ee --- /dev/null +++ b/packages/account/src/multisig_account/multisig_account.cairo @@ -0,0 +1,600 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Contracts for Cairo v4.0.0-alpha.1 +// (account/src/multisig_account/multisig_account.cairo) + +/// # MultisigAccount Component +/// +/// The MultisigAccount component enables contracts to behave as accounts authorized by a quorum +/// of Stark-curve signer keys. Each account signature contains a format version, a signer count, +/// and a strictly increasing numeric sequence of `[signer_public_key, r, s]` records. Every +/// `(r, s)` pair signs the same message hash. +/// +/// Signer and quorum updates are authorized through account self-calls, allowing the current +/// signer quorum to manage the account configuration. +#[starknet::component] +pub mod MultisigAccountComponent { + use core::num::traits::Zero; + use openzeppelin_interfaces::accounts as interface; + use openzeppelin_introspection::src5::SRC5Component; + use openzeppelin_introspection::src5::SRC5Component::{ + InternalTrait as SRC5InternalTrait, SRC5Impl, + }; + use openzeppelin_utils::execution::execute_single_call; + use starknet::account::Call; + use starknet::storage::{ + Map, StorageMapReadAccess, StorageMapWriteAccess, StoragePointerReadAccess, + StoragePointerWriteAccess, + }; + use crate::multisig_account::storage_utils::{SignersInfo, SignersInfoStorePacking}; + use crate::utils::{is_tx_version_valid, is_valid_stark_signature}; + + /// Identifies the supported multisig signature encoding. + pub const SIGNATURE_VERSION: felt252 = 1; + + const SIGNATURE_HEADER_LENGTH: u32 = 2; + const SIGNATURE_RECORD_LENGTH: u32 = 3; + + #[storage] + pub struct Storage { + pub MultisigAccount_signers_info: SignersInfo, + pub MultisigAccount_is_signer: Map, + pub MultisigAccount_signers_by_index: Map, + pub MultisigAccount_signers_indices: Map, + } + + #[event] + #[derive(Drop, Debug, PartialEq, starknet::Event)] + pub enum Event { + SignerAdded: SignerAdded, + SignerRemoved: SignerRemoved, + QuorumUpdated: QuorumUpdated, + } + + /// Emitted when `signer` is added to the account. + #[derive(Drop, Debug, PartialEq, starknet::Event)] + pub struct SignerAdded { + #[key] + pub signer: felt252, + } + + /// Emitted when `signer` is removed from the account. + #[derive(Drop, Debug, PartialEq, starknet::Event)] + pub struct SignerRemoved { + #[key] + pub signer: felt252, + } + + /// Emitted when the account quorum changes. + #[derive(Drop, Debug, PartialEq, starknet::Event)] + pub struct QuorumUpdated { + pub old_quorum: u32, + pub new_quorum: u32, + } + + pub mod Errors { + pub const INVALID_CALLER: felt252 = 'MultisigAccount: invalid caller'; + pub const INVALID_SIGNATURE: felt252 = 'MultisigAccount: invalid sig'; + pub const INVALID_TX_VERSION: felt252 = 'MultisigAccount: bad tx version'; + pub const UNAUTHORIZED: felt252 = 'MultisigAccount: unauthorized'; + pub const NOT_A_SIGNER: felt252 = 'MultisigAccount: not signer'; + pub const ALREADY_A_SIGNER: felt252 = 'MultisigAccount: already signer'; + pub const ZERO_SIGNER: felt252 = 'MultisigAccount: zero signer'; + pub const ZERO_QUORUM: felt252 = 'MultisigAccount: zero quorum'; + pub const QUORUM_TOO_HIGH: felt252 = 'MultisigAccount: high quorum'; + } + + // + // External + // + + #[embeddable_as(SRC6Impl)] + impl SRC6< + TContractState, + +HasComponent, + +SRC5Component::HasComponent, + +Drop, + > of interface::ISRC6> { + /// Executes a list of calls from the account. + /// + /// Requirements: + /// + /// - The caller must be the Starknet protocol. + /// - The transaction version must be supported. + fn __execute__(self: @ComponentState, calls: Array) { + let sender = starknet::get_caller_address(); + assert(sender.is_zero(), Errors::INVALID_CALLER); + assert(is_tx_version_valid(), Errors::INVALID_TX_VERSION); + + for call in calls.span() { + execute_single_call(call); + } + } + + /// Verifies the signer quorum for the current `invoke` transaction. + fn __validate__(self: @ComponentState, calls: Array) -> felt252 { + self.validate_transaction() + } + + /// Verifies a signer quorum for `hash`. + fn is_valid_signature( + self: @ComponentState, hash: felt252, signature: Array, + ) -> felt252 { + if self._is_valid_signature(hash, signature.span()) { + starknet::VALIDATED + } else { + 0 + } + } + } + + #[embeddable_as(DeclarerImpl)] + impl Declarer< + TContractState, + +HasComponent, + +SRC5Component::HasComponent, + +Drop, + > of interface::IDeclarer> { + /// Verifies the signer quorum for the current `declare` transaction. + fn __validate_declare__( + self: @ComponentState, class_hash: felt252, + ) -> felt252 { + self.validate_transaction() + } + } + + #[embeddable_as(DeployableImpl)] + impl Deployable< + TContractState, + +HasComponent, + +SRC5Component::HasComponent, + +Drop, + > of interface::IMultisigDeployable> { + /// Verifies the signer quorum for the current `deploy_account` transaction. + fn __validate_deploy__( + self: @ComponentState, + class_hash: felt252, + contract_address_salt: felt252, + quorum: u32, + signers: Span, + ) -> felt252 { + self.validate_transaction() + } + } + + #[embeddable_as(MultisigImpl)] + impl Multisig< + TContractState, + +HasComponent, + +SRC5Component::HasComponent, + +Drop, + > of interface::IMultisigAccount> { + /// Returns the minimum number of signer records required to authorize an operation. + fn get_quorum(self: @ComponentState) -> u32 { + self.MultisigAccount_signers_info.read().quorum + } + + /// Returns whether `signer` is a registered signer public key. + fn is_signer(self: @ComponentState, signer: felt252) -> bool { + self.MultisigAccount_is_signer.read(signer) + } + + /// Returns all registered signer public keys in registry order. + /// + /// Registry order is not sorted and can change when signers are removed. + fn get_signers(self: @ComponentState) -> Span { + let mut signers = array![]; + let signers_count = self.MultisigAccount_signers_info.read().signers_count; + for index in 0..signers_count { + signers.append(self.MultisigAccount_signers_by_index.read(index)); + } + signers.span() + } + + /// Adds signer public keys and sets the quorum to `new_quorum`. + /// Already registered public keys are ignored. + /// + /// Requirements: + /// + /// - The caller must be the account itself. + /// - Every added signer must be non-zero. + /// - `new_quorum` must be valid for the resulting signer set. + /// + /// Emits a `SignerAdded` event for each newly registered signer and a `QuorumUpdated` event + /// if the quorum changes. + fn add_signers( + ref self: ComponentState, + new_quorum: u32, + signers_to_add: Span, + ) { + self.assert_only_self(); + self._add_signers(new_quorum, signers_to_add); + } + + /// Removes signer public keys and sets the quorum to `new_quorum`. + /// Unregistered public keys are ignored. Removing signers can change registry order. + /// + /// Requirements: + /// + /// - The caller must be the account itself. + /// - `new_quorum` must be valid for the resulting signer set. + /// + /// Emits a `SignerRemoved` event for each removed signer and a `QuorumUpdated` event if the + /// quorum changes. + fn remove_signers( + ref self: ComponentState, + new_quorum: u32, + signers_to_remove: Span, + ) { + self.assert_only_self(); + self._remove_signers(new_quorum, signers_to_remove); + } + + /// Replaces one signer public key with another. + /// + /// Requirements: + /// + /// - The caller must be the account itself. + /// - `signer_to_remove` must be registered. + /// - `signer_to_add` must be non-zero and unregistered. + /// + /// Emits a `SignerRemoved` event followed by a `SignerAdded` event. + fn replace_signer( + ref self: ComponentState, + signer_to_remove: felt252, + signer_to_add: felt252, + ) { + self.assert_only_self(); + self._replace_signer(signer_to_remove, signer_to_add); + } + + /// Sets the number of signer records required to authorize an operation. + /// + /// Requirements: + /// + /// - The caller must be the account itself. + /// - `new_quorum` must be non-zero and no greater than the signer count. + /// + /// Emits a `QuorumUpdated` event if the quorum changes. + fn change_quorum(ref self: ComponentState, new_quorum: u32) { + self.assert_only_self(); + self._change_quorum(new_quorum); + } + } + + /// Adds camelCase support for `ISRC6`. + #[embeddable_as(SRC6CamelOnlyImpl)] + impl SRC6CamelOnly< + TContractState, + +HasComponent, + +SRC5Component::HasComponent, + +Drop, + > of interface::ISRC6CamelOnly> { + fn isValidSignature( + self: @ComponentState, hash: felt252, signature: Array, + ) -> felt252 { + SRC6::is_valid_signature(self, hash, signature) + } + } + + #[embeddable_as(MultisigAccountMixinImpl)] + impl MultisigAccountMixin< + TContractState, + +HasComponent, + impl SRC5: SRC5Component::HasComponent, + +Drop, + > of interface::MultisigAccountABI> { + // ISRC6 + fn __execute__(self: @ComponentState, calls: Array) { + SRC6::__execute__(self, calls) + } + + fn __validate__(self: @ComponentState, calls: Array) -> felt252 { + SRC6::__validate__(self, calls) + } + + fn is_valid_signature( + self: @ComponentState, hash: felt252, signature: Array, + ) -> felt252 { + SRC6::is_valid_signature(self, hash, signature) + } + + // ISRC6CamelOnly + fn isValidSignature( + self: @ComponentState, hash: felt252, signature: Array, + ) -> felt252 { + SRC6CamelOnly::isValidSignature(self, hash, signature) + } + + // IDeclarer + fn __validate_declare__( + self: @ComponentState, class_hash: felt252, + ) -> felt252 { + Declarer::__validate_declare__(self, class_hash) + } + + // IMultisigDeployable + fn __validate_deploy__( + self: @ComponentState, + class_hash: felt252, + contract_address_salt: felt252, + quorum: u32, + signers: Span, + ) -> felt252 { + Deployable::__validate_deploy__( + self, class_hash, contract_address_salt, quorum, signers, + ) + } + + // IMultisigAccount + fn get_quorum(self: @ComponentState) -> u32 { + Multisig::get_quorum(self) + } + + fn is_signer(self: @ComponentState, signer: felt252) -> bool { + Multisig::is_signer(self, signer) + } + + fn get_signers(self: @ComponentState) -> Span { + Multisig::get_signers(self) + } + + fn add_signers( + ref self: ComponentState, + new_quorum: u32, + signers_to_add: Span, + ) { + Multisig::add_signers(ref self, new_quorum, signers_to_add) + } + + fn remove_signers( + ref self: ComponentState, + new_quorum: u32, + signers_to_remove: Span, + ) { + Multisig::remove_signers(ref self, new_quorum, signers_to_remove) + } + + fn replace_signer( + ref self: ComponentState, + signer_to_remove: felt252, + signer_to_add: felt252, + ) { + Multisig::replace_signer(ref self, signer_to_remove, signer_to_add) + } + + fn change_quorum(ref self: ComponentState, new_quorum: u32) { + Multisig::change_quorum(ref self, new_quorum) + } + + // ISRC5 + fn supports_interface( + self: @ComponentState, interface_id: felt252, + ) -> bool { + let src5 = get_dep_component!(self, SRC5); + src5.supports_interface(interface_id) + } + } + + // + // Internal + // + + #[generate_trait] + pub impl InternalImpl< + TContractState, + +HasComponent, + impl SRC5: SRC5Component::HasComponent, + +Drop, + > of InternalTrait { + /// Initializes the signer set and quorum and registers the `ISRC6` interface ID. + /// + /// Requirements: + /// + /// - Every signer must be non-zero. + /// - `quorum` must be non-zero and no greater than the number of unique signers. + /// + /// Emits a `SignerAdded` event for each unique signer and a `QuorumUpdated` event when + /// setting the initial quorum. + fn initializer( + ref self: ComponentState, quorum: u32, signers: Span, + ) { + let mut src5_component = get_dep_component_mut!(ref self, SRC5); + src5_component.register_interface(interface::ISRC6_ID); + self._add_signers(quorum, signers); + } + + /// Asserts that the caller is the account itself. + fn assert_only_self(self: @ComponentState) { + let caller = starknet::get_caller_address(); + let account_address = starknet::get_contract_address(); + assert(caller == account_address, Errors::UNAUTHORIZED); + } + + /// Verifies the signer quorum for the transaction hash and signature in the execution + /// context. + fn validate_transaction(self: @ComponentState) -> felt252 { + let tx_info = starknet::get_tx_info().unbox(); + let tx_hash = tx_info.transaction_hash; + let signature = tx_info.signature; + assert(self._is_valid_signature(tx_hash, signature), Errors::INVALID_SIGNATURE); + starknet::VALIDATED + } + + /// Returns whether `signature` contains a valid signer quorum for `hash`. + /// + /// The signature is encoded as + /// `[version, signer_count, signer_public_key, r, s, ...]`. Signer records must be ordered + /// by strictly increasing numeric public key. Every supplied record must belong to a + /// registered signer and contain a valid Stark-curve signature for `hash`. + /// + /// A zero stored quorum is invalid, so uninitialized component storage fails closed. + fn _is_valid_signature( + self: @ComponentState, hash: felt252, signature: Span, + ) -> bool { + let signature_len = signature.len(); + if signature_len < SIGNATURE_HEADER_LENGTH { + return false; + } + + let records_len = signature_len - SIGNATURE_HEADER_LENGTH; + if records_len % SIGNATURE_RECORD_LENGTH != 0 { + return false; + } + + let provided_signer_count = records_len / SIGNATURE_RECORD_LENGTH; + if *signature.at(0) != SIGNATURE_VERSION + || *signature.at(1) != provided_signer_count.into() { + return false; + } + + let SignersInfo { quorum, signers_count } = self.MultisigAccount_signers_info.read(); + if quorum.is_zero() { + return false; + } + if provided_signer_count < quorum || provided_signer_count > signers_count { + return false; + } + + let mut previous_signer_value: u256 = 0; + let mut record_offset = SIGNATURE_HEADER_LENGTH; + while record_offset < signature_len { + let signer = *signature.at(record_offset); + let signer_value: u256 = signer.into(); + // Strict ordering canonicalizes the encoding and rejects duplicate signer keys. + if signer_value <= previous_signer_value { + return false; + } + if !Multisig::is_signer(self, signer) { + return false; + } + + let signer_signature = array![ + *signature.at(record_offset + 1), *signature.at(record_offset + 2), + ]; + if !is_valid_stark_signature(hash, signer, signer_signature.span()) { + return false; + } + + previous_signer_value = signer_value; + record_offset += SIGNATURE_RECORD_LENGTH; + } + + true + } + + /// Adds unregistered signer public keys and sets the quorum to `new_quorum`. + /// Already registered public keys are ignored. + /// + /// The caller is responsible for enforcing authorization. + fn _add_signers( + ref self: ComponentState, + new_quorum: u32, + signers_to_add: Span, + ) { + if !signers_to_add.is_empty() { + let SignersInfo { + quorum: current_quorum, mut signers_count, + } = self.MultisigAccount_signers_info.read(); + for signer in signers_to_add { + let signer_to_add = *signer; + assert(signer_to_add.is_non_zero(), Errors::ZERO_SIGNER); + if Multisig::is_signer(@self, signer_to_add) { + continue; + } + + let signer_index = signers_count; + self.MultisigAccount_is_signer.write(signer_to_add, true); + self.MultisigAccount_signers_by_index.write(signer_index, signer_to_add); + self.MultisigAccount_signers_indices.write(signer_to_add, signer_index); + self.emit(SignerAdded { signer: signer_to_add }); + signers_count += 1; + } + self + .MultisigAccount_signers_info + .write(SignersInfo { quorum: current_quorum, signers_count }); + } + self._change_quorum(new_quorum); + } + + /// Removes registered signer public keys and sets the quorum to `new_quorum`. + /// Unregistered public keys are ignored. + /// + /// The caller is responsible for enforcing authorization. + fn _remove_signers( + ref self: ComponentState, + new_quorum: u32, + signers_to_remove: Span, + ) { + if !signers_to_remove.is_empty() { + let SignersInfo { + quorum: current_quorum, mut signers_count, + } = self.MultisigAccount_signers_info.read(); + for signer in signers_to_remove { + let signer_to_remove = *signer; + if !Multisig::is_signer(@self, signer_to_remove) { + continue; + } + + let last_index = signers_count - 1; + let removed_index = self.MultisigAccount_signers_indices.read(signer_to_remove); + if removed_index != last_index { + // Move the last signer into the removed signer's registry slot. + let last_signer = self.MultisigAccount_signers_by_index.read(last_index); + self.MultisigAccount_signers_indices.write(last_signer, removed_index); + self.MultisigAccount_signers_by_index.write(removed_index, last_signer); + } + + // Clear the old last slot after the move. + self.MultisigAccount_is_signer.write(signer_to_remove, false); + self.MultisigAccount_signers_by_index.write(last_index, Zero::zero()); + self.MultisigAccount_signers_indices.write(signer_to_remove, 0); + self.emit(SignerRemoved { signer: signer_to_remove }); + signers_count -= 1; + } + self + .MultisigAccount_signers_info + .write(SignersInfo { quorum: current_quorum, signers_count }); + } + self._change_quorum(new_quorum); + } + + /// Replaces one signer public key with another. + /// + /// The caller is responsible for enforcing authorization. + fn _replace_signer( + ref self: ComponentState, + signer_to_remove: felt252, + signer_to_add: felt252, + ) { + assert(signer_to_add.is_non_zero(), Errors::ZERO_SIGNER); + assert(!Multisig::is_signer(@self, signer_to_add), Errors::ALREADY_A_SIGNER); + assert(Multisig::is_signer(@self, signer_to_remove), Errors::NOT_A_SIGNER); + + let signer_index = self.MultisigAccount_signers_indices.read(signer_to_remove); + self.MultisigAccount_is_signer.write(signer_to_remove, false); + self.MultisigAccount_is_signer.write(signer_to_add, true); + self.MultisigAccount_signers_by_index.write(signer_index, signer_to_add); + self.MultisigAccount_signers_indices.write(signer_to_add, signer_index); + self.MultisigAccount_signers_indices.write(signer_to_remove, 0); + self.emit(SignerRemoved { signer: signer_to_remove }); + self.emit(SignerAdded { signer: signer_to_add }); + } + + /// Sets the number of signer records required to authorize an operation. + /// + /// The caller is responsible for enforcing authorization. + fn _change_quorum(ref self: ComponentState, new_quorum: u32) { + let SignersInfo { + quorum: old_quorum, signers_count, + } = self.MultisigAccount_signers_info.read(); + assert(new_quorum.is_non_zero(), Errors::ZERO_QUORUM); + assert(new_quorum <= signers_count, Errors::QUORUM_TOO_HIGH); + if new_quorum != old_quorum { + self + .MultisigAccount_signers_info + .write(SignersInfo { quorum: new_quorum, signers_count }); + self.emit(QuorumUpdated { old_quorum, new_quorum }); + } + } + } +} diff --git a/packages/account/src/multisig_account/storage_utils.cairo b/packages/account/src/multisig_account/storage_utils.cairo new file mode 100644 index 000000000..478e03466 --- /dev/null +++ b/packages/account/src/multisig_account/storage_utils.cairo @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Contracts for Cairo v4.0.0-alpha.1 +// (account/src/multisig_account/storage_utils.cairo) + +use core::integer::u128_safe_divmod; +use starknet::storage_access::StorePacking; + +/// Stores a multisig account's quorum and signer count in one storage slot. +#[derive(Drop)] +pub struct SignersInfo { + pub quorum: u32, + pub signers_count: u32, +} + +const TWO_POW_32: NonZero = 0x100000000; + +/// Packs `SignersInfo` into a `u128` value. +/// +/// The signer count occupies bits 0 through 31 and the quorum occupies bits 32 through 63. +pub impl SignersInfoStorePacking of StorePacking { + fn pack(value: SignersInfo) -> u128 { + let SignersInfo { quorum, signers_count } = value; + quorum.into() * TWO_POW_32.into() + signers_count.into() + } + + fn unpack(value: u128) -> SignersInfo { + let (quorum, signers_count) = u128_safe_divmod(value, TWO_POW_32); + SignersInfo { + quorum: quorum.try_into().unwrap(), signers_count: signers_count.try_into().unwrap(), + } + } +} diff --git a/packages/account/src/tests.cairo b/packages/account/src/tests.cairo index 1d84fc250..784d2f87d 100644 --- a/packages/account/src/tests.cairo +++ b/packages/account/src/tests.cairo @@ -2,5 +2,6 @@ mod extensions; mod test_account; mod test_eth_account; +mod test_multisig_account; mod test_secp256_point; mod test_signature; diff --git a/packages/account/src/tests/test_multisig_account.cairo b/packages/account/src/tests/test_multisig_account.cairo new file mode 100644 index 000000000..1f4882677 --- /dev/null +++ b/packages/account/src/tests/test_multisig_account.cairo @@ -0,0 +1,774 @@ +use core::num::traits::{Bounded, Zero}; +use openzeppelin_interfaces::accounts::{ + ISRC6, ISRC6_ID, MultisigAccountABIDispatcher, MultisigAccountABIDispatcherTrait, +}; +use openzeppelin_interfaces::introspection::ISRC5_ID; +use openzeppelin_test_common::mocks::multisig_account::{ + ISignatureCallerMockDispatcher, ISignatureCallerMockDispatcherTrait, MultisigAccountMock, +}; +use openzeppelin_test_common::mocks::simple::{ISimpleMockDispatcher, ISimpleMockDispatcherTrait}; +use openzeppelin_test_common::multisig_account::get_multisig_signature; +use openzeppelin_testing as utils; +use openzeppelin_testing::constants::stark::{KEY_PAIR, KEY_PAIR_2}; +use openzeppelin_testing::constants::{ + CALLER, MIN_TRANSACTION_VERSION, OTHER, QUERY_OFFSET, QUERY_VERSION, SALT, TRANSACTION_HASH, + ZERO, +}; +use openzeppelin_testing::signing::{StarkKeyPair, get_stark_keys_from}; +use openzeppelin_testing::{EventSpyExt, EventSpyQueue as EventSpy, ExpectedEvent, spy_events}; +use snforge_std::{ + start_cheat_caller_address, start_cheat_signature_global, start_cheat_transaction_hash_global, + start_cheat_transaction_version_global, test_address, +}; +use starknet::ContractAddress; +use starknet::account::Call; +use starknet::storage_access::StorePacking; +use crate::MultisigAccountComponent; +use crate::MultisigAccountComponent::{ + InternalTrait, MultisigImpl, SIGNATURE_VERSION, SRC6CamelOnlyImpl, +}; +use crate::multisig_account::storage_utils::{SignersInfo, SignersInfoStorePacking}; + +type ComponentState = MultisigAccountComponent::ComponentState; + +fn COMPONENT_STATE() -> ComponentState { + MultisigAccountComponent::component_state_for_testing() +} + +fn KEY_PAIR_3() -> StarkKeyPair { + get_stark_keys_from('PRIVATE_KEY_3') +} + +fn DEFAULT_KEY_PAIRS() -> Array { + array![KEY_PAIR(), KEY_PAIR_2(), KEY_PAIR_3()] +} + +fn public_keys(key_pairs: Span) -> Array { + let mut result = array![]; + for key_pair in key_pairs { + result.append((*key_pair).public_key); + } + result +} + +fn setup_component(quorum: u32, key_pairs: Span) -> ComponentState { + let mut state = COMPONENT_STATE(); + let signers = public_keys(key_pairs); + state.initializer(quorum, signers.span()); + state +} + +fn replace_at( + values: Span, index_to_replace: u32, replacement: felt252, +) -> Array { + let mut result = array![]; + for index in 0..values.len() { + result.append(if index == index_to_replace { + replacement + } else { + *values.at(index) + }); + } + result +} + +fn constructor_calldata(quorum: u32, signers: Span) -> Array { + let mut calldata = array![quorum.into(), signers.len().into()]; + for signer in signers { + calldata.append(*signer); + } + calldata +} + +fn deploy_default_account() -> (ContractAddress, MultisigAccountABIDispatcher, felt252) { + let key_pairs = DEFAULT_KEY_PAIRS(); + let signers = public_keys(key_pairs.span()); + let contract_class = utils::declare_class("MultisigAccountMock"); + let account_address = utils::deploy(contract_class, constructor_calldata(2, signers.span())); + let dispatcher = MultisigAccountABIDispatcher { contract_address: account_address }; + (account_address, dispatcher, contract_class.class_hash.into()) +} + +fn start_valid_transaction_context(account_address: ContractAddress) { + let key_pairs = DEFAULT_KEY_PAIRS(); + let signing_key_pairs = array![*key_pairs.at(0), *key_pairs.at(1)]; + let signature = get_multisig_signature(TRANSACTION_HASH, signing_key_pairs.span()); + start_cheat_signature_global(signature.span()); + start_cheat_transaction_hash_global(TRANSACTION_HASH); + start_cheat_transaction_version_global(MIN_TRANSACTION_VERSION); + start_cheat_caller_address(account_address, ZERO); +} + +fn start_invalid_transaction_context(account_address: ContractAddress) { + start_valid_transaction_context(account_address); + start_cheat_transaction_hash_global(TRANSACTION_HASH + 1); +} + +fn assert_signers(state: @ComponentState, expected: Span) { + assert_eq!(state.get_signers(), expected); + for signer in expected { + assert!(state.is_signer(*signer)); + }; +} + +// +// Signature parsing and validation +// + +#[test] +fn test_signature_exact_quorum_and_all_signers() { + let key_pairs = DEFAULT_KEY_PAIRS(); + let state = setup_component(2, key_pairs.span()); + + let quorum_pairs = array![*key_pairs.at(2), *key_pairs.at(0)]; + let quorum_signature = get_multisig_signature(TRANSACTION_HASH, quorum_pairs.span()); + assert_eq!(state.is_valid_signature(TRANSACTION_HASH, quorum_signature), starknet::VALIDATED); + + let all_signature = get_multisig_signature(TRANSACTION_HASH, key_pairs.span()); + assert_eq!(state.is_valid_signature(TRANSACTION_HASH, all_signature), starknet::VALIDATED); +} + +#[test] +fn test_signature_camel_case() { + let key_pairs = DEFAULT_KEY_PAIRS(); + let state = setup_component(2, key_pairs.span()); + let signing_pairs = array![*key_pairs.at(0), *key_pairs.at(1)]; + let signature = get_multisig_signature(TRANSACTION_HASH, signing_pairs.span()); + + assert_eq!(state.isValidSignature(TRANSACTION_HASH, signature.clone()), starknet::VALIDATED); + + let (_, dispatcher, _) = deploy_default_account(); + assert_eq!( + dispatcher.isValidSignature(TRANSACTION_HASH, signature.clone()), starknet::VALIDATED, + ); + assert!(dispatcher.isValidSignature(TRANSACTION_HASH + 1, signature).is_zero()); +} + +#[test] +fn test_parser_rejects_short_and_malformed_lengths_without_panicking() { + let key_pairs = DEFAULT_KEY_PAIRS(); + let state = setup_component(2, key_pairs.span()); + + assert!(state.is_valid_signature(TRANSACTION_HASH, array![]).is_zero()); + assert!(state.is_valid_signature(TRANSACTION_HASH, array![SIGNATURE_VERSION]).is_zero()); + assert!( + state + .is_valid_signature(TRANSACTION_HASH, array![SIGNATURE_VERSION, 0, 'TRAILING_DATA']) + .is_zero(), + ); + assert!( + state + .is_valid_signature(TRANSACTION_HASH, array![SIGNATURE_VERSION, 0, 'TRAILING', 'DATA']) + .is_zero(), + ); +} + +#[test] +fn test_uninitialized_component_rejects_empty_signature_bundle() { + let state = COMPONENT_STATE(); + + assert!(state.is_valid_signature(TRANSACTION_HASH, array![SIGNATURE_VERSION, 0]).is_zero()); +} + +#[test] +fn test_parser_rejects_version_and_count_mismatch() { + let key_pairs = DEFAULT_KEY_PAIRS(); + let state = setup_component(2, key_pairs.span()); + let signing_pairs = array![*key_pairs.at(0), *key_pairs.at(1)]; + let signature = get_multisig_signature(TRANSACTION_HASH, signing_pairs.span()); + + let wrong_version = replace_at(signature.span(), 0, SIGNATURE_VERSION + 1); + assert!(state.is_valid_signature(TRANSACTION_HASH, wrong_version).is_zero()); + + let wrong_count = replace_at(signature.span(), 1, 3); + assert!(state.is_valid_signature(TRANSACTION_HASH, wrong_count).is_zero()); + + let unconvertible_count = replace_at(signature.span(), 1, -1); + assert!(state.is_valid_signature(TRANSACTION_HASH, unconvertible_count).is_zero()); +} + +#[test] +fn test_parser_rejects_below_quorum_and_above_registered_count() { + let key_pairs = DEFAULT_KEY_PAIRS(); + let state = setup_component(2, key_pairs.span()); + + let below_quorum_pairs = array![*key_pairs.at(0)]; + let below_quorum = get_multisig_signature(TRANSACTION_HASH, below_quorum_pairs.span()); + assert!(state.is_valid_signature(TRANSACTION_HASH, below_quorum).is_zero()); + assert!(state.is_valid_signature(TRANSACTION_HASH, array![SIGNATURE_VERSION, 0]).is_zero()); + + let too_many = array![SIGNATURE_VERSION, 4, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]; + assert!(state.is_valid_signature(TRANSACTION_HASH, too_many).is_zero()); +} + +#[test] +fn test_parser_rejects_unknown_zero_duplicate_and_descending_signers() { + let registered_pairs = array![KEY_PAIR(), KEY_PAIR_2()]; + let state = setup_component(2, registered_pairs.span()); + + let unknown_pairs = array![KEY_PAIR(), KEY_PAIR_3()]; + let unknown_signature = get_multisig_signature(TRANSACTION_HASH, unknown_pairs.span()); + assert!(state.is_valid_signature(TRANSACTION_HASH, unknown_signature).is_zero()); + + let valid_signature = get_multisig_signature(TRANSACTION_HASH, registered_pairs.span()); + let zero_signer = array![ + SIGNATURE_VERSION, 2, 0, *valid_signature.at(3), *valid_signature.at(4), + *valid_signature.at(5), *valid_signature.at(6), *valid_signature.at(7), + ]; + assert!(state.is_valid_signature(TRANSACTION_HASH, zero_signer).is_zero()); + + let duplicate_signer = array![ + SIGNATURE_VERSION, 2, *valid_signature.at(2), *valid_signature.at(3), + *valid_signature.at(4), *valid_signature.at(2), *valid_signature.at(3), + *valid_signature.at(4), + ]; + assert!(state.is_valid_signature(TRANSACTION_HASH, duplicate_signer).is_zero()); + + let descending = array![ + SIGNATURE_VERSION, 2, *valid_signature.at(5), *valid_signature.at(6), + *valid_signature.at(7), *valid_signature.at(2), *valid_signature.at(3), + *valid_signature.at(4), + ]; + assert!(state.is_valid_signature(TRANSACTION_HASH, descending).is_zero()); +} + +#[test] +fn test_parser_rejects_wrong_hash_and_invalid_signature_parts() { + let key_pairs = array![KEY_PAIR(), KEY_PAIR_2()]; + let state = setup_component(2, key_pairs.span()); + let signature = get_multisig_signature(TRANSACTION_HASH, key_pairs.span()); + + assert!(state.is_valid_signature(TRANSACTION_HASH + 1, signature).is_zero()); + + let signature = get_multisig_signature(TRANSACTION_HASH, key_pairs.span()); + let bad_r = replace_at(signature.span(), 3, 0); + assert!(state.is_valid_signature(TRANSACTION_HASH, bad_r).is_zero()); + + let signature = get_multisig_signature(TRANSACTION_HASH, key_pairs.span()); + let bad_s = replace_at(signature.span(), 4, 0); + assert!(state.is_valid_signature(TRANSACTION_HASH, bad_s).is_zero()); +} + +#[test] +fn test_parser_checks_invalid_extra_signature_after_quorum() { + let key_pairs = array![KEY_PAIR(), KEY_PAIR_2()]; + let state = setup_component(1, key_pairs.span()); + let signature = get_multisig_signature(TRANSACTION_HASH, key_pairs.span()); + let invalid_extra = replace_at(signature.span(), 6, 0); + + assert!(state.is_valid_signature(TRANSACTION_HASH, invalid_extra).is_zero()); +} + +#[test] +fn test_removed_signer_signature_is_invalid() { + let key_pairs = array![KEY_PAIR(), KEY_PAIR_2()]; + let mut state = setup_component(1, key_pairs.span()); + let removed_pair = array![KEY_PAIR_2()]; + let signature = get_multisig_signature(TRANSACTION_HASH, removed_pair.span()); + assert_eq!(state.is_valid_signature(TRANSACTION_HASH, signature), starknet::VALIDATED); + + start_cheat_caller_address(test_address(), test_address()); + state.remove_signers(1, array![KEY_PAIR_2().public_key].span()); + + let signature = get_multisig_signature(TRANSACTION_HASH, removed_pair.span()); + assert!(state.is_valid_signature(TRANSACTION_HASH, signature).is_zero()); +} + +#[test] +fn test_is_valid_signature_from_nonzero_contract_caller() { + let (account_address, _, _) = deploy_default_account(); + let caller_address = utils::declare_and_deploy("SignatureCallerMock", array![]); + let caller = ISignatureCallerMockDispatcher { contract_address: caller_address }; + let key_pairs = DEFAULT_KEY_PAIRS(); + let signing_pairs = array![*key_pairs.at(0), *key_pairs.at(1)]; + let signature = get_multisig_signature(TRANSACTION_HASH, signing_pairs.span()); + + assert_eq!( + caller.call_is_valid_signature(account_address, TRANSACTION_HASH, signature), + starknet::VALIDATED, + ); +} + +#[test] +fn test_starknet_js_golden_invoke_v3_signature() { + let signers = array![ + 0x566d69d8c99f62bc71118399bab25c1f03719463eab8d6a444cd11ece131616, + 0x5f679dacd8278105bd3b84a15548fe84079068276b0e84d6cc093eb5430f063, + 0x6509ed71b354b7125d61b6f3423cbfa33dd7a6b21f0878399a5713118f9e68e, + ]; + let mut state = COMPONENT_STATE(); + state.initializer(2, signers.span()); + let transaction_hash = 0x211c9566f7eeeb9317a7b9f64596495e7fbc5de41e1749f1068b25014d6f507; + let signature = array![ + 0x1, 0x2, 0x566d69d8c99f62bc71118399bab25c1f03719463eab8d6a444cd11ece131616, + 0x25c78db95a6980b009041b699f4c299f5679f9108765ed984b4e3db08c3a4b6, + 0x5be0428bb190616d6a5d7717ce7dfc1f74133e712879b9c6e49f5940c49566, + 0x5f679dacd8278105bd3b84a15548fe84079068276b0e84d6cc093eb5430f063, + 0x28a401c904d0a95182968cc77a4f0ce104c2dfd917ff04412441beb2ecb296c, + 0xd6e918bb93355eea954d1b11295a0bbfb615b4df8244c85008fcd378770aa1, + ]; + + assert_eq!(state.is_valid_signature(transaction_hash, signature), starknet::VALIDATED); +} + +// +// Initialization and signer management +// + +#[test] +fn test_initializer_getters_events_and_interfaces() { + let mut state = COMPONENT_STATE(); + let key_pairs = DEFAULT_KEY_PAIRS(); + let signers = public_keys(key_pairs.span()); + let mut spy = spy_events(); + + state.initializer(2, signers.span()); + + assert_eq!(state.get_quorum(), 2); + assert_signers(@state, signers.span()); + spy.assert_event_signer_added(test_address(), *signers.at(0)); + spy.assert_event_signer_added(test_address(), *signers.at(1)); + spy.assert_event_signer_added(test_address(), *signers.at(2)); + spy.assert_event_quorum_updated(test_address(), 0, 2); + spy.assert_no_events_left_from(test_address()); + + let (_, dispatcher, _) = deploy_default_account(); + assert!(dispatcher.supports_interface(ISRC5_ID)); + assert!(dispatcher.supports_interface(ISRC6_ID)); + assert!(!dispatcher.supports_interface('DUMMY_INTERFACE')); +} + +#[test] +fn test_initializer_skips_duplicate_signers() { + let mut state = COMPONENT_STATE(); + let first = KEY_PAIR().public_key; + let second = KEY_PAIR_2().public_key; + let signers = array![first, first, second]; + let mut spy = spy_events(); + + state.initializer(2, signers.span()); + + assert_signers(@state, array![first, second].span()); + spy.assert_event_signer_added(test_address(), first); + spy.assert_event_signer_added(test_address(), second); + spy.assert_event_quorum_updated(test_address(), 0, 2); + spy.assert_no_events_left_from(test_address()); +} + +#[test] +#[should_panic(expected: 'MultisigAccount: zero quorum')] +fn test_initializer_rejects_zero_quorum() { + setup_component(0, array![KEY_PAIR()].span()); +} + +#[test] +#[should_panic(expected: 'MultisigAccount: high quorum')] +fn test_initializer_rejects_quorum_above_unique_signer_count() { + setup_component(2, array![KEY_PAIR()].span()); +} + +#[test] +#[should_panic(expected: 'MultisigAccount: zero signer')] +fn test_initializer_rejects_zero_signer() { + let mut state = COMPONENT_STATE(); + state.initializer(1, array![KEY_PAIR().public_key, 0].span()); +} + +#[test] +fn test_add_signers_duplicate_empty_and_quorum_branches() { + let initial_pairs = array![KEY_PAIR(), KEY_PAIR_2()]; + let mut state = setup_component(1, initial_pairs.span()); + start_cheat_caller_address(test_address(), test_address()); + let new_signer = KEY_PAIR_3().public_key; + let mut spy = spy_events(); + + state.add_signers(2, array![new_signer, new_signer, KEY_PAIR().public_key].span()); + + assert_signers( + @state, array![KEY_PAIR().public_key, KEY_PAIR_2().public_key, new_signer].span(), + ); + spy.assert_event_signer_added(test_address(), new_signer); + spy.assert_event_quorum_updated(test_address(), 1, 2); + spy.assert_no_events_left_from(test_address()); + + state.add_signers(3, array![].span()); + assert_eq!(state.get_quorum(), 3); + spy.assert_only_event_quorum_updated(test_address(), 2, 3); +} + +#[test] +#[should_panic(expected: 'MultisigAccount: unauthorized')] +fn test_add_signers_rejects_non_self_caller() { + let mut state = setup_component(1, array![KEY_PAIR()].span()); + start_cheat_caller_address(test_address(), OTHER); + state.add_signers(1, array![KEY_PAIR_2().public_key].span()); +} + +#[test] +#[should_panic(expected: 'MultisigAccount: zero signer')] +fn test_add_signers_rejects_zero_signer() { + let mut state = setup_component(1, array![KEY_PAIR()].span()); + start_cheat_caller_address(test_address(), test_address()); + state.add_signers(1, array![0].span()); +} + +#[test] +#[should_panic(expected: 'MultisigAccount: high quorum')] +fn test_add_signers_rejects_high_resulting_quorum() { + let mut state = setup_component(1, array![KEY_PAIR()].span()); + start_cheat_caller_address(test_address(), test_address()); + state.add_signers(3, array![KEY_PAIR_2().public_key].span()); +} + +#[test] +fn test_remove_signers_swap_last_unknown_empty_and_quorum_branches() { + let key_pairs = DEFAULT_KEY_PAIRS(); + let signers = public_keys(key_pairs.span()); + let mut state = setup_component(2, key_pairs.span()); + start_cheat_caller_address(test_address(), test_address()); + let mut spy = spy_events(); + + state.remove_signers(2, array![*signers.at(1), 'UNKNOWN_SIGNER'].span()); + assert_signers(@state, array![*signers.at(0), *signers.at(2)].span()); + assert!(!state.is_signer(*signers.at(1))); + spy.assert_only_event_signer_removed(test_address(), *signers.at(1)); + + state.remove_signers(1, array![*signers.at(2)].span()); + assert_signers(@state, array![*signers.at(0)].span()); + spy.assert_event_signer_removed(test_address(), *signers.at(2)); + spy.assert_event_quorum_updated(test_address(), 2, 1); + spy.assert_no_events_left_from(test_address()); + + state.remove_signers(1, array![].span()); + spy.assert_no_events_left_from(test_address()); +} + +#[test] +fn test_empty_remove_can_change_quorum() { + let key_pairs = array![KEY_PAIR(), KEY_PAIR_2()]; + let mut state = setup_component(2, key_pairs.span()); + start_cheat_caller_address(test_address(), test_address()); + let mut spy = spy_events(); + + state.remove_signers(1, array![].span()); + + assert_eq!(state.get_quorum(), 1); + spy.assert_only_event_quorum_updated(test_address(), 2, 1); +} + +#[test] +#[should_panic(expected: 'MultisigAccount: unauthorized')] +fn test_remove_signers_rejects_non_self_caller() { + let mut state = setup_component(1, array![KEY_PAIR()].span()); + start_cheat_caller_address(test_address(), OTHER); + state.remove_signers(1, array![KEY_PAIR().public_key].span()); +} + +#[test] +#[should_panic(expected: 'MultisigAccount: high quorum')] +fn test_remove_signers_rejects_high_resulting_quorum() { + let key_pairs = array![KEY_PAIR(), KEY_PAIR_2()]; + let mut state = setup_component(2, key_pairs.span()); + start_cheat_caller_address(test_address(), test_address()); + state.remove_signers(2, array![KEY_PAIR_2().public_key].span()); +} + +#[test] +#[should_panic(expected: 'MultisigAccount: zero quorum')] +fn test_remove_signers_rejects_zero_quorum() { + let key_pairs = array![KEY_PAIR(), KEY_PAIR_2()]; + let mut state = setup_component(1, key_pairs.span()); + start_cheat_caller_address(test_address(), test_address()); + state.remove_signers(0, array![KEY_PAIR_2().public_key].span()); +} + +#[test] +fn test_replace_signer_updates_index_membership_and_events() { + let key_pairs = array![KEY_PAIR(), KEY_PAIR_2()]; + let mut state = setup_component(2, key_pairs.span()); + start_cheat_caller_address(test_address(), test_address()); + let old_signer = KEY_PAIR().public_key; + let new_signer = KEY_PAIR_3().public_key; + let mut spy = spy_events(); + + state.replace_signer(old_signer, new_signer); + + assert_signers(@state, array![new_signer, KEY_PAIR_2().public_key].span()); + assert!(!state.is_signer(old_signer)); + spy.assert_event_signer_removed(test_address(), old_signer); + spy.assert_event_signer_added(test_address(), new_signer); + spy.assert_no_events_left_from(test_address()); +} + +#[test] +#[should_panic(expected: 'MultisigAccount: zero signer')] +fn test_replace_signer_rejects_zero_new_signer() { + let mut state = setup_component(1, array![KEY_PAIR()].span()); + start_cheat_caller_address(test_address(), test_address()); + state.replace_signer(KEY_PAIR().public_key, 0); +} + +#[test] +#[should_panic(expected: 'MultisigAccount: already signer')] +fn test_replace_signer_rejects_registered_new_signer() { + let key_pairs = array![KEY_PAIR(), KEY_PAIR_2()]; + let mut state = setup_component(1, key_pairs.span()); + start_cheat_caller_address(test_address(), test_address()); + state.replace_signer(KEY_PAIR().public_key, KEY_PAIR_2().public_key); +} + +#[test] +#[should_panic(expected: 'MultisigAccount: not signer')] +fn test_replace_signer_rejects_unregistered_old_signer() { + let mut state = setup_component(1, array![KEY_PAIR()].span()); + start_cheat_caller_address(test_address(), test_address()); + state.replace_signer(KEY_PAIR_2().public_key, KEY_PAIR_3().public_key); +} + +#[test] +#[should_panic(expected: 'MultisigAccount: unauthorized')] +fn test_replace_signer_rejects_non_self_caller() { + let mut state = setup_component(1, array![KEY_PAIR()].span()); + start_cheat_caller_address(test_address(), CALLER); + state.replace_signer(KEY_PAIR().public_key, KEY_PAIR_2().public_key); +} + +#[test] +fn test_change_quorum_and_same_value_event_branch() { + let key_pairs = array![KEY_PAIR(), KEY_PAIR_2()]; + let mut state = setup_component(1, key_pairs.span()); + start_cheat_caller_address(test_address(), test_address()); + let mut spy = spy_events(); + + state.change_quorum(2); + assert_eq!(state.get_quorum(), 2); + spy.assert_only_event_quorum_updated(test_address(), 1, 2); + + state.change_quorum(2); + spy.assert_no_events_left_from(test_address()); +} + +#[test] +#[should_panic(expected: 'MultisigAccount: unauthorized')] +fn test_change_quorum_rejects_non_self_caller() { + let mut state = setup_component(1, array![KEY_PAIR()].span()); + start_cheat_caller_address(test_address(), OTHER); + state.change_quorum(1); +} + +#[test] +#[should_panic(expected: 'MultisigAccount: zero quorum')] +fn test_change_quorum_rejects_zero() { + let mut state = setup_component(1, array![KEY_PAIR()].span()); + start_cheat_caller_address(test_address(), test_address()); + state.change_quorum(0); +} + +#[test] +#[should_panic(expected: 'MultisigAccount: high quorum')] +fn test_change_quorum_rejects_value_above_signer_count() { + let mut state = setup_component(1, array![KEY_PAIR()].span()); + start_cheat_caller_address(test_address(), test_address()); + state.change_quorum(2); +} + +#[test] +fn test_mixin_dispatches_all_signer_management_methods() { + let (account_address, account, _) = deploy_default_account(); + let original_signers = account.get_signers(); + let added_signer = 'ADDED_SIGNER'; + let replacement_signer = 'REPLACEMENT_SIGNER'; + start_cheat_caller_address(account_address, account_address); + + account.add_signers(3, array![added_signer].span()); + assert_eq!(account.get_quorum(), 3); + assert!(account.is_signer(added_signer)); + + account.replace_signer(*original_signers.at(0), replacement_signer); + assert!(!account.is_signer(*original_signers.at(0))); + assert!(account.is_signer(replacement_signer)); + + account.remove_signers(2, array![*original_signers.at(1)].span()); + assert!(!account.is_signer(*original_signers.at(1))); + assert_eq!(account.get_signers().len(), 3); + + account.change_quorum(1); + assert_eq!(account.get_quorum(), 1); + assert!(account.supports_interface(ISRC6_ID)); +} + +// +// Account protocol entrypoints and execution +// + +#[test] +fn test_validate_invoke_declare_and_deploy() { + let (account_address, account, class_hash) = deploy_default_account(); + start_valid_transaction_context(account_address); + let key_pairs = DEFAULT_KEY_PAIRS(); + let signers = public_keys(key_pairs.span()); + + assert_eq!(account.__validate__(array![]), starknet::VALIDATED); + assert_eq!(account.__validate_declare__(class_hash), starknet::VALIDATED); + assert_eq!( + account.__validate_deploy__(class_hash, SALT, 2, signers.span()), starknet::VALIDATED, + ); +} + +#[test] +#[should_panic(expected: 'MultisigAccount: invalid sig')] +fn test_validate_invoke_rejects_invalid_signature() { + let (account_address, account, _) = deploy_default_account(); + start_invalid_transaction_context(account_address); + account.__validate__(array![]); +} + +#[test] +#[should_panic(expected: 'MultisigAccount: invalid sig')] +fn test_validate_declare_rejects_invalid_signature() { + let (account_address, account, class_hash) = deploy_default_account(); + start_invalid_transaction_context(account_address); + account.__validate_declare__(class_hash); +} + +#[test] +#[should_panic(expected: 'MultisigAccount: invalid sig')] +fn test_validate_deploy_rejects_invalid_signature() { + let (account_address, account, class_hash) = deploy_default_account(); + start_invalid_transaction_context(account_address); + let key_pairs = DEFAULT_KEY_PAIRS(); + let signers = public_keys(key_pairs.span()); + account.__validate_deploy__(class_hash, SALT, 2, signers.span()); +} + +fn execute_increase_with_version(version: felt252) { + let (account_address, account, _) = deploy_default_account(); + start_cheat_caller_address(account_address, ZERO); + start_cheat_transaction_version_global(version); + let target_address = utils::declare_and_deploy("SimpleMock", array![]); + let target = ISimpleMockDispatcher { contract_address: target_address }; + let call = Call { + to: target_address, selector: selector!("increase_balance"), calldata: array![200].span(), + }; + + account.__execute__(array![call]); + assert_eq!(target.get_balance(), 200); +} + +#[test] +fn test_execute_supported_current_future_and_query_versions() { + execute_increase_with_version(MIN_TRANSACTION_VERSION); + execute_increase_with_version(MIN_TRANSACTION_VERSION + 1); + execute_increase_with_version(QUERY_VERSION); + execute_increase_with_version(QUERY_VERSION + 1); +} + +#[test] +fn test_execute_multicall() { + let (account_address, account, _) = deploy_default_account(); + start_cheat_caller_address(account_address, ZERO); + start_cheat_transaction_version_global(MIN_TRANSACTION_VERSION); + let target_address = utils::declare_and_deploy("SimpleMock", array![]); + let target = ISimpleMockDispatcher { contract_address: target_address }; + let first = Call { + to: target_address, selector: selector!("increase_balance"), calldata: array![300].span(), + }; + let second = Call { + to: target_address, selector: selector!("increase_balance"), calldata: array![500].span(), + }; + + account.__execute__(array![first, second]); + + assert_eq!(target.get_balance(), 800); +} + +#[test] +#[should_panic(expected: 'MultisigAccount: bad tx version')] +fn test_execute_rejects_version_zero() { + execute_increase_with_version(MIN_TRANSACTION_VERSION - 1); +} + +#[test] +#[should_panic(expected: 'MultisigAccount: bad tx version')] +fn test_execute_rejects_bare_query_offset() { + execute_increase_with_version(QUERY_OFFSET); +} + +#[test] +#[should_panic(expected: 'MultisigAccount: invalid caller')] +fn test_execute_rejects_nonzero_caller() { + let (account_address, account, _) = deploy_default_account(); + start_cheat_caller_address(account_address, CALLER); + start_cheat_transaction_version_global(MIN_TRANSACTION_VERSION); + account.__execute__(array![]); +} + +// +// Storage packing +// + +#[test] +fn test_signers_info_packing_zero_and_known_value() { + let unpacked_zero: SignersInfo = StorePacking::unpack(0); + assert_eq!(unpacked_zero.quorum, 0); + assert_eq!(unpacked_zero.signers_count, 0); + + let info = SignersInfo { quorum: 2, signers_count: 3 }; + let packed = StorePacking::pack(info); + assert_eq!(packed, 0x200000003); + let unpacked: SignersInfo = StorePacking::unpack(packed); + assert_eq!(unpacked.quorum, 2); + assert_eq!(unpacked.signers_count, 3); +} + +#[test] +fn test_signers_info_packing_u32_boundaries() { + let max: u32 = Bounded::::MAX; + let info = SignersInfo { quorum: max, signers_count: max }; + let packed = StorePacking::pack(info); + let unpacked: SignersInfo = StorePacking::unpack(packed); + + assert_eq!(unpacked.quorum, max); + assert_eq!(unpacked.signers_count, max); +} + +// +// Event helpers +// + +#[generate_trait] +impl MultisigAccountSpyHelpersImpl of MultisigAccountSpyHelpers { + fn assert_event_signer_added(ref self: EventSpy, contract: ContractAddress, signer: felt252) { + let expected = ExpectedEvent::new().key(selector!("SignerAdded")).key(signer); + self.assert_emitted_single(contract, expected); + } + + fn assert_event_signer_removed(ref self: EventSpy, contract: ContractAddress, signer: felt252) { + let expected = ExpectedEvent::new().key(selector!("SignerRemoved")).key(signer); + self.assert_emitted_single(contract, expected); + } + + fn assert_event_quorum_updated( + ref self: EventSpy, contract: ContractAddress, old_quorum: u32, new_quorum: u32, + ) { + let expected = ExpectedEvent::new() + .key(selector!("QuorumUpdated")) + .data(old_quorum) + .data(new_quorum); + self.assert_emitted_single(contract, expected); + } + + fn assert_only_event_signer_removed( + ref self: EventSpy, contract: ContractAddress, signer: felt252, + ) { + self.assert_event_signer_removed(contract, signer); + self.assert_no_events_left_from(contract); + } + + fn assert_only_event_quorum_updated( + ref self: EventSpy, contract: ContractAddress, old_quorum: u32, new_quorum: u32, + ) { + self.assert_event_quorum_updated(contract, old_quorum, new_quorum); + self.assert_no_events_left_from(contract); + } +} diff --git a/packages/interfaces/CHANGELOG.md b/packages/interfaces/CHANGELOG.md index cb00dc02b..dcc4763c7 100644 --- a/packages/interfaces/CHANGELOG.md +++ b/packages/interfaces/CHANGELOG.md @@ -7,6 +7,13 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## Unreleased + +### Added + +- `IMultisigAccount`, `IMultisigDeployable`, and `MultisigAccountABI` for quorum-based + STARK-curve accounts + ## 2.1.0 (2025-12-11) ### Added @@ -19,4 +26,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Moved interfaces, ABIs and dispatchers into `openzeppelin_interfaces` (#1463) - - Some structs and types that were defined inside interface files were also moved \ No newline at end of file + - Some structs and types that were defined inside interface files were also moved diff --git a/packages/interfaces/README.md b/packages/interfaces/README.md index 024264ed5..3366a807a 100644 --- a/packages/interfaces/README.md +++ b/packages/interfaces/README.md @@ -1,8 +1,8 @@ ## Interfaces -List of standardized interfaces, ABI traits and dispatchers. +Interfaces, ABI traits, and dispatchers used across OpenZeppelin Contracts for Cairo. -### Standardized Interfaces +### Interface Traits - [`IAccessControlDefaultAdminRules`](https://docs.openzeppelin.com/contracts-cairo/3.x/api/access#IAccessControlDefaultAdminRules) - [`IAccessControl`](https://docs.openzeppelin.com/contracts-cairo/3.x/api/access#IAccessControl) @@ -16,6 +16,8 @@ List of standardized interfaces, ABI traits and dispatchers. - `ISRC6CamelOnly` - `IDeclarer` - `IDeployable` +- [`IMultisigAccount`](https://docs.openzeppelin.com/contracts-cairo/3.x/api/account#IMultisigAccount) +- [`IMultisigDeployable`](https://docs.openzeppelin.com/contracts-cairo/3.x/api/account#IMultisigDeployable) - `IPublicKey` - `IPublicKeyCamel` - `IEthDeployable` @@ -73,6 +75,7 @@ List of standardized interfaces, ABI traits and dispatchers. - `OwnableABI` - `OwnableTwoStepABI` - `AccountABI` +- `MultisigAccountABI` - `EthAccountABI` - `TimelockABI` - `VotesABI` diff --git a/packages/interfaces/src/account/accounts.cairo b/packages/interfaces/src/account/accounts.cairo index d5d2cbc22..f9416fe78 100644 --- a/packages/interfaces/src/account/accounts.cairo +++ b/packages/interfaces/src/account/accounts.cairo @@ -107,6 +107,90 @@ pub trait AccountABI { fn setPublicKey(ref self: TState, newPublicKey: felt252, signature: Span); } +// +// MultisigAccount +// + +/// Interface for managing the signer set and quorum of a multisig account. +#[starknet::interface] +pub trait IMultisigAccount { + /// Returns the minimum number of signer records required to authorize an operation. + fn get_quorum(self: @TState) -> u32; + + /// Returns whether `signer` is registered as a signer public key. + fn is_signer(self: @TState, signer: felt252) -> bool; + + /// Returns all registered signer public keys in registry order. + /// Registry order is not sorted and can change when signers are removed. + fn get_signers(self: @TState) -> Span; + + /// Adds signer public keys and sets the quorum to `new_quorum`. + /// Already registered public keys are ignored. + fn add_signers(ref self: TState, new_quorum: u32, signers_to_add: Span); + + /// Removes signer public keys and sets the quorum to `new_quorum`. + /// Unregistered public keys are ignored. Removing signers can change registry order. + fn remove_signers(ref self: TState, new_quorum: u32, signers_to_remove: Span); + + /// Replaces one signer public key with another. + fn replace_signer(ref self: TState, signer_to_remove: felt252, signer_to_add: felt252); + + /// Sets the number of signer records required to authorize an operation. + fn change_quorum(ref self: TState, new_quorum: u32); +} + +/// Validates a transaction before deploying a multisig account. +#[starknet::interface] +pub trait IMultisigDeployable { + /// This function is used by the protocol to verify `deploy_account` transactions whose + /// constructor configures a signer set and quorum. + /// + /// Returns the short string 'VALID' if valid, otherwise it reverts. + fn __validate_deploy__( + self: @TState, + class_hash: felt252, + contract_address_salt: felt252, + quorum: u32, + signers: Span, + ) -> felt252; +} + +/// Complete ABI for a Stark-curve multisig account. +#[starknet::interface] +pub trait MultisigAccountABI { + // ISRC6 + fn __execute__(self: @TState, calls: Array); + fn __validate__(self: @TState, calls: Array) -> felt252; + fn is_valid_signature(self: @TState, hash: felt252, signature: Array) -> felt252; + + // ISRC5 + fn supports_interface(self: @TState, interface_id: felt252) -> bool; + + // IDeclarer + fn __validate_declare__(self: @TState, class_hash: felt252) -> felt252; + + // IMultisigDeployable + fn __validate_deploy__( + self: @TState, + class_hash: felt252, + contract_address_salt: felt252, + quorum: u32, + signers: Span, + ) -> felt252; + + // IMultisigAccount + fn get_quorum(self: @TState) -> u32; + fn is_signer(self: @TState, signer: felt252) -> bool; + fn get_signers(self: @TState) -> Span; + fn add_signers(ref self: TState, new_quorum: u32, signers_to_add: Span); + fn remove_signers(ref self: TState, new_quorum: u32, signers_to_remove: Span); + fn replace_signer(ref self: TState, signer_to_remove: felt252, signer_to_add: felt252); + fn change_quorum(ref self: TState, new_quorum: u32); + + // ISRC6CamelOnly + fn isValidSignature(self: @TState, hash: felt252, signature: Array) -> felt252; +} + // // EthAccount // diff --git a/packages/macros/src/attribute/with_components/components.rs b/packages/macros/src/attribute/with_components/components.rs index ca2fb7461..2b811157f 100644 --- a/packages/macros/src/attribute/with_components/components.rs +++ b/packages/macros/src/attribute/with_components/components.rs @@ -4,6 +4,7 @@ use cairo_lang_macro::Diagnostic; /// The list of allowed components for the `with_components` attribute. pub enum AllowedComponents { Account, + MultisigAccount, EthAccount, SRC9, AccessControl, @@ -51,6 +52,7 @@ impl AllowedComponents { pub fn from_str(s: &str) -> Result { match s { "Account" => Ok(AllowedComponents::Account), + "MultisigAccount" => Ok(AllowedComponents::MultisigAccount), "EthAccount" => Ok(AllowedComponents::EthAccount), "SRC9" => Ok(AllowedComponents::SRC9), "AccessControl" => Ok(AllowedComponents::AccessControl), @@ -109,6 +111,15 @@ impl AllowedComponents { has_immutable_config: false, internal_impls: vec!["InternalImpl"], }, + AllowedComponents::MultisigAccount => ComponentInfo { + name: "MultisigAccountComponent", + path: "openzeppelin_account::MultisigAccountComponent", + storage: "multisig_account", + event: "MultisigAccountEvent", + has_initializer: true, + has_immutable_config: false, + internal_impls: vec!["InternalImpl"], + }, AllowedComponents::EthAccount => ComponentInfo { name: "EthAccountComponent", path: "openzeppelin_account::EthAccountComponent", diff --git a/packages/macros/src/tests/snapshots/openzeppelin_macros__tests__test_with_components__with_multisig_account.snap b/packages/macros/src/tests/snapshots/openzeppelin_macros__tests__test_with_components__with_multisig_account.snap new file mode 100644 index 000000000..60392425c --- /dev/null +++ b/packages/macros/src/tests/snapshots/openzeppelin_macros__tests__test_with_components__with_multisig_account.snap @@ -0,0 +1,41 @@ +--- +source: src/tests/test_with_components.rs +expression: result +--- +TokenStream: + +#[starknet::contract(account)] +pub mod MyContract { + #[storage] + pub struct Storage { + #[substorage(v0)] + pub multisig_account: MultisigAccountComponent::Storage, + } + #[constructor] + fn constructor(ref self: ContractState, quorum: u32, signers: Span) { + self.multisig_account.initializer(quorum, signers); + } + use openzeppelin_account::MultisigAccountComponent; + + component!( + path: MultisigAccountComponent, storage: multisig_account, event: MultisigAccountEvent, + ); + + impl MultisigAccountInternalImpl = MultisigAccountComponent::InternalImpl; + + #[event] + #[derive(Drop, starknet::Event)] + enum Event { + #[flat] + MultisigAccountEvent: MultisigAccountComponent::Event, + } +} + + +Diagnostics: + +None + +AuxData: + +None diff --git a/packages/macros/src/tests/snapshots/openzeppelin_macros__tests__test_with_components__with_multisig_account_no_initializer.snap b/packages/macros/src/tests/snapshots/openzeppelin_macros__tests__test_with_components__with_multisig_account_no_initializer.snap new file mode 100644 index 000000000..26febedeb --- /dev/null +++ b/packages/macros/src/tests/snapshots/openzeppelin_macros__tests__test_with_components__with_multisig_account_no_initializer.snap @@ -0,0 +1,44 @@ +--- +source: src/tests/test_with_components.rs +expression: result +--- +TokenStream: + +#[starknet::contract(account)] +pub mod MyContract { + #[storage] + pub struct Storage { + #[substorage(v0)] + pub multisig_account: MultisigAccountComponent::Storage, + } + use openzeppelin_account::MultisigAccountComponent; + + component!( + path: MultisigAccountComponent, storage: multisig_account, event: MultisigAccountEvent, + ); + + impl MultisigAccountInternalImpl = MultisigAccountComponent::InternalImpl; + + #[event] + #[derive(Drop, starknet::Event)] + enum Event { + #[flat] + MultisigAccountEvent: MultisigAccountComponent::Event, + } +} + + +Diagnostics: + +==== +Warning: It looks like the initializers for the following components are missing: + +MultisigAccount + +This may lead to unexpected behavior. +We recommend adding the corresponding initializer calls to the constructor. +==== + +AuxData: + +None diff --git a/packages/macros/src/tests/test_with_components.rs b/packages/macros/src/tests/test_with_components.rs index 4d8d10f37..f67e0195c 100644 --- a/packages/macros/src/tests/test_with_components.rs +++ b/packages/macros/src/tests/test_with_components.rs @@ -37,6 +37,39 @@ fn test_with_account_no_initializer() { assert_snapshot!(result); } +#[test] +fn test_with_multisig_account() { + let attribute = quote! { (MultisigAccount) }; + let item = quote! { + #[starknet::contract(account)] + pub mod MyContract { + #[storage] + pub struct Storage {} + + #[constructor] + fn constructor(ref self: ContractState, quorum: u32, signers: Span) { + self.multisig_account.initializer(quorum, signers); + } + } + }; + let result = get_string_result(attribute, item); + assert_snapshot!(result); +} + +#[test] +fn test_with_multisig_account_no_initializer() { + let attribute = quote! { (MultisigAccount) }; + let item = quote! { + #[starknet::contract(account)] + pub mod MyContract { + #[storage] + pub struct Storage {} + } + }; + let result = get_string_result(attribute, item); + assert_snapshot!(result); +} + #[test] fn test_with_eth_account() { let attribute = quote! { (EthAccount) }; diff --git a/packages/presets/README.md b/packages/presets/README.md index 9b8a4a78a..4379bfb7f 100644 --- a/packages/presets/README.md +++ b/packages/presets/README.md @@ -2,11 +2,17 @@ > **NOTE:** This document is better viewed at [https://docs.openzeppelin.com/contracts-cairo/presets](https://docs.openzeppelin.com/contracts-cairo/3.x/presets) -Presets are ready-to-deploy contracts provided by the library. Since presets are intended to be very simple and as -generic as possible, there’s no support for custom or complex contracts such as `ERC20Pausable` or `ERC721Mintable`. +Presets are ready-to-deploy contracts that combine widely used components into simple, +general-purpose configurations. -For contract customization and combination of modules you can use -[Wizard for Cairo](https://wizard.openzeppelin.com/cairo), our code-generation tool. +Use [Wizard for Cairo](https://wizard.openzeppelin.com/cairo), our code-generation tool, to build +custom combinations of components. + +`MultisigAccountUpgradeable` provides quorum-based STARK-curve authorization through SRC6, +outside execution through SRC9, and self-authorized class upgrades. The current signer quorum +authorizes signer, quorum, and implementation changes through account self-calls. Its signatures +use the canonical `[1, n, public_key_1, r_1, s_1, ..., public_key_n, r_n, s_n]` encoding described +in the [account API](https://docs.openzeppelin.com/contracts-cairo/3.x/api/account#MultisigAccountComponent). ### Presets @@ -15,4 +21,5 @@ For contract customization and combination of modules you can use - [`ERC721Upgradeable`](https://docs.openzeppelin.com/contracts-cairo/3.x/api/erc721#ERC721Upgradeable) - [`ERC1155Upgradeable`](https://docs.openzeppelin.com/contracts-cairo/3.x/api/erc1155#ERC1155Upgradeable) - [`EthAccountUpgradeable`](https://docs.openzeppelin.com/contracts-cairo/3.x/api/account#EthAccountUpgradeable) +- [`MultisigAccountUpgradeable`](https://docs.openzeppelin.com/contracts-cairo/3.x/api/account#MultisigAccountUpgradeable) - [`UniversalDeployer`](https://docs.openzeppelin.com/contracts-cairo/3.x/api/udc#UniversalDeployer) diff --git a/packages/presets/Scarb.toml b/packages/presets/Scarb.toml index a040ea672..8fc85c039 100644 --- a/packages/presets/Scarb.toml +++ b/packages/presets/Scarb.toml @@ -52,6 +52,7 @@ build-external-contracts = [ "openzeppelin_test_common::mocks::account::SnakeAccountMock", "openzeppelin_test_common::mocks::account::SnakeEthAccountMock", "openzeppelin_test_common::mocks::account::LegacyAccountMock", + "openzeppelin_test_common::mocks::multisig_account::MultisigAccountMock", "openzeppelin_test_common::mocks::counter::CounterMock", "openzeppelin_test_common::mocks::simple::SimpleMock", "openzeppelin_test_common::mocks::erc20::DualCaseERC20Mock", diff --git a/packages/presets/src/interfaces.cairo b/packages/presets/src/interfaces.cairo index f0d3228f8..6d312e80b 100644 --- a/packages/presets/src/interfaces.cairo +++ b/packages/presets/src/interfaces.cairo @@ -4,6 +4,7 @@ pub mod erc20; pub mod erc721; pub mod eth_account; pub mod meta_tx_v0; +pub mod multisig_account; pub mod vesting; pub use account::AccountUpgradeableABI; @@ -24,4 +25,8 @@ pub use eth_account::{ pub use meta_tx_v0::{ MetaTransactionV0ABI, MetaTransactionV0ABIDispatcher, MetaTransactionV0ABIDispatcherTrait, }; +pub use multisig_account::{ + MultisigAccountUpgradeableABI, MultisigAccountUpgradeableABIDispatcher, + MultisigAccountUpgradeableABIDispatcherTrait, +}; pub use vesting::{VestingWalletABI, VestingWalletABIDispatcher, VestingWalletABIDispatcherTrait}; diff --git a/packages/presets/src/interfaces/multisig_account.cairo b/packages/presets/src/interfaces/multisig_account.cairo new file mode 100644 index 000000000..1ce9ddb32 --- /dev/null +++ b/packages/presets/src/interfaces/multisig_account.cairo @@ -0,0 +1,47 @@ +use openzeppelin_interfaces::src9::OutsideExecution; +use starknet::ClassHash; +use starknet::account::Call; + +#[starknet::interface] +pub trait MultisigAccountUpgradeableABI { + // ISRC6 + fn __execute__(self: @TState, calls: Array); + fn __validate__(self: @TState, calls: Array) -> felt252; + fn is_valid_signature(self: @TState, hash: felt252, signature: Array) -> felt252; + + // ISRC5 + fn supports_interface(self: @TState, interface_id: felt252) -> bool; + + // ISRC9 + fn execute_from_outside_v2( + ref self: TState, outside_execution: OutsideExecution, signature: Span, + ) -> Array>; + fn is_valid_outside_execution_nonce(self: @TState, nonce: felt252) -> bool; + + // IDeclarer + fn __validate_declare__(self: @TState, class_hash: felt252) -> felt252; + + // IMultisigDeployable + fn __validate_deploy__( + self: @TState, + class_hash: felt252, + contract_address_salt: felt252, + quorum: u32, + signers: Span, + ) -> felt252; + + // IMultisigAccount + fn get_quorum(self: @TState) -> u32; + fn is_signer(self: @TState, signer: felt252) -> bool; + fn get_signers(self: @TState) -> Span; + fn add_signers(ref self: TState, new_quorum: u32, signers_to_add: Span); + fn remove_signers(ref self: TState, new_quorum: u32, signers_to_remove: Span); + fn replace_signer(ref self: TState, signer_to_remove: felt252, signer_to_add: felt252); + fn change_quorum(ref self: TState, new_quorum: u32); + + // ISRC6CamelOnly + fn isValidSignature(self: @TState, hash: felt252, signature: Array) -> felt252; + + // IUpgradeable + fn upgrade(ref self: TState, new_class_hash: ClassHash); +} diff --git a/packages/presets/src/lib.cairo b/packages/presets/src/lib.cairo index a06b38d28..d2c381f34 100644 --- a/packages/presets/src/lib.cairo +++ b/packages/presets/src/lib.cairo @@ -5,6 +5,7 @@ pub mod erc721; pub mod eth_account; pub mod interfaces; pub mod meta_tx_v0; +pub mod multisig_account; #[cfg(test)] mod tests; @@ -18,5 +19,6 @@ pub use erc20::ERC20Upgradeable; pub use erc721::ERC721Upgradeable; pub use eth_account::EthAccountUpgradeable; pub use meta_tx_v0::MetaTransactionV0; +pub use multisig_account::MultisigAccountUpgradeable; pub use universal_deployer::UniversalDeployer; pub use vesting::VestingWallet; diff --git a/packages/presets/src/multisig_account.cairo b/packages/presets/src/multisig_account.cairo new file mode 100644 index 000000000..42654b305 --- /dev/null +++ b/packages/presets/src/multisig_account.cairo @@ -0,0 +1,78 @@ +// SPDX-License-Identifier: MIT +// OpenZeppelin Contracts for Cairo v4.0.0-alpha.1 (presets/src/multisig_account.cairo) + +/// # MultisigAccount Preset +/// +/// Upgradeable account authorized by a quorum of Stark-curve signer keys. The account can manage +/// its signer set, declare and deploy contracts, execute calls, and perform outside execution +/// through SRC9. +#[starknet::contract(account)] +pub mod MultisigAccountUpgradeable { + use openzeppelin_account::MultisigAccountComponent; + use openzeppelin_account::extensions::SRC9Component; + use openzeppelin_interfaces::upgrades::IUpgradeable; + use openzeppelin_introspection::src5::SRC5Component; + use openzeppelin_upgrades::UpgradeableComponent; + use starknet::ClassHash; + + component!( + path: MultisigAccountComponent, storage: multisig_account, event: MultisigAccountEvent, + ); + component!(path: SRC5Component, storage: src5, event: SRC5Event); + component!(path: SRC9Component, storage: src9, event: SRC9Event); + component!(path: UpgradeableComponent, storage: upgradeable, event: UpgradeableEvent); + + // MultisigAccount Mixin + #[abi(embed_v0)] + pub(crate) impl MultisigAccountMixinImpl = + MultisigAccountComponent::MultisigAccountMixinImpl; + impl MultisigAccountInternalImpl = MultisigAccountComponent::InternalImpl; + + // SRC9 + #[abi(embed_v0)] + impl OutsideExecutionV2Impl = + SRC9Component::OutsideExecutionV2Impl; + impl OutsideExecutionInternalImpl = SRC9Component::InternalImpl; + + // Upgradeable + impl UpgradeableInternalImpl = UpgradeableComponent::InternalImpl; + + #[storage] + pub struct Storage { + #[substorage(v0)] + pub multisig_account: MultisigAccountComponent::Storage, + #[substorage(v0)] + pub src5: SRC5Component::Storage, + #[substorage(v0)] + pub src9: SRC9Component::Storage, + #[substorage(v0)] + pub upgradeable: UpgradeableComponent::Storage, + } + + #[event] + #[derive(Drop, starknet::Event)] + enum Event { + #[flat] + MultisigAccountEvent: MultisigAccountComponent::Event, + #[flat] + SRC5Event: SRC5Component::Event, + #[flat] + SRC9Event: SRC9Component::Event, + #[flat] + UpgradeableEvent: UpgradeableComponent::Event, + } + + #[constructor] + pub fn constructor(ref self: ContractState, quorum: u32, signers: Span) { + self.multisig_account.initializer(quorum, signers); + self.src9.initializer(); + } + + #[abi(embed_v0)] + impl UpgradeableImpl of IUpgradeable { + fn upgrade(ref self: ContractState, new_class_hash: ClassHash) { + self.multisig_account.assert_only_self(); + self.upgradeable.upgrade(new_class_hash); + } + } +} diff --git a/packages/presets/src/tests.cairo b/packages/presets/src/tests.cairo index 726e1da2d..8174608be 100644 --- a/packages/presets/src/tests.cairo +++ b/packages/presets/src/tests.cairo @@ -4,5 +4,6 @@ mod test_erc20; mod test_erc721; mod test_eth_account; mod test_meta_tx_v0; +mod test_multisig_account; mod test_universal_deployer; mod test_vesting; diff --git a/packages/presets/src/tests/test_multisig_account.cairo b/packages/presets/src/tests/test_multisig_account.cairo new file mode 100644 index 000000000..0ca7cb830 --- /dev/null +++ b/packages/presets/src/tests/test_multisig_account.cairo @@ -0,0 +1,170 @@ +use openzeppelin_account::extensions::SRC9Component::SNIP12MetadataImpl; +use openzeppelin_account::extensions::src9::snip12_utils::OutsideExecutionStructHash; +use openzeppelin_interfaces::accounts::ISRC6_ID; +use openzeppelin_interfaces::introspection::ISRC5_ID; +use openzeppelin_interfaces::src9::{ISRC9_V2_ID, OutsideExecution}; +use openzeppelin_test_common::multisig_account::get_multisig_signature; +use openzeppelin_test_common::upgrades::UpgradeableSpyHelpers; +use openzeppelin_testing as utils; +use openzeppelin_testing::constants::stark::{KEY_PAIR, KEY_PAIR_2}; +use openzeppelin_testing::constants::{CLASS_HASH_ZERO, FELT_VALUE, MIN_TRANSACTION_VERSION, ZERO}; +use openzeppelin_testing::spy_events; +use openzeppelin_utils::cryptography::snip12::OffchainMessageHash; +use snforge_std::{ + CheatSpan, cheat_caller_address, load, start_cheat_block_timestamp_global, + start_cheat_signature_global, start_cheat_transaction_hash_global, + start_cheat_transaction_version_global, +}; +use starknet::account::Call; +use starknet::{ClassHash, ContractAddress}; +use crate::interfaces::{ + MultisigAccountUpgradeableABIDispatcher, MultisigAccountUpgradeableABIDispatcherTrait, +}; + +// +// Setup +// + +fn signer_public_keys() -> Array { + array![KEY_PAIR().public_key, KEY_PAIR_2().public_key] +} + +fn multisig_signature(hash: felt252) -> Array { + get_multisig_signature(hash, array![KEY_PAIR(), KEY_PAIR_2()].span()) +} + +fn setup_dispatcher() -> (ContractAddress, MultisigAccountUpgradeableABIDispatcher) { + let signers = signer_public_keys(); + let calldata = array![2, 2, *signers.at(0), *signers.at(1)]; + let account_address = utils::declare_and_deploy("MultisigAccountUpgradeable", calldata); + let dispatcher = MultisigAccountUpgradeableABIDispatcher { contract_address: account_address }; + (account_address, dispatcher) +} + +fn setup_simple_mock() -> ContractAddress { + utils::declare_and_deploy("SimpleMock", array![]) +} + +fn multisig_account_mock_class() -> ClassHash { + utils::declare_class("MultisigAccountMock").class_hash +} + +// +// constructor +// + +#[test] +fn test_constructor() { + let (_, dispatcher) = setup_dispatcher(); + let expected_signers = signer_public_keys(); + + assert_eq!(dispatcher.get_quorum(), 2); + assert_eq!(dispatcher.get_signers(), expected_signers.span()); + assert!(dispatcher.is_signer(*expected_signers.at(0))); + assert!(dispatcher.is_signer(*expected_signers.at(1))); + assert!(dispatcher.supports_interface(ISRC5_ID)); + assert!(dispatcher.supports_interface(ISRC6_ID)); + assert!(dispatcher.supports_interface(ISRC9_V2_ID)); +} + +// +// account validation and execution +// + +#[test] +fn test_is_valid_signature_and_camel_case() { + let (_, dispatcher) = setup_dispatcher(); + let hash = 'MULTISIG_HASH'; + + assert_eq!(dispatcher.is_valid_signature(hash, multisig_signature(hash)), starknet::VALIDATED); + assert_eq!(dispatcher.isValidSignature(hash, multisig_signature(hash)), starknet::VALIDATED); +} + +#[test] +fn test_execute_self_call_changes_quorum() { + let (account_address, dispatcher) = setup_dispatcher(); + let validation_call = Call { + to: account_address, selector: selector!("change_quorum"), calldata: array![1].span(), + }; + let execution_call = Call { + to: account_address, selector: selector!("change_quorum"), calldata: array![1].span(), + }; + let transaction_hash = 'SELF_CALL_TRANSACTION'; + let signature = multisig_signature(transaction_hash); + + start_cheat_signature_global(signature.span()); + start_cheat_transaction_hash_global(transaction_hash); + assert_eq!(dispatcher.__validate__(array![validation_call]), starknet::VALIDATED); + + start_cheat_transaction_version_global(MIN_TRANSACTION_VERSION); + cheat_caller_address(account_address, ZERO, CheatSpan::TargetCalls(1)); + dispatcher.__execute__(array![execution_call]); + + assert_eq!(dispatcher.get_quorum(), 1); +} + +// +// upgrade +// + +#[test] +#[should_panic(expected: 'MultisigAccount: unauthorized')] +fn test_upgrade_access_control() { + let (_, dispatcher) = setup_dispatcher(); + dispatcher.upgrade(CLASS_HASH_ZERO); +} + +#[test] +#[should_panic(expected: 'Class hash cannot be zero')] +fn test_upgrade_with_zero_class_hash() { + let (account_address, dispatcher) = setup_dispatcher(); + cheat_caller_address(account_address, account_address, CheatSpan::TargetCalls(1)); + dispatcher.upgrade(CLASS_HASH_ZERO); +} + +#[test] +fn test_upgrade_preserves_multisig_state() { + let (account_address, dispatcher) = setup_dispatcher(); + let expected_signers = signer_public_keys(); + let new_class_hash = multisig_account_mock_class(); + let mut spy = spy_events(); + + cheat_caller_address(account_address, account_address, CheatSpan::TargetCalls(1)); + dispatcher.upgrade(new_class_hash); + + spy.assert_only_event_upgraded(account_address, new_class_hash); + assert_eq!(dispatcher.get_quorum(), 2); + assert_eq!(dispatcher.get_signers(), expected_signers.span()); +} + +// +// execute_from_outside_v2 +// + +#[test] +fn test_execute_from_outside_v2_with_multisig_signature() { + let (account_address, dispatcher) = setup_dispatcher(); + let simple_mock = setup_simple_mock(); + let call = Call { + to: simple_mock, + selector: selector!("set_balance"), + calldata: array![FELT_VALUE, false.into()].span(), + }; + let outside_execution = OutsideExecution { + caller: 'ANY_CALLER'.try_into().unwrap(), + nonce: 5, + execute_after: 10, + execute_before: 20, + calls: array![call].span(), + }; + start_cheat_block_timestamp_global(15); + assert!(dispatcher.is_valid_outside_execution_nonce(outside_execution.nonce)); + + let hash = outside_execution.get_message_hash(account_address); + let signature = multisig_signature(hash); + dispatcher.execute_from_outside_v2(outside_execution, signature.span()); + + assert!(!dispatcher.is_valid_outside_execution_nonce(5)); + let value = *load(simple_mock, selector!("balance"), 1).at(0); + assert_eq!(value, FELT_VALUE); +} diff --git a/packages/test_common/src/lib.cairo b/packages/test_common/src/lib.cairo index 56d8dac92..f599e2b0a 100644 --- a/packages/test_common/src/lib.cairo +++ b/packages/test_common/src/lib.cairo @@ -7,6 +7,7 @@ pub mod erc721; pub mod eth_account; pub mod math; pub mod mocks; +pub mod multisig_account; pub mod ownable; pub mod upgrades; pub mod vesting; diff --git a/packages/test_common/src/mocks.cairo b/packages/test_common/src/mocks.cairo index d5199d9b8..a2f482549 100644 --- a/packages/test_common/src/mocks.cairo +++ b/packages/test_common/src/mocks.cairo @@ -11,6 +11,7 @@ pub mod erc6909; pub mod erc721; pub mod governor; pub mod multisig; +pub mod multisig_account; pub mod non_implementing; pub mod nonces; pub mod observer; diff --git a/packages/test_common/src/mocks/multisig_account.cairo b/packages/test_common/src/mocks/multisig_account.cairo new file mode 100644 index 000000000..f60874c57 --- /dev/null +++ b/packages/test_common/src/mocks/multisig_account.cairo @@ -0,0 +1,65 @@ +#[starknet::contract(account)] +pub mod MultisigAccountMock { + use openzeppelin_account::MultisigAccountComponent; + use openzeppelin_introspection::src5::SRC5Component; + + component!( + path: MultisigAccountComponent, storage: multisig_account, event: MultisigAccountEvent, + ); + component!(path: SRC5Component, storage: src5, event: SRC5Event); + + #[abi(embed_v0)] + impl MultisigAccountMixinImpl = + MultisigAccountComponent::MultisigAccountMixinImpl; + impl MultisigAccountInternalImpl = MultisigAccountComponent::InternalImpl; + + #[storage] + pub struct Storage { + #[substorage(v0)] + pub multisig_account: MultisigAccountComponent::Storage, + #[substorage(v0)] + pub src5: SRC5Component::Storage, + } + + #[event] + #[derive(Drop, starknet::Event)] + pub enum Event { + #[flat] + MultisigAccountEvent: MultisigAccountComponent::Event, + #[flat] + SRC5Event: SRC5Component::Event, + } + + #[constructor] + fn constructor(ref self: ContractState, quorum: u32, signers: Span) { + self.multisig_account.initializer(quorum, signers); + } +} + +#[starknet::interface] +pub trait ISignatureCallerMock { + fn call_is_valid_signature( + self: @TState, account: starknet::ContractAddress, hash: felt252, signature: Array, + ) -> felt252; +} + +#[starknet::contract] +pub mod SignatureCallerMock { + use openzeppelin_interfaces::accounts::{ISRC6Dispatcher, ISRC6DispatcherTrait}; + use starknet::ContractAddress; + + #[storage] + pub struct Storage {} + + #[abi(embed_v0)] + impl SignatureCallerMockImpl of super::ISignatureCallerMock { + fn call_is_valid_signature( + self: @ContractState, + account: ContractAddress, + hash: felt252, + signature: Array, + ) -> felt252 { + ISRC6Dispatcher { contract_address: account }.is_valid_signature(hash, signature) + } + } +} diff --git a/packages/test_common/src/multisig_account.cairo b/packages/test_common/src/multisig_account.cairo new file mode 100644 index 000000000..7ea68aace --- /dev/null +++ b/packages/test_common/src/multisig_account.cairo @@ -0,0 +1,40 @@ +use core::num::traits::Bounded; +use openzeppelin_account::MultisigAccountComponent::SIGNATURE_VERSION; +use openzeppelin_testing::signing::StarkKeyPair; +use snforge_std::signature::stark_curve::StarkCurveSignerImpl; + +/// Builds the canonical multisig signature encoding for `hash`. +/// +/// Signer records are sorted by ascending public key independently of the input order. +pub fn get_multisig_signature(hash: felt252, key_pairs: Span) -> Array { + let mut result = array![SIGNATURE_VERSION, key_pairs.len().into()]; + let mut previous_public_key: u256 = 0; + let mut records_added = 0; + + while records_added < key_pairs.len() { + let mut found = false; + let mut selected_index = 0; + let mut selected_public_key: u256 = Bounded::::MAX; + + for index in 0..key_pairs.len() { + let key_pair = *key_pairs.at(index); + let public_key: u256 = key_pair.public_key.into(); + if public_key > previous_public_key && (!found || public_key < selected_public_key) { + found = true; + selected_index = index; + selected_public_key = public_key; + } + } + + assert(found, 'Duplicate signing key'); + let key_pair = *key_pairs.at(selected_index); + let (r, s) = key_pair.sign(hash).unwrap(); + result.append(key_pair.public_key); + result.append(r); + result.append(s); + previous_public_key = selected_public_key; + records_added += 1; + } + + result +} diff --git a/scripts/get_hashes_page.py b/scripts/get_hashes_page.py index 593e79704..ed2d94488 100644 --- a/scripts/get_hashes_page.py +++ b/scripts/get_hashes_page.py @@ -6,6 +6,7 @@ "ERC721Upgradeable", "ERC1155Upgradeable", "AccountUpgradeable", + "MultisigAccountUpgradeable", "EthAccountUpgradeable", "UniversalDeployer" ] diff --git a/sncast_scripts/Scarb.toml b/sncast_scripts/Scarb.toml index c95571b12..419b2885c 100644 --- a/sncast_scripts/Scarb.toml +++ b/sncast_scripts/Scarb.toml @@ -17,6 +17,7 @@ sierra = true casm = true build-external-contracts = [ "openzeppelin_presets::account::AccountUpgradeable", + "openzeppelin_presets::multisig_account::MultisigAccountUpgradeable", "openzeppelin_presets::erc20::ERC20Upgradeable", "openzeppelin_presets::erc721::ERC721Upgradeable", "openzeppelin_presets::erc1155::ERC1155Upgradeable", diff --git a/sncast_scripts/src/declare_presets.cairo b/sncast_scripts/src/declare_presets.cairo index d2b897e56..25d0ec5ce 100644 --- a/sncast_scripts/src/declare_presets.cairo +++ b/sncast_scripts/src/declare_presets.cairo @@ -5,8 +5,8 @@ const MAX_FEE: felt252 = 99_999_999_999_999_999; fn main() { let contracts = array![ - "AccountUpgradeable", "ERC20Upgradeable", "ERC721Upgradeable", "ERC1155Upgradeable", - "EthAccountUpgradeable", "VestingWallet", + "AccountUpgradeable", "MultisigAccountUpgradeable", "ERC20Upgradeable", "ERC721Upgradeable", + "ERC1155Upgradeable", "EthAccountUpgradeable", "VestingWallet", ]; let mut consumed_latest_nonce = false; From 4fb2b73e12fb664e961070d6ee641cc229554619 Mon Sep 17 00:00:00 2001 From: Eric Nordelo Date: Tue, 18 Aug 2026 15:13:43 +0200 Subject: [PATCH 2/2] feat: rollback docs changes --- docs/modules/ROOT/pages/api/account.adoc | 522 ------------------ docs/modules/ROOT/pages/presets.adoc | 16 +- .../ROOT/pages/utils/_class_hashes.adoc | 19 +- 3 files changed, 12 insertions(+), 545 deletions(-) diff --git a/docs/modules/ROOT/pages/api/account.adoc b/docs/modules/ROOT/pages/api/account.adoc index 9ec7d13b1..941515eda 100644 --- a/docs/modules/ROOT/pages/api/account.adoc +++ b/docs/modules/ROOT/pages/api/account.adoc @@ -64,130 +64,6 @@ Returns the short string `'VALID'` if valid, otherwise it reverts. Validates whether a signature is valid or not for the given message hash. -Returns the short string `'VALID'` if valid, otherwise returns `0`. - -[.contract] -[[IMultisigAccount]] -=== `++IMultisigAccount++` link:https://github.com/OpenZeppelin/cairo-contracts/blob/release-v4.0.0-alpha.1/packages/interfaces/src/account/accounts.cairo[{github-icon},role=heading-link] - -```cairo -use openzeppelin_interfaces::accounts::IMultisigAccount; -``` - -Interface for reading and managing a multisig account's registered STARK-curve signers and quorum. -Configuration changes are authorized by the account calling itself. - -[.contract-index] -.Functions --- -* xref:#IMultisigAccount-get_quorum[`++get_quorum()++`] -* xref:#IMultisigAccount-is_signer[`++is_signer(signer)++`] -* xref:#IMultisigAccount-get_signers[`++get_signers()++`] -* xref:#IMultisigAccount-add_signers[`++add_signers(new_quorum, signers_to_add)++`] -* xref:#IMultisigAccount-remove_signers[`++remove_signers(new_quorum, signers_to_remove)++`] -* xref:#IMultisigAccount-replace_signer[`++replace_signer(signer_to_remove, signer_to_add)++`] -* xref:#IMultisigAccount-change_quorum[`++change_quorum(new_quorum)++`] --- - -[#IMultisigAccount-Functions] -==== Functions - -[.contract-item] -[[IMultisigAccount-get_quorum]] -==== `[.contract-item-name]#++get_quorum++#++() → u32++` [.item-kind]#external# - -Returns the minimum number of signer records required to authorize an operation. - -[.contract-item] -[[IMultisigAccount-is_signer]] -==== `[.contract-item-name]#++is_signer++#++(signer: felt252) → bool++` [.item-kind]#external# - -Returns whether `signer` is a registered signer public key. - -[.contract-item] -[[IMultisigAccount-get_signers]] -==== `[.contract-item-name]#++get_signers++#++() → Span++` [.item-kind]#external# - -Returns the registered signer public keys. - -The function exposes registry order, which is not sorted and can change when signers are removed. -Sort selected public keys numerically in ascending order when encoding a multisig signature. - -[.contract-item] -[[IMultisigAccount-add_signers]] -==== `[.contract-item-name]#++add_signers++#++(new_quorum: u32, signers_to_add: Span)++` [.item-kind]#external# - -Registers the nonzero public keys in `signers_to_add` and sets the account quorum to -`new_quorum`. Public keys that are already registered are left unchanged. - -Requirements: - -- The caller must be the account itself. -- Every newly registered signer must be nonzero. -- `new_quorum` must be nonzero and no greater than the resulting signer count. - -[.contract-item] -[[IMultisigAccount-remove_signers]] -==== `[.contract-item-name]#++remove_signers++#++(new_quorum: u32, signers_to_remove: Span)++` [.item-kind]#external# - -Unregisters the public keys in `signers_to_remove` and sets the account quorum to -`new_quorum`. Public keys that are not registered are left unchanged. - -Requirements: - -- The caller must be the account itself. -- `new_quorum` must be nonzero and no greater than the resulting signer count. - -[.contract-item] -[[IMultisigAccount-replace_signer]] -==== `[.contract-item-name]#++replace_signer++#++(signer_to_remove: felt252, signer_to_add: felt252)++` [.item-kind]#external# - -Replaces `signer_to_remove` with `signer_to_add` while preserving the current quorum. - -Requirements: - -- The caller must be the account itself. -- `signer_to_remove` must be registered. -- `signer_to_add` must be nonzero and unregistered. - -[.contract-item] -[[IMultisigAccount-change_quorum]] -==== `[.contract-item-name]#++change_quorum++#++(new_quorum: u32)++` [.item-kind]#external# - -Sets the minimum number of signer records required to authorize an operation. - -Requirements: - -- The caller must be the account itself. -- `new_quorum` must be nonzero and no greater than the registered signer count. - -[.contract] -[[IMultisigDeployable]] -=== `++IMultisigDeployable++` link:https://github.com/OpenZeppelin/cairo-contracts/blob/release-v4.0.0-alpha.1/packages/interfaces/src/account/accounts.cairo[{github-icon},role=heading-link] - -```cairo -use openzeppelin_interfaces::accounts::IMultisigDeployable; -``` - -Interface for validating a multisig `DeployAccount` transaction whose constructor configures a -quorum and signer set. - -[.contract-index] -.Functions --- -* xref:#IMultisigDeployable-\\__validate_deploy__[`++__validate_deploy__(class_hash, contract_address_salt, quorum, signers)++`] --- - -[#IMultisigDeployable-Functions] -==== Functions - -[.contract-item] -[[IMultisigDeployable-__validate_deploy__]] -==== `[.contract-item-name]#++__validate_deploy__++#++(class_hash: felt252, contract_address_salt: felt252, quorum: u32, signers: Span) → felt252++` [.item-kind]#external# - -Validates a `DeployAccount` transaction using the signer quorum configured by `quorum` and -`signers`. - Returns the short string `'VALID'` if valid, otherwise it reverts. [.contract] @@ -492,328 +368,6 @@ Emitted when a `public_key` is added. Emitted when a `public_key` is removed. -[.contract] -[[MultisigAccountComponent]] -=== `++MultisigAccountComponent++` link:https://github.com/OpenZeppelin/cairo-contracts/blob/release-v4.0.0-alpha.1/packages/account/src/multisig_account/multisig_account.cairo[{github-icon},role=heading-link] - -:SignerAdded: xref:MultisigAccountComponent-SignerAdded[SignerAdded] -:SignerRemoved: xref:MultisigAccountComponent-SignerRemoved[SignerRemoved] -:QuorumUpdated: xref:MultisigAccountComponent-QuorumUpdated[QuorumUpdated] - -```cairo -use openzeppelin_account::MultisigAccountComponent; -``` - -Account component implementing xref:ISRC6[`ISRC6`] with quorum-based authorization from registered -STARK-curve signer keys. - -The canonical signature encoding is: - -```text -[1, n, public_key_1, r_1, s_1, ..., public_key_n, r_n, s_n] -``` - -The first felt is the signature format version and `n` is the number of signer records. A valid -signature satisfies all of the following conditions: - -- The array contains exactly two header felts followed by `n` three-felt signer records. -- The format version is `1`, and the encoded `n` matches the number of records. -- `n` is at least the account quorum and no greater than the registered signer count. -- Public keys are in strictly increasing numeric order and registered with the account. -- Every `(r, s)` pair is a valid STARK-curve signature for the same hash under its associated -public key. - -`is_valid_signature` returns the short string `'VALID'` when every condition holds and `0` -otherwise. The `invoke`, `declare`, and `deploy_account` validation entry points return `'VALID'` -for a valid transaction signature and revert for an invalid signature. - -CAUTION: Signature validation verifies every supplied signer record. Its execution cost grows -linearly with `n`, so callers can minimize validation work by supplying the smallest valid quorum. - -CAUTION: Configure a quorum that fits within Starknet account-validation resource limits. An -impractically high required quorum can prevent the account from authorizing a configuration -recovery. - -Signer and quorum changes are authorized through account self-calls. The current signer quorum -therefore authorizes configuration changes as account transactions. - -NOTE: {src5-component-required-note} - -[.contract-index#MultisigAccountComponent-Embeddable-Mixin-Impl] -.{mixin-impls} - --- -.MultisigAccountMixinImpl - -* xref:#MultisigAccountComponent-Embeddable-Impls-SRC6Impl[`++SRC6Impl++`] -* xref:#MultisigAccountComponent-Embeddable-Impls-DeclarerImpl[`++DeclarerImpl++`] -* xref:#MultisigAccountComponent-Embeddable-Impls-DeployableImpl[`++DeployableImpl++`] -* xref:#MultisigAccountComponent-Embeddable-Impls-MultisigImpl[`++MultisigImpl++`] -* xref:#MultisigAccountComponent-Embeddable-Impls-SRC6CamelOnlyImpl[`++SRC6CamelOnlyImpl++`] -* xref:api/introspection.adoc#SRC5Component-Embeddable-Impls[`++SRC5Impl++`] --- - -[.contract-index#MultisigAccountComponent-Embeddable-Impls] -.Embeddable Implementations --- -[.sub-index#MultisigAccountComponent-Embeddable-Impls-SRC6Impl] -.SRC6Impl - -* xref:#MultisigAccountComponent-\\__execute__[`++__execute__(self, calls)++`] -* xref:#MultisigAccountComponent-\\__validate__[`++__validate__(self, calls)++`] -* xref:#MultisigAccountComponent-is_valid_signature[`++is_valid_signature(self, hash, signature)++`] - -[.sub-index#MultisigAccountComponent-Embeddable-Impls-DeclarerImpl] -.DeclarerImpl - -* xref:#MultisigAccountComponent-\\__validate_declare__[`++__validate_declare__(self, class_hash)++`] - -[.sub-index#MultisigAccountComponent-Embeddable-Impls-DeployableImpl] -.DeployableImpl - -* xref:#MultisigAccountComponent-\\__validate_deploy__[`++__validate_deploy__(self, class_hash, contract_address_salt, quorum, signers)++`] - -[.sub-index#MultisigAccountComponent-Embeddable-Impls-MultisigImpl] -.MultisigImpl - -* xref:#MultisigAccountComponent-get_quorum[`++get_quorum(self)++`] -* xref:#MultisigAccountComponent-is_signer[`++is_signer(self, signer)++`] -* xref:#MultisigAccountComponent-get_signers[`++get_signers(self)++`] -* xref:#MultisigAccountComponent-add_signers[`++add_signers(self, new_quorum, signers_to_add)++`] -* xref:#MultisigAccountComponent-remove_signers[`++remove_signers(self, new_quorum, signers_to_remove)++`] -* xref:#MultisigAccountComponent-replace_signer[`++replace_signer(self, signer_to_remove, signer_to_add)++`] -* xref:#MultisigAccountComponent-change_quorum[`++change_quorum(self, new_quorum)++`] - -[.sub-index#MultisigAccountComponent-Embeddable-Impls-SRC6CamelOnlyImpl] -.SRC6CamelOnlyImpl - -* xref:#MultisigAccountComponent-isValidSignature[`++isValidSignature(self, hash, signature)++`] - -.SRC5Impl -* xref:api/introspection.adoc#ISRC5-supports_interface[`supports_interface(self, interface_id: felt252)`] --- - -[.contract-index] -.Internal Implementations --- -.InternalImpl - -* xref:#MultisigAccountComponent-initializer[`++initializer(self, quorum, signers)++`] -* xref:#MultisigAccountComponent-assert_only_self[`++assert_only_self(self)++`] -* xref:#MultisigAccountComponent-validate_transaction[`++validate_transaction(self)++`] -* xref:#MultisigAccountComponent-_is_valid_signature[`++_is_valid_signature(self, hash, signature)++`] -* xref:#MultisigAccountComponent-_add_signers[`++_add_signers(self, new_quorum, signers_to_add)++`] -* xref:#MultisigAccountComponent-_remove_signers[`++_remove_signers(self, new_quorum, signers_to_remove)++`] -* xref:#MultisigAccountComponent-_replace_signer[`++_replace_signer(self, signer_to_remove, signer_to_add)++`] -* xref:#MultisigAccountComponent-_change_quorum[`++_change_quorum(self, new_quorum)++`] --- - -[.contract-index] -.Events --- -* xref:#MultisigAccountComponent-SignerAdded[`++SignerAdded(signer)++`] -* xref:#MultisigAccountComponent-SignerRemoved[`++SignerRemoved(signer)++`] -* xref:#MultisigAccountComponent-QuorumUpdated[`++QuorumUpdated(old_quorum, new_quorum)++`] --- - -[#MultisigAccountComponent-Embeddable-Functions] -==== Embeddable functions - -[.contract-item] -[[MultisigAccountComponent-__execute__]] -==== `[.contract-item-name]#++__execute__++#++(self: @ContractState, calls: Array)++` [.item-kind]#external# - -Executes `calls` as an account transaction. - -Requirements: - -- The caller must be the Starknet protocol. -- The transaction version must be supported. - -[.contract-item] -[[MultisigAccountComponent-__validate__]] -==== `[.contract-item-name]#++__validate__++#++(self: @ContractState, calls: Array) → felt252++` [.item-kind]#external# - -Validates the current `invoke` transaction using its transaction hash and multisig signature. - -Returns the short string `'VALID'` if valid, otherwise it reverts. - -[.contract-item] -[[MultisigAccountComponent-is_valid_signature]] -==== `[.contract-item-name]#++is_valid_signature++#++(self: @ContractState, hash: felt252, signature: Array) → felt252++` [.item-kind]#external# - -Validates the canonical multisig `signature` for `hash` against the current signer set and quorum. -This function can be called by contracts that use SRC6 signature validation. - -Returns the short string `'VALID'` if valid, otherwise returns `0`. - -[.contract-item] -[[MultisigAccountComponent-__validate_declare__]] -==== `[.contract-item-name]#++__validate_declare__++#++(self: @ContractState, class_hash: felt252) → felt252++` [.item-kind]#external# - -Validates the current `Declare` transaction using its transaction hash and multisig signature. - -Returns the short string `'VALID'` if valid, otherwise it reverts. - -[.contract-item] -[[MultisigAccountComponent-__validate_deploy__]] -==== `[.contract-item-name]#++__validate_deploy__++#++(self: @ContractState, class_hash: felt252, contract_address_salt: felt252, quorum: u32, signers: Span) → felt252++` [.item-kind]#external# - -Validates the current `DeployAccount` transaction using its transaction hash and the signer -configuration supplied to the constructor. - -Returns the short string `'VALID'` if valid, otherwise it reverts. - -[.contract-item] -[[MultisigAccountComponent-get_quorum]] -==== `[.contract-item-name]#++get_quorum++#++(self: @ContractState) → u32++` [.item-kind]#external# - -See xref:IMultisigAccount-get_quorum[IMultisigAccount::get_quorum]. - -[.contract-item] -[[MultisigAccountComponent-is_signer]] -==== `[.contract-item-name]#++is_signer++#++(self: @ContractState, signer: felt252) → bool++` [.item-kind]#external# - -See xref:IMultisigAccount-is_signer[IMultisigAccount::is_signer]. - -[.contract-item] -[[MultisigAccountComponent-get_signers]] -==== `[.contract-item-name]#++get_signers++#++(self: @ContractState) → Span++` [.item-kind]#external# - -See xref:IMultisigAccount-get_signers[IMultisigAccount::get_signers]. - -[.contract-item] -[[MultisigAccountComponent-add_signers]] -==== `[.contract-item-name]#++add_signers++#++(ref self: ContractState, new_quorum: u32, signers_to_add: Span)++` [.item-kind]#external# - -See xref:IMultisigAccount-add_signers[IMultisigAccount::add_signers]. - -Emits a {SignerAdded} event for each newly registered signer and a {QuorumUpdated} event when the -quorum changes. - -[.contract-item] -[[MultisigAccountComponent-remove_signers]] -==== `[.contract-item-name]#++remove_signers++#++(ref self: ContractState, new_quorum: u32, signers_to_remove: Span)++` [.item-kind]#external# - -See xref:IMultisigAccount-remove_signers[IMultisigAccount::remove_signers]. - -Emits a {SignerRemoved} event for each removed signer and a {QuorumUpdated} event when the -quorum changes. - -[.contract-item] -[[MultisigAccountComponent-replace_signer]] -==== `[.contract-item-name]#++replace_signer++#++(ref self: ContractState, signer_to_remove: felt252, signer_to_add: felt252)++` [.item-kind]#external# - -See xref:IMultisigAccount-replace_signer[IMultisigAccount::replace_signer]. - -Emits a {SignerRemoved} event followed by a {SignerAdded} event. - -[.contract-item] -[[MultisigAccountComponent-change_quorum]] -==== `[.contract-item-name]#++change_quorum++#++(ref self: ContractState, new_quorum: u32)++` [.item-kind]#external# - -See xref:IMultisigAccount-change_quorum[IMultisigAccount::change_quorum]. - -Emits a {QuorumUpdated} event when the quorum changes. - -[.contract-item] -[[MultisigAccountComponent-isValidSignature]] -==== `[.contract-item-name]#++isValidSignature++#++(self: @ContractState, hash: felt252, signature: Array) → felt252++` [.item-kind]#external# - -See xref:MultisigAccountComponent-is_valid_signature[is_valid_signature]. - -[#MultisigAccountComponent-Internal-Functions] -==== Internal functions - -[.contract-item] -[[MultisigAccountComponent-initializer]] -==== `[.contract-item-name]#++initializer++#++(ref self: ComponentState, quorum: u32, signers: Span)++` [.item-kind]#internal# - -Registers each unique signer, sets the account quorum, and registers the `ISRC6` interface ID. - -Requirements: - -- Every signer must be nonzero. -- `quorum` must be nonzero and no greater than the number of unique signers. - -Emits a {SignerAdded} event for each unique signer and a {QuorumUpdated} event when setting the -initial quorum. - -[.contract-item] -[[MultisigAccountComponent-assert_only_self]] -==== `[.contract-item-name]#++assert_only_self++#++(self: @ComponentState)++` [.item-kind]#internal# - -Validates that the caller is the account itself. Otherwise it reverts. - -[.contract-item] -[[MultisigAccountComponent-validate_transaction]] -==== `[.contract-item-name]#++validate_transaction++#++(self: @ComponentState) → felt252++` [.item-kind]#internal# - -Validates the canonical multisig signature from the transaction context against the transaction -hash. - -Returns the short string `'VALID'` if valid, otherwise it reverts. - -[.contract-item] -[[MultisigAccountComponent-_is_valid_signature]] -==== `[.contract-item-name]#++_is_valid_signature++#++(self: @ComponentState, hash: felt252, signature: Span) → bool++` [.item-kind]#internal# - -Returns whether `signature` uses the canonical encoding and contains a valid signer quorum for -`hash`. - -[.contract-item] -[[MultisigAccountComponent-_add_signers]] -==== `[.contract-item-name]#++_add_signers++#++(ref self: ComponentState, new_quorum: u32, signers_to_add: Span)++` [.item-kind]#internal# - -Registers each nonzero, unregistered signer and sets the quorum to `new_quorum`. - -The caller embedding this internal function is responsible for enforcing authorization. - -[.contract-item] -[[MultisigAccountComponent-_remove_signers]] -==== `[.contract-item-name]#++_remove_signers++#++(ref self: ComponentState, new_quorum: u32, signers_to_remove: Span)++` [.item-kind]#internal# - -Unregisters each registered signer and sets the quorum to `new_quorum`. - -The caller embedding this internal function is responsible for enforcing authorization. - -[.contract-item] -[[MultisigAccountComponent-_replace_signer]] -==== `[.contract-item-name]#++_replace_signer++#++(ref self: ComponentState, signer_to_remove: felt252, signer_to_add: felt252)++` [.item-kind]#internal# - -Replaces a registered signer with a nonzero, unregistered signer while preserving the quorum. - -The caller embedding this internal function is responsible for enforcing authorization. - -[.contract-item] -[[MultisigAccountComponent-_change_quorum]] -==== `[.contract-item-name]#++_change_quorum++#++(ref self: ComponentState, new_quorum: u32)++` [.item-kind]#internal# - -Sets the quorum to a nonzero value no greater than the registered signer count. - -The caller embedding this internal function is responsible for enforcing authorization. - -[#MultisigAccountComponent-Events] -==== Events - -[.contract-item] -[[MultisigAccountComponent-SignerAdded]] -==== `[.contract-item-name]#++SignerAdded++#++(signer: felt252)++` [.item-kind]#event# - -Emitted when `signer` is registered with the account. - -[.contract-item] -[[MultisigAccountComponent-SignerRemoved]] -==== `[.contract-item-name]#++SignerRemoved++#++(signer: felt252)++` [.item-kind]#event# - -Emitted when `signer` is unregistered from the account. - -[.contract-item] -[[MultisigAccountComponent-QuorumUpdated]] -==== `[.contract-item-name]#++QuorumUpdated++#++(old_quorum: u32, new_quorum: u32)++` [.item-kind]#event# - -Emitted when the account quorum changes from `old_quorum` to `new_quorum`. - [.contract] [[EthAccountComponent]] === `++EthAccountComponent++` link:https://github.com/OpenZeppelin/cairo-contracts/blob/release-v4.0.0-alpha.1/packages/account/src/eth_account.cairo[{github-icon},role=heading-link] @@ -1209,82 +763,6 @@ Requirements: - The caller is the account contract itself. - `new_class_hash` cannot be zero. -[.contract] -[[MultisigAccountUpgradeable]] -=== `++MultisigAccountUpgradeable++` link:https://github.com/OpenZeppelin/cairo-contracts/blob/release-v4.0.0-alpha.1/packages/presets/src/multisig_account.cairo[{github-icon},role=heading-link] - -```cairo -use openzeppelin_presets::MultisigAccountUpgradeable; -``` - -Upgradeable account authorized by a quorum of registered STARK-curve signer keys. The preset can -manage its signer set and quorum, declare and deploy contracts, execute calls, and perform outside -execution through xref:#SRC9Component[SRC9]. Class upgrades are authorized through account -self-calls. - -Signatures use the canonical -`[1, n, public_key_1, r_1, s_1, ..., public_key_n, r_n, s_n]` encoding documented by -xref:#MultisigAccountComponent[MultisigAccountComponent]. - -include::../utils/_class_hashes.adoc[] - -[.contract-index] -.{presets-page} --- -{MultisigAccountUpgradeable-class-hash} --- - -[.contract-index] -.Constructor --- -* xref:#MultisigAccountUpgradeable-constructor[`++constructor(self, quorum, signers)++`] --- - -[.contract-index] -.Embedded Implementations --- -.MultisigAccountComponent -* xref:#MultisigAccountComponent-Embeddable-Mixin-Impl[`++MultisigAccountMixinImpl++`] - -.SRC9Component -* xref:#SRC9Component-Embeddable-Impls-OutsideExecutionV2Impl[`++OutsideExecutionV2Impl++`] --- - -[.contract-index] -.External Functions --- -* xref:#MultisigAccountUpgradeable-upgrade[`++upgrade(self, new_class_hash)++`] --- - -[#MultisigAccountUpgradeable-constructor-section] -==== Constructor - -[.contract-item] -[[MultisigAccountUpgradeable-constructor]] -==== `[.contract-item-name]#++constructor++#++(ref self: ContractState, quorum: u32, signers: Span)++` [.item-kind]#constructor# - -Registers each unique signer, sets the account quorum, and registers the `ISRC6` and `ISRC9_V2` -interface IDs. - -Requirements: - -- Every signer must be nonzero. -- `quorum` must be nonzero and no greater than the number of unique signers. - -[#MultisigAccountUpgradeable-external-functions] -==== External functions - -[.contract-item] -[[MultisigAccountUpgradeable-upgrade]] -==== `[.contract-item-name]#++upgrade++#++(ref self: ContractState, new_class_hash: ClassHash)++` [.item-kind]#external# - -Upgrades the contract to the implementation identified by `new_class_hash`. - -Requirements: - -- The caller is the account contract itself. -- `new_class_hash` cannot be zero. - [.contract] [[EthAccountUpgradeable]] === `++EthAccountUpgradeable++` link:https://github.com/OpenZeppelin/cairo-contracts/blob/release-v4.0.0-alpha.1/packages/presets/src/eth_account.cairo[{github-icon},role=heading-link] diff --git a/docs/modules/ROOT/pages/presets.adoc b/docs/modules/ROOT/pages/presets.adoc index 4bcfc8920..6437590f5 100644 --- a/docs/modules/ROOT/pages/presets.adoc +++ b/docs/modules/ROOT/pages/presets.adoc @@ -3,7 +3,6 @@ :erc721-upgradeable: xref:/api/erc721.adoc#ERC721Upgradeable[ERC721Upgradeable] :erc1155-upgradeable: xref:/api/erc1155.adoc#ERC1155Upgradeable[ERC1155Upgradeable] :eth-account-upgradeable: xref:/api/account.adoc#EthAccountUpgradeable[EthAccountUpgradeable] -:multisig-account-upgradeable: xref:/api/account.adoc#MultisigAccountUpgradeable[MultisigAccountUpgradeable] :udc: xref:/api/udc.adoc#UniversalDeployer[UniversalDeployer] :vesting-wallet: xref:/api/finance.adoc#VestingWallet[VestingWallet] :sierra-class-hashes: https://docs.starknet.io/architecture-and-concepts/smart-contracts/class-hash/[Sierra class hashes] @@ -15,10 +14,10 @@ include::utils/_class_hashes.adoc[] -Presets are ready-to-deploy contracts that combine widely used components into simple, -general-purpose configurations. +Presets are ready-to-deploy contracts provided by the library. Since presets are intended to be very simple +and as generic as possible, there's no support for custom or complex contracts such as `ERC20Pausable` or `ERC721Mintable`. -TIP: Use {wizard}, our code-generation tool, to build custom combinations of components. +TIP: For contract customization and combination of modules you can use {wizard}, our code-generation tool. == Available presets @@ -37,9 +36,6 @@ CAUTION: Before version 4.0.0-alpha.1, class hashes were computed using the `sca | `{account-upgradeable}` | `{AccountUpgradeable-class-hash}` -| `{multisig-account-upgradeable}` -| `{MultisigAccountUpgradeable-class-hash}` - | `{erc20-upgradeable}` | `{ERC20Upgradeable-class-hash}` @@ -59,12 +55,6 @@ CAUTION: Before version 4.0.0-alpha.1, class hashes were computed using the `sca | `{VestingWallet-class-hash}` |=== -The {multisig-account-upgradeable} preset provides quorum-based STARK-curve authorization through -SRC6, outside execution through SRC9, and self-authorized class upgrades. Its constructor accepts -a quorum and a signer span, and account signatures use the canonical -`[1, n, public_key_1, r_1, s_1, ..., public_key_n, r_n, s_n]` encoding documented by -xref:/api/account.adoc#MultisigAccountComponent[MultisigAccountComponent]. - TIP: {starkli} class-hash command can be used to compute the class hash from a Sierra artifact. == Usage diff --git a/docs/modules/ROOT/pages/utils/_class_hashes.adoc b/docs/modules/ROOT/pages/utils/_class_hashes.adoc index ef2654550..2396a7ea8 100644 --- a/docs/modules/ROOT/pages/utils/_class_hashes.adoc +++ b/docs/modules/ROOT/pages/utils/_class_hashes.adoc @@ -1,16 +1,15 @@ // Version -:class-hash-cairo-version: https://crates.io/crates/cairo-lang-compiler/2.18.0[cairo 2.18.0] +:class-hash-cairo-version: https://crates.io/crates/cairo-lang-compiler/2.17.0[cairo 2.17.0] // Class Hashes -:ERC20Upgradeable-class-hash: 0x06d081c70145f14c3e0d05eacbfaa01620a27fde89430e6c227a8f4a573e3727 -:ERC721Upgradeable-class-hash: 0x05f3d201fc5c9de757ca6c42ec753d514ab1ea011b60bd139b6167cd069109d2 -:ERC1155Upgradeable-class-hash: 0x075829cba5eea49a998988967e6a97de68824830118f7f008f3ac07d407cb99a -:AccountUpgradeable-class-hash: 0x03013bf2edcf93ac575367eee41cd07bb50e809c78925817b61878db29826144 -:MultisigAccountUpgradeable-class-hash: 0x05e016dd4ace8b826345a13d90929f149658b7562373c86397c4af3c1cc7bc61 -:EthAccountUpgradeable-class-hash: 0x000be1cf8839442d664572712fd36554c4d259f86549ad02e22353d10729b900 -:UniversalDeployer-class-hash: 0x05412c7a3e145fdc79862e65df612324d83b0169c071dd564021155bd8cfd29e -:MetaTransactionV0-class-hash: 0x059b5a7a062db4a9fa1ec4b96ea74cac3d55477971d68ac9fba877c087e59808 -:VestingWallet-class-hash: 0x05613fb6c8f729fdcbef50d25c9dd72260762543d48254c59e6851dd3d46595a +:ERC20Upgradeable-class-hash: 0x02306d411d9061591d2a661f177ea92e53918a474803c33a901bace86dfaea6c +:ERC721Upgradeable-class-hash: 0x0735400adfb617a215a0eadd009e84ecebf10b258b7ce7583d4131f7fa793bf1 +:ERC1155Upgradeable-class-hash: 0x02f81498862c308981a544e7da3e65c715c375c97a881da2d157b2f3a3dcdd93 +:AccountUpgradeable-class-hash: 0x0342f3c683f708fd920e625e425b77726aa4c834da47ec92b4da5c207f025207 +:EthAccountUpgradeable-class-hash: 0x0216f6773aef13b25e5f8ecc989c4a4668d45ba118b41bbdca10d08fc6fda878 +:UniversalDeployer-class-hash: 0x00ce766f9026176e6796a13720059301c4caa1b24cc55d964afb62bed07b0158 +:MetaTransactionV0-class-hash: 0x051d6576154ab74933e521cd0d8e3367d14ff1713fd2b2ce982af1f555564134 +:VestingWallet-class-hash: 0x051be0e6c0a6904b496c77f991f423e766ff465752cb18e93b6eb5ca525cf219 // Presets page :presets-page: xref:presets.adoc[Sierra class hash]