diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index b79c8d4..a82605e 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -1,38 +1,37 @@
-name: CI
+name: test
-permissions: {}
-
-on:
- push:
- pull_request:
- workflow_dispatch:
+on: [push, pull_request]
env:
FOUNDRY_PROFILE: ci
jobs:
check:
+ strategy:
+ fail-fast: true
+
name: Foundry project
runs-on: ubuntu-latest
- permissions:
- contents: read
steps:
- - uses: actions/checkout@v5
+ - uses: actions/checkout@v3
with:
- persist-credentials: false
submodules: recursive
- name: Install Foundry
uses: foundry-rs/foundry-toolchain@v1
-
- - name: Show Forge version
- run: forge --version
-
- - name: Run Forge fmt
- run: forge fmt --check
+ with:
+ version: stable
- name: Run Forge build
- run: forge build --sizes
+ run: |
+ forge --version
+ forge build --sizes
+ id: build
- name: Run Forge tests
- run: forge test -vvv
+ run: |
+ forge test -vvv
+ id: test
+ env:
+ ETH_RPC_URL : ${{ secrets.ETH_RPC_URL }}
+ BASE_RPC_URL: ${{ secrets.BASE_RPC_URL }}
diff --git a/.gitmodules b/.gitmodules
index 888d42d..6ab1806 100644
--- a/.gitmodules
+++ b/.gitmodules
@@ -1,3 +1,10 @@
[submodule "lib/forge-std"]
path = lib/forge-std
url = https://github.com/foundry-rs/forge-std
+[submodule "lib/openzeppelin-contracts"]
+ path = lib/openzeppelin-contracts
+ url = https://github.com/OpenZeppelin/openzeppelin-contracts
+[submodule "lib/dss-test"]
+ path = lib/dss-test
+ url = https://github.com/sky-ecosystem/dss-test
+
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..0ad25db
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,661 @@
+ GNU AFFERO GENERAL PUBLIC LICENSE
+ Version 3, 19 November 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc.
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+ Preamble
+
+ The GNU Affero General Public License is a free, copyleft license for
+software and other kinds of works, specifically designed to ensure
+cooperation with the community in the case of network server software.
+
+ The licenses for most software and other practical works are designed
+to take away your freedom to share and change the works. By contrast,
+our General Public Licenses are intended to guarantee your freedom to
+share and change all versions of a program--to make sure it remains free
+software for all its users.
+
+ When we speak of free software, we are referring to freedom, not
+price. Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+them if you wish), that you receive source code or can get it if you
+want it, that you can change the software or use pieces of it in new
+free programs, and that you know you can do these things.
+
+ Developers that use our General Public Licenses protect your rights
+with two steps: (1) assert copyright on the software, and (2) offer
+you this License which gives you legal permission to copy, distribute
+and/or modify the software.
+
+ A secondary benefit of defending all users' freedom is that
+improvements made in alternate versions of the program, if they
+receive widespread use, become available for other developers to
+incorporate. Many developers of free software are heartened and
+encouraged by the resulting cooperation. However, in the case of
+software used on network servers, this result may fail to come about.
+The GNU General Public License permits making a modified version and
+letting the public access it on a server without ever releasing its
+source code to the public.
+
+ The GNU Affero General Public License is designed specifically to
+ensure that, in such cases, the modified source code becomes available
+to the community. It requires the operator of a network server to
+provide the source code of the modified version running there to the
+users of that server. Therefore, public use of a modified version, on
+a publicly accessible server, gives the public access to the source
+code of the modified version.
+
+ An older license, called the Affero General Public License and
+published by Affero, was designed to accomplish similar goals. This is
+a different license, not a version of the Affero GPL, but Affero has
+released a new version of the Affero GPL which permits relicensing under
+this license.
+
+ The precise terms and conditions for copying, distribution and
+modification follow.
+
+ TERMS AND CONDITIONS
+
+ 0. Definitions.
+
+ "This License" refers to version 3 of the GNU Affero General Public License.
+
+ "Copyright" also means copyright-like laws that apply to other kinds of
+works, such as semiconductor masks.
+
+ "The Program" refers to any copyrightable work licensed under this
+License. Each licensee is addressed as "you". "Licensees" and
+"recipients" may be individuals or organizations.
+
+ To "modify" a work means to copy from or adapt all or part of the work
+in a fashion requiring copyright permission, other than the making of an
+exact copy. The resulting work is called a "modified version" of the
+earlier work or a work "based on" the earlier work.
+
+ A "covered work" means either the unmodified Program or a work based
+on the Program.
+
+ To "propagate" a work means to do anything with it that, without
+permission, would make you directly or secondarily liable for
+infringement under applicable copyright law, except executing it on a
+computer or modifying a private copy. Propagation includes copying,
+distribution (with or without modification), making available to the
+public, and in some countries other activities as well.
+
+ To "convey" a work means any kind of propagation that enables other
+parties to make or receive copies. Mere interaction with a user through
+a computer network, with no transfer of a copy, is not conveying.
+
+ An interactive user interface displays "Appropriate Legal Notices"
+to the extent that it includes a convenient and prominently visible
+feature that (1) displays an appropriate copyright notice, and (2)
+tells the user that there is no warranty for the work (except to the
+extent that warranties are provided), that licensees may convey the
+work under this License, and how to view a copy of this License. If
+the interface presents a list of user commands or options, such as a
+menu, a prominent item in the list meets this criterion.
+
+ 1. Source Code.
+
+ The "source code" for a work means the preferred form of the work
+for making modifications to it. "Object code" means any non-source
+form of a work.
+
+ A "Standard Interface" means an interface that either is an official
+standard defined by a recognized standards body, or, in the case of
+interfaces specified for a particular programming language, one that
+is widely used among developers working in that language.
+
+ The "System Libraries" of an executable work include anything, other
+than the work as a whole, that (a) is included in the normal form of
+packaging a Major Component, but which is not part of that Major
+Component, and (b) serves only to enable use of the work with that
+Major Component, or to implement a Standard Interface for which an
+implementation is available to the public in source code form. A
+"Major Component", in this context, means a major essential component
+(kernel, window system, and so on) of the specific operating system
+(if any) on which the executable work runs, or a compiler used to
+produce the work, or an object code interpreter used to run it.
+
+ The "Corresponding Source" for a work in object code form means all
+the source code needed to generate, install, and (for an executable
+work) run the object code and to modify the work, including scripts to
+control those activities. However, it does not include the work's
+System Libraries, or general-purpose tools or generally available free
+programs which are used unmodified in performing those activities but
+which are not part of the work. For example, Corresponding Source
+includes interface definition files associated with source files for
+the work, and the source code for shared libraries and dynamically
+linked subprograms that the work is specifically designed to require,
+such as by intimate data communication or control flow between those
+subprograms and other parts of the work.
+
+ The Corresponding Source need not include anything that users
+can regenerate automatically from other parts of the Corresponding
+Source.
+
+ The Corresponding Source for a work in source code form is that
+same work.
+
+ 2. Basic Permissions.
+
+ All rights granted under this License are granted for the term of
+copyright on the Program, and are irrevocable provided the stated
+conditions are met. This License explicitly affirms your unlimited
+permission to run the unmodified Program. The output from running a
+covered work is covered by this License only if the output, given its
+content, constitutes a covered work. This License acknowledges your
+rights of fair use or other equivalent, as provided by copyright law.
+
+ You may make, run and propagate covered works that you do not
+convey, without conditions so long as your license otherwise remains
+in force. You may convey covered works to others for the sole purpose
+of having them make modifications exclusively for you, or provide you
+with facilities for running those works, provided that you comply with
+the terms of this License in conveying all material for which you do
+not control copyright. Those thus making or running the covered works
+for you must do so exclusively on your behalf, under your direction
+and control, on terms that prohibit them from making any copies of
+your copyrighted material outside their relationship with you.
+
+ Conveying under any other circumstances is permitted solely under
+the conditions stated below. Sublicensing is not allowed; section 10
+makes it unnecessary.
+
+ 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
+
+ No covered work shall be deemed part of an effective technological
+measure under any applicable law fulfilling obligations under article
+11 of the WIPO copyright treaty adopted on 20 December 1996, or
+similar laws prohibiting or restricting circumvention of such
+measures.
+
+ When you convey a covered work, you waive any legal power to forbid
+circumvention of technological measures to the extent such circumvention
+is effected by exercising rights under this License with respect to
+the covered work, and you disclaim any intention to limit operation or
+modification of the work as a means of enforcing, against the work's
+users, your or third parties' legal rights to forbid circumvention of
+technological measures.
+
+ 4. Conveying Verbatim Copies.
+
+ You may convey verbatim copies of the Program's source code as you
+receive it, in any medium, provided that you conspicuously and
+appropriately publish on each copy an appropriate copyright notice;
+keep intact all notices stating that this License and any
+non-permissive terms added in accord with section 7 apply to the code;
+keep intact all notices of the absence of any warranty; and give all
+recipients a copy of this License along with the Program.
+
+ You may charge any price or no price for each copy that you convey,
+and you may offer support or warranty protection for a fee.
+
+ 5. Conveying Modified Source Versions.
+
+ You may convey a work based on the Program, or the modifications to
+produce it from the Program, in the form of source code under the
+terms of section 4, provided that you also meet all of these conditions:
+
+ a) The work must carry prominent notices stating that you modified
+ it, and giving a relevant date.
+
+ b) The work must carry prominent notices stating that it is
+ released under this License and any conditions added under section
+ 7. This requirement modifies the requirement in section 4 to
+ "keep intact all notices".
+
+ c) You must license the entire work, as a whole, under this
+ License to anyone who comes into possession of a copy. This
+ License will therefore apply, along with any applicable section 7
+ additional terms, to the whole of the work, and all its parts,
+ regardless of how they are packaged. This License gives no
+ permission to license the work in any other way, but it does not
+ invalidate such permission if you have separately received it.
+
+ d) If the work has interactive user interfaces, each must display
+ Appropriate Legal Notices; however, if the Program has interactive
+ interfaces that do not display Appropriate Legal Notices, your
+ work need not make them do so.
+
+ A compilation of a covered work with other separate and independent
+works, which are not by their nature extensions of the covered work,
+and which are not combined with it such as to form a larger program,
+in or on a volume of a storage or distribution medium, is called an
+"aggregate" if the compilation and its resulting copyright are not
+used to limit the access or legal rights of the compilation's users
+beyond what the individual works permit. Inclusion of a covered work
+in an aggregate does not cause this License to apply to the other
+parts of the aggregate.
+
+ 6. Conveying Non-Source Forms.
+
+ You may convey a covered work in object code form under the terms
+of sections 4 and 5, provided that you also convey the
+machine-readable Corresponding Source under the terms of this License,
+in one of these ways:
+
+ a) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by the
+ Corresponding Source fixed on a durable physical medium
+ customarily used for software interchange.
+
+ b) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by a
+ written offer, valid for at least three years and valid for as
+ long as you offer spare parts or customer support for that product
+ model, to give anyone who possesses the object code either (1) a
+ copy of the Corresponding Source for all the software in the
+ product that is covered by this License, on a durable physical
+ medium customarily used for software interchange, for a price no
+ more than your reasonable cost of physically performing this
+ conveying of source, or (2) access to copy the
+ Corresponding Source from a network server at no charge.
+
+ c) Convey individual copies of the object code with a copy of the
+ written offer to provide the Corresponding Source. This
+ alternative is allowed only occasionally and noncommercially, and
+ only if you received the object code with such an offer, in accord
+ with subsection 6b.
+
+ d) Convey the object code by offering access from a designated
+ place (gratis or for a charge), and offer equivalent access to the
+ Corresponding Source in the same way through the same place at no
+ further charge. You need not require recipients to copy the
+ Corresponding Source along with the object code. If the place to
+ copy the object code is a network server, the Corresponding Source
+ may be on a different server (operated by you or a third party)
+ that supports equivalent copying facilities, provided you maintain
+ clear directions next to the object code saying where to find the
+ Corresponding Source. Regardless of what server hosts the
+ Corresponding Source, you remain obligated to ensure that it is
+ available for as long as needed to satisfy these requirements.
+
+ e) Convey the object code using peer-to-peer transmission, provided
+ you inform other peers where the object code and Corresponding
+ Source of the work are being offered to the general public at no
+ charge under subsection 6d.
+
+ A separable portion of the object code, whose source code is excluded
+from the Corresponding Source as a System Library, need not be
+included in conveying the object code work.
+
+ A "User Product" is either (1) a "consumer product", which means any
+tangible personal property which is normally used for personal, family,
+or household purposes, or (2) anything designed or sold for incorporation
+into a dwelling. In determining whether a product is a consumer product,
+doubtful cases shall be resolved in favor of coverage. For a particular
+product received by a particular user, "normally used" refers to a
+typical or common use of that class of product, regardless of the status
+of the particular user or of the way in which the particular user
+actually uses, or expects or is expected to use, the product. A product
+is a consumer product regardless of whether the product has substantial
+commercial, industrial or non-consumer uses, unless such uses represent
+the only significant mode of use of the product.
+
+ "Installation Information" for a User Product means any methods,
+procedures, authorization keys, or other information required to install
+and execute modified versions of a covered work in that User Product from
+a modified version of its Corresponding Source. The information must
+suffice to ensure that the continued functioning of the modified object
+code is in no case prevented or interfered with solely because
+modification has been made.
+
+ If you convey an object code work under this section in, or with, or
+specifically for use in, a User Product, and the conveying occurs as
+part of a transaction in which the right of possession and use of the
+User Product is transferred to the recipient in perpetuity or for a
+fixed term (regardless of how the transaction is characterized), the
+Corresponding Source conveyed under this section must be accompanied
+by the Installation Information. But this requirement does not apply
+if neither you nor any third party retains the ability to install
+modified object code on the User Product (for example, the work has
+been installed in ROM).
+
+ The requirement to provide Installation Information does not include a
+requirement to continue to provide support service, warranty, or updates
+for a work that has been modified or installed by the recipient, or for
+the User Product in which it has been modified or installed. Access to a
+network may be denied when the modification itself materially and
+adversely affects the operation of the network or violates the rules and
+protocols for communication across the network.
+
+ Corresponding Source conveyed, and Installation Information provided,
+in accord with this section must be in a format that is publicly
+documented (and with an implementation available to the public in
+source code form), and must require no special password or key for
+unpacking, reading or copying.
+
+ 7. Additional Terms.
+
+ "Additional permissions" are terms that supplement the terms of this
+License by making exceptions from one or more of its conditions.
+Additional permissions that are applicable to the entire Program shall
+be treated as though they were included in this License, to the extent
+that they are valid under applicable law. If additional permissions
+apply only to part of the Program, that part may be used separately
+under those permissions, but the entire Program remains governed by
+this License without regard to the additional permissions.
+
+ When you convey a copy of a covered work, you may at your option
+remove any additional permissions from that copy, or from any part of
+it. (Additional permissions may be written to require their own
+removal in certain cases when you modify the work.) You may place
+additional permissions on material, added by you to a covered work,
+for which you have or can give appropriate copyright permission.
+
+ Notwithstanding any other provision of this License, for material you
+add to a covered work, you may (if authorized by the copyright holders of
+that material) supplement the terms of this License with terms:
+
+ a) Disclaiming warranty or limiting liability differently from the
+ terms of sections 15 and 16 of this License; or
+
+ b) Requiring preservation of specified reasonable legal notices or
+ author attributions in that material or in the Appropriate Legal
+ Notices displayed by works containing it; or
+
+ c) Prohibiting misrepresentation of the origin of that material, or
+ requiring that modified versions of such material be marked in
+ reasonable ways as different from the original version; or
+
+ d) Limiting the use for publicity purposes of names of licensors or
+ authors of the material; or
+
+ e) Declining to grant rights under trademark law for use of some
+ trade names, trademarks, or service marks; or
+
+ f) Requiring indemnification of licensors and authors of that
+ material by anyone who conveys the material (or modified versions of
+ it) with contractual assumptions of liability to the recipient, for
+ any liability that these contractual assumptions directly impose on
+ those licensors and authors.
+
+ All other non-permissive additional terms are considered "further
+restrictions" within the meaning of section 10. If the Program as you
+received it, or any part of it, contains a notice stating that it is
+governed by this License along with a term that is a further
+restriction, you may remove that term. If a license document contains
+a further restriction but permits relicensing or conveying under this
+License, you may add to a covered work material governed by the terms
+of that license document, provided that the further restriction does
+not survive such relicensing or conveying.
+
+ If you add terms to a covered work in accord with this section, you
+must place, in the relevant source files, a statement of the
+additional terms that apply to those files, or a notice indicating
+where to find the applicable terms.
+
+ Additional terms, permissive or non-permissive, may be stated in the
+form of a separately written license, or stated as exceptions;
+the above requirements apply either way.
+
+ 8. Termination.
+
+ You may not propagate or modify a covered work except as expressly
+provided under this License. Any attempt otherwise to propagate or
+modify it is void, and will automatically terminate your rights under
+this License (including any patent licenses granted under the third
+paragraph of section 11).
+
+ However, if you cease all violation of this License, then your
+license from a particular copyright holder is reinstated (a)
+provisionally, unless and until the copyright holder explicitly and
+finally terminates your license, and (b) permanently, if the copyright
+holder fails to notify you of the violation by some reasonable means
+prior to 60 days after the cessation.
+
+ Moreover, your license from a particular copyright holder is
+reinstated permanently if the copyright holder notifies you of the
+violation by some reasonable means, this is the first time you have
+received notice of violation of this License (for any work) from that
+copyright holder, and you cure the violation prior to 30 days after
+your receipt of the notice.
+
+ Termination of your rights under this section does not terminate the
+licenses of parties who have received copies or rights from you under
+this License. If your rights have been terminated and not permanently
+reinstated, you do not qualify to receive new licenses for the same
+material under section 10.
+
+ 9. Acceptance Not Required for Having Copies.
+
+ You are not required to accept this License in order to receive or
+run a copy of the Program. Ancillary propagation of a covered work
+occurring solely as a consequence of using peer-to-peer transmission
+to receive a copy likewise does not require acceptance. However,
+nothing other than this License grants you permission to propagate or
+modify any covered work. These actions infringe copyright if you do
+not accept this License. Therefore, by modifying or propagating a
+covered work, you indicate your acceptance of this License to do so.
+
+ 10. Automatic Licensing of Downstream Recipients.
+
+ Each time you convey a covered work, the recipient automatically
+receives a license from the original licensors, to run, modify and
+propagate that work, subject to this License. You are not responsible
+for enforcing compliance by third parties with this License.
+
+ An "entity transaction" is a transaction transferring control of an
+organization, or substantially all assets of one, or subdividing an
+organization, or merging organizations. If propagation of a covered
+work results from an entity transaction, each party to that
+transaction who receives a copy of the work also receives whatever
+licenses to the work the party's predecessor in interest had or could
+give under the previous paragraph, plus a right to possession of the
+Corresponding Source of the work from the predecessor in interest, if
+the predecessor has it or can get it with reasonable efforts.
+
+ You may not impose any further restrictions on the exercise of the
+rights granted or affirmed under this License. For example, you may
+not impose a license fee, royalty, or other charge for exercise of
+rights granted under this License, and you may not initiate litigation
+(including a cross-claim or counterclaim in a lawsuit) alleging that
+any patent claim is infringed by making, using, selling, offering for
+sale, or importing the Program or any portion of it.
+
+ 11. Patents.
+
+ A "contributor" is a copyright holder who authorizes use under this
+License of the Program or a work on which the Program is based. The
+work thus licensed is called the contributor's "contributor version".
+
+ A contributor's "essential patent claims" are all patent claims
+owned or controlled by the contributor, whether already acquired or
+hereafter acquired, that would be infringed by some manner, permitted
+by this License, of making, using, or selling its contributor version,
+but do not include claims that would be infringed only as a
+consequence of further modification of the contributor version. For
+purposes of this definition, "control" includes the right to grant
+patent sublicenses in a manner consistent with the requirements of
+this License.
+
+ Each contributor grants you a non-exclusive, worldwide, royalty-free
+patent license under the contributor's essential patent claims, to
+make, use, sell, offer for sale, import and otherwise run, modify and
+propagate the contents of its contributor version.
+
+ In the following three paragraphs, a "patent license" is any express
+agreement or commitment, however denominated, not to enforce a patent
+(such as an express permission to practice a patent or covenant not to
+sue for patent infringement). To "grant" such a patent license to a
+party means to make such an agreement or commitment not to enforce a
+patent against the party.
+
+ If you convey a covered work, knowingly relying on a patent license,
+and the Corresponding Source of the work is not available for anyone
+to copy, free of charge and under the terms of this License, through a
+publicly available network server or other readily accessible means,
+then you must either (1) cause the Corresponding Source to be so
+available, or (2) arrange to deprive yourself of the benefit of the
+patent license for this particular work, or (3) arrange, in a manner
+consistent with the requirements of this License, to extend the patent
+license to downstream recipients. "Knowingly relying" means you have
+actual knowledge that, but for the patent license, your conveying the
+covered work in a country, or your recipient's use of the covered work
+in a country, would infringe one or more identifiable patents in that
+country that you have reason to believe are valid.
+
+ If, pursuant to or in connection with a single transaction or
+arrangement, you convey, or propagate by procuring conveyance of, a
+covered work, and grant a patent license to some of the parties
+receiving the covered work authorizing them to use, propagate, modify
+or convey a specific copy of the covered work, then the patent license
+you grant is automatically extended to all recipients of the covered
+work and works based on it.
+
+ A patent license is "discriminatory" if it does not include within
+the scope of its coverage, prohibits the exercise of, or is
+conditioned on the non-exercise of one or more of the rights that are
+specifically granted under this License. You may not convey a covered
+work if you are a party to an arrangement with a third party that is
+in the business of distributing software, under which you make payment
+to the third party based on the extent of your activity of conveying
+the work, and under which the third party grants, to any of the
+parties who would receive the covered work from you, a discriminatory
+patent license (a) in connection with copies of the covered work
+conveyed by you (or copies made from those copies), or (b) primarily
+for and in connection with specific products or compilations that
+contain the covered work, unless you entered into that arrangement,
+or that patent license was granted, prior to 28 March 2007.
+
+ Nothing in this License shall be construed as excluding or limiting
+any implied license or other defenses to infringement that may
+otherwise be available to you under applicable patent law.
+
+ 12. No Surrender of Others' Freedom.
+
+ If conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License. If you cannot convey a
+covered work so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you may
+not convey it at all. For example, if you agree to terms that obligate you
+to collect a royalty for further conveying from those to whom you convey
+the Program, the only way you could satisfy both those terms and this
+License would be to refrain entirely from conveying the Program.
+
+ 13. Remote Network Interaction; Use with the GNU General Public License.
+
+ Notwithstanding any other provision of this License, if you modify the
+Program, your modified version must prominently offer all users
+interacting with it remotely through a computer network (if your version
+supports such interaction) an opportunity to receive the Corresponding
+Source of your version by providing access to the Corresponding Source
+from a network server at no charge, through some standard or customary
+means of facilitating copying of software. This Corresponding Source
+shall include the Corresponding Source for any work covered by version 3
+of the GNU General Public License that is incorporated pursuant to the
+following paragraph.
+
+ Notwithstanding any other provision of this License, you have
+permission to link or combine any covered work with a work licensed
+under version 3 of the GNU General Public License into a single
+combined work, and to convey the resulting work. The terms of this
+License will continue to apply to the part which is the covered work,
+but the work with which it is combined will remain governed by version
+3 of the GNU General Public License.
+
+ 14. Revised Versions of this License.
+
+ The Free Software Foundation may publish revised and/or new versions of
+the GNU Affero General Public License from time to time. Such new versions
+will be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+ Each version is given a distinguishing version number. If the
+Program specifies that a certain numbered version of the GNU Affero General
+Public License "or any later version" applies to it, you have the
+option of following the terms and conditions either of that numbered
+version or of any later version published by the Free Software
+Foundation. If the Program does not specify a version number of the
+GNU Affero General Public License, you may choose any version ever published
+by the Free Software Foundation.
+
+ If the Program specifies that a proxy can decide which future
+versions of the GNU Affero General Public License can be used, that proxy's
+public statement of acceptance of a version permanently authorizes you
+to choose that version for the Program.
+
+ Later license versions may give you additional or different
+permissions. However, no additional obligations are imposed on any
+author or copyright holder as a result of your choosing to follow a
+later version.
+
+ 15. Disclaimer of Warranty.
+
+ THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
+APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
+HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
+OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
+IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
+ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+ 16. Limitation of Liability.
+
+ IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
+THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
+USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
+DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
+PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
+EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
+SUCH DAMAGES.
+
+ 17. Interpretation of Sections 15 and 16.
+
+ If the disclaimer of warranty and limitation of liability provided
+above cannot be given local legal effect according to their terms,
+reviewing courts shall apply local law that most closely approximates
+an absolute waiver of all civil liability in connection with the
+Program, unless a warranty or assumption of liability accompanies a
+copy of the Program in return for a fee.
+
+ END OF TERMS AND CONDITIONS
+
+ How to Apply These Terms to Your New Programs
+
+ If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+ To do so, attach the following notices to the program. It is safest
+to attach them to the start of each source file to most effectively
+state the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+
+ Copyright (C)
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see .
+
+Also add information on how to contact you by electronic and paper mail.
+
+ If your software can interact with users remotely through a computer
+network, you should also make sure that it provides a way for users to
+get its source. For example, if your program is a web application, its
+interface could display a "Source" link that leads users to an archive
+of the code. There are many ways you could offer source, and different
+solutions will be better for different programs; see section 13 for the
+specific requirements.
+
+ You should also get your employer (if you work as a programmer) or school,
+if any, to sign a "copyright disclaimer" for the program, if necessary.
+For more information on this, and how to apply and follow the GNU AGPL, see
+.
diff --git a/README.md b/README.md
index e69de29..ba68244 100644
--- a/README.md
+++ b/README.md
@@ -0,0 +1,35 @@
+# PAS - Parallelized Allocation System
+
+PAS is a governance framework for managing rate-limited operations through authorized actors called cBeams. It provides timelocked proposal execution, configurable rate limits, and role-based access control for interacting with external controllers.
+
+## Contracts
+
+### BeamState
+
+Central registry that manages system configuration and access control. Stores which cBeams are authorized to operate on which rate limit contracts and controllers, defines default rate limits and allowed controller actions, and configures parameters like `hop` (minimum time between increases) and `maxChange` (maximum rate of change). Supports role-based permissions with actions split between timelocked and direct access.
+
+### Configurator
+
+The operational interface used by cBeams to modify rate limits and execute controller actions. Enforces that rate limit changes respect the configured ceilings and requires waiting for `hop` between increases. Also gates controller calls through pre-approved action hashes stored in BeamState.
+
+### Timelock
+
+Extended OpenZeppelin TimelockController with pausing support, permissionless execution, and operation tracking for keeper integration. Disables self-calls to prevent proposals from modifying admin settings.
+
+### PASMom
+
+Emergency governance contract that allows authorized parties to trigger circuit breakers. Can call `stop()` on BeamState to halt Configurator operations and `pause()` on Timelock to block scheduling and execution. Callable by the owner or via the Chief's hat through the authority.
+
+## Unlimited rate limits
+
+A rate limit key is treated as unlimited — the Configurator forces any cBeam call to keep it at `(max, 0)` and rejects any attempt to lower it — in either of these cases:
+
+- It is registered in BeamState as `(max, 0)` (via `addInitRateLimits`), or
+- It is currently `(max, 0)` in RateLimits and has no BeamState default for that key.
+
+The second case protects keys that are already unlimited (e.g. withdrawal keys kept unlimited for security) even when they were never registered: a cBeam cannot unilateraly reset such a key to `0`. This holds both when first enabling the Configurator on an already-active PAU and when later opting into a facet that adds such a key.
+
+To make an existing unlimited key adjustable by a cBeam (e.g. to bound it), a `roleAuth` caller must register a bounded default for it via `addInitRateLimits` (through a star spell or the Timelock, depending on how the action is routed); the key then follows the normal bounded-limit rules.
+Note that the bounded default must be nonzero in at least one field as (0, 0) is indistinguishable from an unregistered key.
+
+**Warning:** unlocking a protected unlimited key (by registering a bounded default) hands control of it to the cBeam — which can then set it to any value down to `0`, immediately. Treat it as a deliberate governance decision: use a **key-rateLimits** specific default (never the general `address(0)` slot, which applies to that key across all RateLimits contracts), and only for keys you intend to make cBeam-adjustable.
diff --git a/SECURITY.md b/SECURITY.md
new file mode 100644
index 0000000..ec42ddd
--- /dev/null
+++ b/SECURITY.md
@@ -0,0 +1,22 @@
+# Notes and Trust Assumptions
+
+- The following entities are trusted:
+ - **Timelock**: Fully trusted.
+ - **Core Council through direct actions**: Very highly trusted.
+ - **cBEAMs**: Mostly trusted (a trusted team's multisig, malicious activity is assumed to be of very low likelihood).
+
+- It is assumed proposers or cancelers would not cancel proposals maliciously (including Core Council).
+- It is assumed that before adding calldata, rate limits, or whitelisting targets, security related scenarios are thoroughly considered.
+- As part of the above considerations, possible misalignment of cBEAMs is expected to be taken into consideration (although unlikely). For example:
+ 1. Omission - not executing a call when expected
+ 2. Double execution - executing the same call twice
+ 3. Stale execution - executing an old call after a newer one superseded it
+ 4. Reordering - executing calls in the wrong order
+- It is also expected to be considered whether cBEAMs can block withdrawals in case of emergencies (for example, when needed, infinity rate limit would be configured).
+- It is assumed the Core Council would not block withdrawals or emergency mechanisms on purpose.
+- It is expected to be considered whether a default rate limits / calldata can be applied simultaneously on many controllers, thus amplifying potential harm. For example, the damage of depositing into a new vault without applying slippage protection might be amplified if it can be done for all whitelisted controllers. In general it is assumed that as the system scales more protections are added for such scenarios.
+- It is assumed that cBEAMs and Core Council are synced in their operations, including timing their actions against Timelock executions.
+- It is assumed the cancelers and Core Council are available on short notice to remove proposals and configurations if needed. This includes removing old calldata/rate-limits in a timely manner.
+- It is assumed pausers would not temporarily DoS the timelock on purpose.
+- As with other Sky contracts, a mom mechanism is not expected to be deployed on remote chains. It is expected to be sufficiently replaced with highly secured and trusted multisigs.
+
diff --git a/audit/20260219-cantina-report-sky-pas.pdf b/audit/20260219-cantina-report-sky-pas.pdf
new file mode 100644
index 0000000..8db05e0
Binary files /dev/null and b/audit/20260219-cantina-report-sky-pas.pdf differ
diff --git a/audit/20260506-ChainSecurity_Sky_PAS_audit.pdf b/audit/20260506-ChainSecurity_Sky_PAS_audit.pdf
new file mode 100644
index 0000000..304ef6b
Binary files /dev/null and b/audit/20260506-ChainSecurity_Sky_PAS_audit.pdf differ
diff --git a/audit/20260521-cantina-report-sky-pas.pdf b/audit/20260521-cantina-report-sky-pas.pdf
new file mode 100644
index 0000000..f2b39ba
Binary files /dev/null and b/audit/20260521-cantina-report-sky-pas.pdf differ
diff --git a/deploy/L2PASSpell.sol b/deploy/L2PASSpell.sol
new file mode 100644
index 0000000..31323ff
--- /dev/null
+++ b/deploy/L2PASSpell.sol
@@ -0,0 +1,53 @@
+// SPDX-FileCopyrightText: © 2026 Dai Foundation
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+pragma solidity ^0.8.24;
+
+import { PASInit } from "./PASInit.sol";
+import { PASInstance } from "./PASInstance.sol";
+
+// An L2 spell template for L2GovernanceRelay to initialize PAS contracts on a foreign chain.
+// This spell should be viewed as skeleton/sample and can be altered prior to being used
+// if further configurations are needed (for example adding more proposers).
+contract L2PASSpell {
+
+ address public immutable beamState;
+ address public immutable configurator;
+ address public immutable timelock;
+
+ constructor(address beamState_, address configurator_, address timelock_) {
+ beamState = beamState_;
+ configurator = configurator_;
+ timelock = timelock_;
+ }
+
+ function init(
+ uint256 minDelay,
+ address coreCouncil,
+ address[] memory cancellers,
+ address[] memory pausers,
+ bool startPaused
+ ) external {
+ PASInstance memory pas = PASInstance({
+ beamState: beamState,
+ configurator: configurator,
+ timelock: timelock
+ });
+
+ PASInit.init(pas, minDelay, coreCouncil, cancellers, pausers);
+ if (startPaused) PASInit.pauseTimelock(timelock, address(this));
+ }
+}
diff --git a/deploy/PASAuthorizeInPAU.sol b/deploy/PASAuthorizeInPAU.sol
new file mode 100644
index 0000000..f9ca571
--- /dev/null
+++ b/deploy/PASAuthorizeInPAU.sol
@@ -0,0 +1,51 @@
+// SPDX-FileCopyrightText: © 2026 Dai Foundation
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+pragma solidity >=0.8.0;
+
+interface AccessControlLike {
+ function grantRole(bytes32 role, address account) external;
+}
+
+// Authorizes the PAS Configurator within a Star's PAU access-control system.
+//
+// Counterpart to PASInit: whereas PASInit configures the PAS-owned contracts (BeamState,
+// Configurator, Timelock), this is run by a Star's governance (the current DEFAULT_ADMIN_ROLE
+// holder on its PAU controller stack) to grant the PAS Configurator the admin access it needs
+// on the Star's own contracts.
+//
+// The two grants cover everything the Configurator calls:
+// - accessControls: gates every facet admin setter dispatched via `callControllerAction`
+// (the controller has no roles of its own; it delegates to AccessControls).
+// - rateLimits: gates `setRateLimit` (setRateLimitData / setUnlimitedRateLimitData).
+//
+// NOTE: DEFAULT_ADMIN_ROLE is the OZ role-admin, so each grant makes the Configurator a full
+// role-superadmin of that contract (it can grant/revoke any role, including allocators). This
+// is the intended trust model but has a wide blast radius; the security of these roles rests
+// entirely on the timelock/cBeam/BeamState gating in front of the Configurator.
+library PASAuthorizeInPAU {
+
+ bytes32 internal constant DEFAULT_ADMIN_ROLE = 0x00;
+
+ function authorize(
+ address configurator,
+ address accessControls,
+ address rateLimits
+ ) internal {
+ AccessControlLike(accessControls).grantRole(DEFAULT_ADMIN_ROLE, configurator);
+ AccessControlLike(rateLimits).grantRole(DEFAULT_ADMIN_ROLE, configurator);
+ }
+}
diff --git a/deploy/PASDeploy.sol b/deploy/PASDeploy.sol
new file mode 100644
index 0000000..d366e82
--- /dev/null
+++ b/deploy/PASDeploy.sol
@@ -0,0 +1,53 @@
+// SPDX-FileCopyrightText: © 2026 Dai Foundation
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+pragma solidity ^0.8.24;
+
+import { ScriptTools } from "dss-test/ScriptTools.sol";
+import { PASInstance } from "./PASInstance.sol";
+import { BeamState } from "src/BeamState.sol";
+import { Configurator } from "src/Configurator.sol";
+import { Timelock } from "src/timelock/Timelock.sol";
+import { PASMom } from "src/PASMom.sol";
+
+library PASDeploy {
+
+ function deploy(
+ address deployer,
+ address owner,
+ uint256 minDelay
+ ) internal returns (PASInstance memory pasInstance) {
+ pasInstance.beamState = address(new BeamState());
+ pasInstance.configurator = address(new Configurator(pasInstance.beamState));
+ pasInstance.timelock = address(new Timelock(
+ minDelay,
+ owner
+ ));
+ ScriptTools.switchOwner(pasInstance.beamState, deployer, owner);
+ }
+
+ function deployMom(
+ address owner,
+ address beamState,
+ address timelock
+ ) internal returns (address mom) {
+ mom = address(new PASMom(
+ beamState,
+ timelock
+ ));
+ PASMom(mom).setOwner(owner);
+ }
+}
diff --git a/deploy/PASInit.sol b/deploy/PASInit.sol
new file mode 100644
index 0000000..a0dcc12
--- /dev/null
+++ b/deploy/PASInit.sol
@@ -0,0 +1,258 @@
+// SPDX-FileCopyrightText: © 2026 Dai Foundation
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+pragma solidity >=0.8.0;
+
+import { DssInstance } from "dss-test/MCD.sol";
+import { PASInstance } from "./PASInstance.sol";
+
+interface BeamStateLike {
+ function rely(address) external;
+ function setUserRole(address, uint8, bool) external;
+ function setRoleAction(uint8, bytes4, bool) external;
+ function stop() external;
+ function start() external;
+ function setHop(address, uint256) external;
+ function setMaxChange(address, uint256) external;
+ function addRateLimits(address) external;
+ function delRateLimits(address) external;
+ function addController(address) external;
+ function delController(address) external;
+ function addCBeam(address) external;
+ function delCBeam(address) external;
+ function setCBeamForRateLimits(address, address) external;
+ function unsetCBeamForRateLimits(address, address) external;
+ function setCBeamForController(address, address) external;
+ function unsetCBeamForController(address, address) external;
+ function addInitRateLimits(bytes32, address, uint256, uint256) external;
+ function delInitRateLimits(bytes32, address) external;
+ function addInitControllerActions(bytes calldata data, address) external;
+ function delInitControllerActions(bytes32, address) external;
+}
+
+interface ConfiguratorLike {
+ function beamState() external view returns (address);
+}
+
+interface TimelockLike {
+ function getMinDelay() external view returns (uint256);
+ function PROPOSER_ROLE() external view returns (bytes32);
+ function CANCELLER_ROLE() external view returns (bytes32);
+ function PAUSER_ROLE() external view returns (bytes32);
+ function grantRole(bytes32, address) external;
+ function revokeRole(bytes32, address) external;
+ function pause() external;
+}
+
+interface PASMomLike {
+ function beamState() external view returns (address);
+ function timelock() external view returns (address);
+ function setAuthority(address) external;
+}
+
+struct InitCBeamConfig {
+ address cBeam;
+ address[] rateLimits;
+ address[] controllers;
+}
+
+struct InitRateLimitConfig {
+ bytes32 key;
+ address rateLimits;
+ uint256 maxAmount;
+ uint256 slope;
+}
+
+struct InitControllerActionConfig {
+ bytes data;
+ address controller;
+}
+
+library PASInit {
+
+ // It is noted that delayed and immediate operations order is non deterministic.
+ // The relevant operators of each role are assumed to communicate and track timelock and configurator executions.
+ enum Role {
+ _UNSET, // 0 (unused)
+ DELAYED, // 1
+ IMMEDIATE // 2
+ }
+
+ function init(
+ PASInstance memory pasInstance,
+ uint256 minDelay,
+ address coreCouncil,
+ address[] memory cancellers,
+ address[] memory pausers
+ ) internal {
+ BeamStateLike beamState = BeamStateLike(pasInstance.beamState);
+ ConfiguratorLike configurator = ConfiguratorLike(pasInstance.configurator);
+ TimelockLike timelock = TimelockLike(pasInstance.timelock);
+
+ // --- Sanity checks ---
+
+ require(configurator.beamState() == address(beamState), "PASInit/configurator-beamState-mismatch");
+ require(timelock.getMinDelay() == minDelay, "PASInit/timelock-minDelay-mismatch");
+
+ // --- Configure BeamState ---
+
+ // Define Beam actions that are accessed through timelock (DELAYED) and directly (IMMEDIATE)
+ beamState.setRoleAction(uint8(Role.DELAYED), BeamStateLike.start.selector, true);
+ beamState.setRoleAction(uint8(Role.DELAYED), BeamStateLike.setHop.selector, true);
+ beamState.setRoleAction(uint8(Role.DELAYED), BeamStateLike.setMaxChange.selector, true);
+ beamState.setRoleAction(uint8(Role.DELAYED), BeamStateLike.addRateLimits.selector, true);
+ beamState.setRoleAction(uint8(Role.DELAYED), BeamStateLike.addController.selector, true);
+ beamState.setRoleAction(uint8(Role.DELAYED), BeamStateLike.addCBeam.selector, true);
+ beamState.setRoleAction(uint8(Role.DELAYED), BeamStateLike.addInitRateLimits.selector, true);
+ beamState.setRoleAction(uint8(Role.DELAYED), BeamStateLike.addInitControllerActions.selector, true);
+ beamState.setRoleAction(uint8(Role.IMMEDIATE), BeamStateLike.stop.selector, true);
+ beamState.setRoleAction(uint8(Role.IMMEDIATE), BeamStateLike.delRateLimits.selector, true);
+ beamState.setRoleAction(uint8(Role.IMMEDIATE), BeamStateLike.delController.selector, true);
+ beamState.setRoleAction(uint8(Role.IMMEDIATE), BeamStateLike.delCBeam.selector, true);
+ beamState.setRoleAction(uint8(Role.IMMEDIATE), BeamStateLike.setCBeamForRateLimits.selector, true);
+ beamState.setRoleAction(uint8(Role.IMMEDIATE), BeamStateLike.unsetCBeamForRateLimits.selector, true);
+ beamState.setRoleAction(uint8(Role.IMMEDIATE), BeamStateLike.setCBeamForController.selector, true);
+ beamState.setRoleAction(uint8(Role.IMMEDIATE), BeamStateLike.unsetCBeamForController.selector, true);
+ beamState.setRoleAction(uint8(Role.IMMEDIATE), BeamStateLike.delInitRateLimits.selector, true);
+ beamState.setRoleAction(uint8(Role.IMMEDIATE), BeamStateLike.delInitControllerActions.selector, true);
+
+ // Set timelock as the user with DELAYED role and coreCouncil with IMMEDIATE role
+ beamState.setUserRole(address(timelock), uint8(Role.DELAYED), true);
+ beamState.setUserRole(coreCouncil, uint8(Role.IMMEDIATE), true);
+
+ // --- Configure Timelock ---
+
+ // Grant the coreCouncil as the proposer in the timelock directly
+ // Grant cancellers and pausers in timelock with their respective roles
+ timelock.grantRole(timelock.PROPOSER_ROLE(), coreCouncil);
+ timelock.grantRole(timelock.CANCELLER_ROLE(), coreCouncil);
+ for (uint256 i = 0; i < cancellers.length; ++i) {
+ timelock.grantRole(timelock.CANCELLER_ROLE(), cancellers[i]);
+ }
+ for (uint256 i = 0; i < pausers.length; ++i) {
+ timelock.grantRole(timelock.PAUSER_ROLE(), pausers[i]);
+ }
+ }
+
+ // Call after `init` for starting with spell-only configurations of `DELAYED` actions
+ // The admin is assumed not to be listed in the pausers array, so we do not mistakenly revoke its role
+ function pauseTimelock(
+ address timelock_,
+ address admin
+ ) internal {
+ TimelockLike timelock = TimelockLike(timelock_);
+
+ timelock.grantRole(timelock.PAUSER_ROLE(), admin);
+ timelock.pause();
+ timelock.revokeRole(timelock.PAUSER_ROLE(), admin);
+ }
+
+ function initExtras(
+ PASInstance memory pasInstance,
+ uint256 hop,
+ uint256 maxChange,
+ address[] memory rateLimits,
+ address[] memory controllers,
+ InitCBeamConfig[] memory cBeamConfigs
+ ) internal {
+ require(hop > 0, "PASInit/hop-is-zero");
+
+ BeamStateLike beamState = BeamStateLike(pasInstance.beamState);
+
+ beamState.setHop(address(0), hop);
+ beamState.setMaxChange(address(0), maxChange);
+ for(uint256 i; i < rateLimits.length; i++) {
+ beamState.addRateLimits(rateLimits[i]);
+ }
+ for(uint256 i; i < controllers.length; i++) {
+ beamState.addController(controllers[i]);
+ }
+ for(uint256 i; i < cBeamConfigs.length; i++) {
+ InitCBeamConfig memory c = cBeamConfigs[i];
+ beamState.addCBeam(c.cBeam);
+ for(uint256 j; j < c.rateLimits.length; j++) {
+ beamState.setCBeamForRateLimits(c.rateLimits[j], c.cBeam);
+ }
+ for(uint256 j; j < c.controllers.length; j++) {
+ beamState.setCBeamForController(c.controllers[j], c.cBeam);
+ }
+ }
+ }
+
+ function initLimitsAndControllerData(
+ PASInstance memory pasInstance,
+ InitRateLimitConfig[] memory rateLimitConfigs,
+ InitControllerActionConfig[] memory controllerActionConfigs
+ ) internal {
+ BeamStateLike beamState = BeamStateLike(pasInstance.beamState);
+
+ for (uint256 i; i < rateLimitConfigs.length; i++) {
+ beamState.addInitRateLimits(
+ rateLimitConfigs[i].key,
+ rateLimitConfigs[i].rateLimits,
+ rateLimitConfigs[i].maxAmount,
+ rateLimitConfigs[i].slope
+ );
+ }
+ for (uint256 i; i < controllerActionConfigs.length; i++) {
+ beamState.addInitControllerActions(
+ controllerActionConfigs[i].data,
+ controllerActionConfigs[i].controller
+ );
+ }
+ }
+
+ function addCoreToChainlog(
+ DssInstance memory dss,
+ PASInstance memory pasInstance,
+ bytes32 stateKey,
+ bytes32 configuratorKey,
+ bytes32 timelockKey
+ ) internal {
+ dss.chainlog.setAddress(stateKey, pasInstance.beamState);
+ dss.chainlog.setAddress(configuratorKey, pasInstance.configurator);
+ dss.chainlog.setAddress(timelockKey, pasInstance.timelock);
+ }
+
+ function initMom(
+ DssInstance memory dss,
+ PASInstance memory pasInstance,
+ address mom_,
+ bytes32 key
+ ) internal {
+ BeamStateLike beamState = BeamStateLike(pasInstance.beamState);
+ TimelockLike timelock = TimelockLike(pasInstance.timelock);
+ PASMomLike mom = PASMomLike(mom_);
+
+ // --- Sanity checks ---
+
+ require(mom.beamState() == address(beamState), "PASInit/mom-beamState-mismatch");
+ require(mom.timelock() == address(timelock), "PASInit/mom-timelock-mismatch");
+
+ // --- Set permissions ---
+
+ // Rely Mom on BeamState to call stop()
+ beamState.rely(address(mom));
+ // Give Mom the PAUSER_ROLE to call pause() on Timelock
+ timelock.grantRole(timelock.PAUSER_ROLE(), address(mom));
+ // Set Mom's authority to MCD_ADM
+ mom.setAuthority(dss.chainlog.getAddress("MCD_ADM"));
+
+ // --- Chainlog ---
+
+ dss.chainlog.setAddress(key, address(mom));
+ }
+}
diff --git a/deploy/PASInstance.sol b/deploy/PASInstance.sol
new file mode 100644
index 0000000..2e43739
--- /dev/null
+++ b/deploy/PASInstance.sol
@@ -0,0 +1,23 @@
+// SPDX-FileCopyrightText: © 2026 Dai Foundation
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+pragma solidity >=0.8.0;
+
+struct PASInstance {
+ address beamState;
+ address configurator;
+ address timelock;
+}
diff --git a/foundry.toml b/foundry.toml
index 25b918f..bb34125 100644
--- a/foundry.toml
+++ b/foundry.toml
@@ -2,5 +2,10 @@
src = "src"
out = "out"
libs = ["lib"]
+solc = "0.8.24"
+optimizer = true
+optimizer_runs = 200
+evmVersion = "cancun"
+verbosity = 1
# See more config options https://github.com/foundry-rs/foundry/blob/master/crates/config/README.md#all-options
diff --git a/lib/dss-test b/lib/dss-test
new file mode 160000
index 0000000..61cf29f
--- /dev/null
+++ b/lib/dss-test
@@ -0,0 +1 @@
+Subproject commit 61cf29fc0cf0c177a3b4072b433c43a7326ccd7b
diff --git a/lib/openzeppelin-contracts b/lib/openzeppelin-contracts
new file mode 160000
index 0000000..a83d9aa
--- /dev/null
+++ b/lib/openzeppelin-contracts
@@ -0,0 +1 @@
+Subproject commit a83d9aabbca1ad4be17acba3e1caeca90539d3cc
diff --git a/src/BeamState.sol b/src/BeamState.sol
new file mode 100644
index 0000000..146d7d8
--- /dev/null
+++ b/src/BeamState.sol
@@ -0,0 +1,268 @@
+// SPDX-FileCopyrightText: © 2026 Dai Foundation
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+pragma solidity ^0.8.24;
+
+contract BeamState {
+
+ // --- Storage variables ---
+
+ // Note: Some of the variables defined here are for controlling the actions of the Configurator.
+ // That's why they might not have a direct apparent reason within this contract itself.
+
+ // Note: allowed == 0 => false, allowed == 1 => true
+ mapping(address usr => uint256 allowed) public wards;
+ mapping(address usr => bytes32 rolesData) public userRoles;
+ mapping(bytes4 sig => bytes32 rolesData) public actionsRoles;
+ mapping(address rateLimits_ => uint256 allowed) public rateLimits;
+ mapping(address controller => uint256 allowed) public controllers;
+ mapping(address cBeam => uint256 allowed) public cBeams;
+ mapping(address rateLimits_ => mapping(address cBeam => uint256 allowed)) public rateLimitsCBeams;
+ mapping(address controller => mapping(address cBeam => uint256 allowed)) public controllersCBeams;
+ mapping(bytes32 key => mapping(address rateLimits_ => DefaultRateLimits limit)) public initRateLimits; // rateLimits == address(0) => general fallback configuration
+ mapping(bytes32 key => mapping(address controller => uint256 allowed)) public initControllerActions; // controller == address(0) => general fallback configuration
+ mapping(address rateLimits_ => uint256 value) public hop; // rateLimits == address(0) => general fallback configuration
+ mapping(address rateLimits_ => uint256 value) public maxChange; // rateLimits == address(0) => general fallback configuration
+
+ bool public stopped;
+
+ struct DefaultRateLimits {
+ uint256 maxAmount;
+ uint256 slope;
+ }
+
+ // --- Constants ---
+
+ uint256 internal constant WAD = 10**18;
+
+ // --- Events ---
+
+ event Rely(address indexed usr);
+ event Deny(address indexed usr);
+ event SetUserRole(address indexed who, uint8 indexed role, bool enabled);
+ event SetRoleAction(uint8 indexed role, bytes4 indexed sig, bool enabled);
+ event Stop();
+ event Start();
+ event SetHop(address indexed rateLimits_, uint256 value);
+ event SetMaxChange(address indexed rateLimits_, uint256 value);
+ event AddCBeam(address indexed cBeam);
+ event DelCBeam(address indexed cBeam);
+ event AddRateLimits(address indexed rateLimits_);
+ event DelRateLimits(address indexed rateLimits_);
+ event AddController(address indexed controller);
+ event DelController(address indexed controller);
+ event SetCBeamForController(address indexed controller, address indexed cBeam);
+ event UnsetCBeamForController(address indexed controller, address indexed cBeam);
+ event SetCBeamForRateLimits(address indexed rateLimits_, address indexed cBeam);
+ event UnsetCBeamForRateLimits(address indexed rateLimits_, address indexed cBeam);
+ event AddInitRateLimits(bytes32 indexed key, address indexed rateLimits_, uint256 maxAmount, uint256 slope);
+ event DelInitRateLimits(bytes32 indexed key, address indexed rateLimits_);
+ event AddInitControllerActions(bytes32 indexed key, address indexed controller);
+ event DelInitControllerActions(bytes32 indexed key, address indexed controller);
+
+ // --- Modifiers ---
+
+ modifier auth() {
+ require(wards[msg.sender] == 1, "BeamState/not-authorized");
+ _;
+ }
+
+ modifier roleAuth() {
+ require(
+ userRoles[msg.sender] & actionsRoles[msg.sig] != bytes32(0) ||
+ wards[msg.sender] == 1, "BeamState/role-not-authorized"
+ );
+ _;
+ }
+
+ // --- Constructor ---
+
+ constructor() {
+ wards[msg.sender] = 1;
+ emit Rely(msg.sender);
+ }
+
+ // --- External getters ---
+
+ function hasUserRole(address usr, uint8 role) external view returns (bool has) {
+ has = userRoles[usr] & bytes32(uint256(1) << role) != bytes32(0);
+ }
+
+ function isActionInRole(bytes4 sig, uint8 role) external view returns (bool has) {
+ has = actionsRoles[sig] & bytes32(uint256(1) << role) != bytes32(0);
+ }
+
+ function getHop(address rateLimits_) external view returns (uint256 hop_) {
+ // If not set for specific rateLimits, check in general
+ hop_ = hop[rateLimits_]; hop_ = hop_ != 0 ? hop_ : hop[address(0)];
+ }
+
+ function getMaxChange(address rateLimits_) external view returns (uint256 maxChange_) {
+ // If not set for specific rateLimits, check in general
+ maxChange_ = maxChange[rateLimits_]; maxChange_ = maxChange_ != 0 ? maxChange_ : maxChange[address(0)];
+ }
+
+ function getInitRateLimits(bytes32 key, address rateLimits_) external view returns (DefaultRateLimits memory defaultRateLimits) {
+ defaultRateLimits = initRateLimits[key][rateLimits_];
+ if (defaultRateLimits.maxAmount == 0 && defaultRateLimits.slope == 0) {
+ // If not set for specific rateLimits, check in general
+ defaultRateLimits = initRateLimits[key][address(0)];
+ }
+ }
+
+ function isControllerActionEnabled(bytes32 key, address controller) external view returns (bool ok) {
+ ok = initControllerActions[key][address(0)] == 1 || // address(0) enabled for every controller
+ initControllerActions[key][controller] == 1;
+ }
+
+ // --- Admin functions ---
+
+ function rely(address usr) external auth {
+ wards[usr] = 1;
+ emit Rely(usr);
+ }
+
+ function deny(address usr) external auth {
+ wards[usr] = 0;
+ emit Deny(usr);
+ }
+
+ function setUserRole(address who, uint8 role, bool enabled) external auth {
+ bytes32 mask = bytes32(uint256(1) << role);
+ if (enabled) {
+ userRoles[who] |= mask;
+ } else {
+ userRoles[who] &= ~mask;
+ }
+ emit SetUserRole(who, role, enabled);
+ }
+
+ function setRoleAction(uint8 role, bytes4 sig, bool enabled) external auth {
+ bytes32 mask = bytes32(uint256(1) << role);
+ if (enabled) {
+ actionsRoles[sig] |= mask;
+ } else {
+ actionsRoles[sig] &= ~mask;
+ }
+ emit SetRoleAction(role, sig, enabled);
+ }
+
+ // --- Role authed functions ---
+
+ function stop() external roleAuth {
+ stopped = true;
+ emit Stop();
+ }
+
+ function start() external roleAuth {
+ stopped = false;
+ emit Start();
+ }
+
+ function setHop(address rateLimits_, uint256 value) external roleAuth {
+ hop[rateLimits_] = value;
+ emit SetHop(rateLimits_, value);
+ }
+
+ function setMaxChange(address rateLimits_, uint256 value) external roleAuth {
+ require(value == 0 || value >= WAD, "BeamState/invalid-maxChange");
+ maxChange[rateLimits_] = value;
+ emit SetMaxChange(rateLimits_, value);
+ }
+
+ function addRateLimits(address rateLimits_) external roleAuth {
+ rateLimits[rateLimits_] = 1;
+ emit AddRateLimits(rateLimits_);
+ }
+
+ function delRateLimits(address rateLimits_) external roleAuth {
+ // Note: it's a soft deprecation avoiding pairing it with new cBeams.
+ // Existing relationships need to be individually unset for full removal.
+ rateLimits[rateLimits_] = 0;
+ emit DelRateLimits(rateLimits_);
+ }
+
+ function addController(address controller) external roleAuth {
+ controllers[controller] = 1;
+ emit AddController(controller);
+ }
+
+ function delController(address controller) external roleAuth {
+ // Note: it's a soft deprecation avoiding pairing it with new cBeams.
+ // Existing relationships need to be individually unset for full removal.
+ controllers[controller] = 0;
+ emit DelController(controller);
+ }
+
+ function addCBeam(address cBeam) external roleAuth {
+ cBeams[cBeam] = 1;
+ emit AddCBeam(cBeam);
+ }
+
+ function delCBeam(address cBeam) external roleAuth {
+ // Note: it's a soft deprecation avoiding pairing it with new rateLimits and controllers.
+ // Existing relationships need to be individually unset for full removal.
+ cBeams[cBeam] = 0;
+ emit DelCBeam(cBeam);
+ }
+
+ // Note: Once cBEAMS are whitelisted they can be assigned to controllers/rate-limits (potentially without delay),
+ // so theoretically they can be positioned to interfere with each other. This is known and assumed to be monitored.
+
+ function setCBeamForRateLimits(address rateLimits_, address cBeam) external roleAuth {
+ require(rateLimits[rateLimits_] == 1, "BeamState/not-existing-rateLimits");
+ require(cBeams[cBeam] == 1, "BeamState/not-existing-cBeam");
+ rateLimitsCBeams[rateLimits_][cBeam] = 1;
+ emit SetCBeamForRateLimits(rateLimits_, cBeam);
+ }
+
+ function unsetCBeamForRateLimits(address rateLimits_, address cBeam) external roleAuth {
+ rateLimitsCBeams[rateLimits_][cBeam] = 0;
+ emit UnsetCBeamForRateLimits(rateLimits_, cBeam);
+ }
+
+ function setCBeamForController(address controller, address cBeam) external roleAuth {
+ require(controllers[controller] == 1, "BeamState/not-existing-controller");
+ require(cBeams[cBeam] == 1, "BeamState/not-existing-cBeam");
+ controllersCBeams[controller][cBeam] = 1;
+ emit SetCBeamForController(controller, cBeam);
+ }
+
+ function unsetCBeamForController(address controller, address cBeam) external roleAuth {
+ controllersCBeams[controller][cBeam] = 0;
+ emit UnsetCBeamForController(controller, cBeam);
+ }
+
+ function addInitRateLimits(bytes32 key, address rateLimits_, uint256 maxAmount, uint256 slope) external roleAuth {
+ initRateLimits[key][rateLimits_] = DefaultRateLimits(maxAmount, slope);
+ emit AddInitRateLimits(key, rateLimits_, maxAmount, slope);
+ }
+
+ function delInitRateLimits(bytes32 key, address rateLimits_) external roleAuth {
+ delete initRateLimits[key][rateLimits_];
+ emit DelInitRateLimits(key, rateLimits_);
+ }
+
+ function addInitControllerActions(bytes calldata data, address controller) external roleAuth returns (bytes32 key) {
+ initControllerActions[key = keccak256(data)][controller] = 1;
+ emit AddInitControllerActions(key, controller);
+ }
+
+ function delInitControllerActions(bytes32 key, address controller) external roleAuth {
+ initControllerActions[key][controller] = 0;
+ emit DelInitControllerActions(key, controller);
+ }
+}
diff --git a/src/Configurator.sol b/src/Configurator.sol
new file mode 100644
index 0000000..bb37fad
--- /dev/null
+++ b/src/Configurator.sol
@@ -0,0 +1,146 @@
+// SPDX-FileCopyrightText: © 2026 Dai Foundation
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+pragma solidity ^0.8.24;
+
+interface BeamStateLike {
+ function controllersCBeams(address, address) external view returns (uint256);
+ function rateLimitsCBeams(address, address) external view returns (uint256);
+ function stopped() external view returns (bool);
+ function getHop(address) external view returns (uint256);
+ function getMaxChange(address) external view returns (uint256);
+ function getInitRateLimits(bytes32, address) external view returns (uint256, uint256);
+ function isControllerActionEnabled(bytes32, address) external view returns (bool);
+}
+
+interface RateLimitsLike {
+ struct RateLimitData {
+ uint256 maxAmount;
+ uint256 slope;
+ uint256 lastAmount;
+ uint256 lastUpdated;
+ }
+
+ function getRateLimitData(bytes32) external view returns (RateLimitData memory);
+ function getCurrentRateLimit(bytes32) external view returns (uint256);
+ function setRateLimitData(bytes32, uint256, uint256, uint256, uint256) external;
+ function setUnlimitedRateLimitData(bytes32) external;
+}
+
+contract Configurator {
+
+ // --- Storage variables ---
+
+ mapping(address rateLimits => mapping(bytes32 key => uint256 timestamp)) public zzz;
+
+ // --- Immutables ---
+
+ BeamStateLike public immutable beamState;
+
+ // --- Constants ---
+
+ uint256 internal constant WAD = 10**18;
+
+ // --- Events ---
+
+ event SetRateLimit(address indexed rateLimits, bytes32 indexed key, uint256 maxAmount, uint256 slope);
+ event CallControllerAction(address indexed controller, bytes data);
+
+ // --- Modifiers ---
+
+ modifier notStopped() {
+ require(!beamState.stopped(), "Configurator/stopped");
+ _;
+ }
+
+ modifier authController(address controller) {
+ require(beamState.controllersCBeams(controller, msg.sender) == 1, "Configurator/not-authorized-controller-cBeam");
+ _;
+ }
+
+ modifier authRateLimits(address rateLimits) {
+ require(beamState.rateLimitsCBeams(rateLimits, msg.sender) == 1, "Configurator/not-authorized-ratelimits-cBeam");
+ _;
+ }
+
+ // --- Constructor ---
+
+ constructor(address beamState_) {
+ beamState = BeamStateLike(beamState_);
+ }
+
+ // --- Internal functions ---
+
+ function _min(uint256 x, uint256 y) internal pure returns (uint256 z) {
+ z = x < y ? x : y;
+ }
+
+ // cBeams functions
+
+ function setRateLimit(address rateLimits, bytes32 key, uint256 maxAmount, uint256 slope) external notStopped authRateLimits(rateLimits) {
+ (uint256 defMaxAmount, uint256 defSlope) = beamState.getInitRateLimits(key, rateLimits);
+ RateLimitsLike.RateLimitData memory current = RateLimitsLike(rateLimits).getRateLimitData(key);
+
+ if (defMaxAmount == type(uint256).max && defSlope == 0 ||
+ current.maxAmount == type(uint256).max && current.slope == 0 && defMaxAmount == 0 && defSlope == 0) {
+ // The key is locked as unlimited — and the cBEAM must keep it at (max, 0) — when it is
+ // either registered as unlimited in BeamState, or currently unlimited in the RateLimits with no
+ // BeamState default for it. This prevents lowering a pre-existing unlimited key.
+ require(maxAmount == type(uint256).max && slope == 0, "Configurator/unlimited-incorrect-params");
+ RateLimitsLike(rateLimits).setUnlimitedRateLimitData(key);
+ emit SetRateLimit(rateLimits, key, type(uint256).max, 0);
+ } else {
+ uint256 maxChange = beamState.getMaxChange(rateLimits);
+
+ // Ceiling is the max of (current * maxChange) and default
+ // Avoids overflow when current maxAmount is type(uint256).max and guarantees decreases are always possible even when maxChange is 0
+ require(
+ maxAmount <= defMaxAmount ||
+ maxAmount <= current.maxAmount ||
+ maxAmount <= current.maxAmount * maxChange / WAD,
+ "Configurator/exceeds-max-amount"
+ );
+ // Avoids overflow for a hypothetical case where current slope is type(uint256).max
+ require(
+ slope <= defSlope ||
+ slope <= current.slope ||
+ slope <= current.slope * maxChange / WAD,
+ "Configurator/exceeds-max-slope"
+ );
+
+ // Any increase requires hop
+ if (maxAmount > current.maxAmount || slope > current.slope) {
+ uint256 hop = beamState.getHop(rateLimits);
+ require(hop > 0, "Configurator/hop-not-set");
+ require(block.timestamp >= zzz[rateLimits][key] + hop, "Configurator/increment-too-soon");
+ zzz[rateLimits][key] = block.timestamp;
+ }
+
+ // Note that the initial capacity for a new key will be 0 (which might differ from previous usages)
+ uint256 lastAmount = RateLimitsLike(rateLimits).getCurrentRateLimit(key);
+ RateLimitsLike(rateLimits).setRateLimitData(key, maxAmount, slope, _min(maxAmount, lastAmount), block.timestamp);
+ emit SetRateLimit(rateLimits, key, maxAmount, slope);
+ }
+ }
+
+ function callControllerAction(address controller, bytes calldata data) external notStopped authController(controller) returns (bytes memory ret) {
+ require(beamState.isControllerActionEnabled(keccak256(data), controller), "Configurator/not-valid-data");
+ bool ok;
+ (ok, ret) = controller.call(data);
+ require(ok, "Configurator/call-failed");
+ emit CallControllerAction(controller, data);
+ }
+}
diff --git a/src/PASMom.sol b/src/PASMom.sol
new file mode 100644
index 0000000..4cebaa8
--- /dev/null
+++ b/src/PASMom.sol
@@ -0,0 +1,106 @@
+// SPDX-FileCopyrightText: © 2026 Dai Foundation
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+pragma solidity ^0.8.24;
+
+interface AuthorityLike {
+ function canCall(address src, address dst, bytes4 sig) external view returns (bool);
+}
+
+interface BeamStateLike {
+ function stop() external;
+}
+
+interface TimelockLike {
+ function pause() external;
+}
+
+contract PASMom {
+
+ // --- Storage variables ---
+
+ address public owner;
+ address public authority;
+
+ // --- Immutables ---
+
+ BeamStateLike public immutable beamState;
+ TimelockLike public immutable timelock;
+
+ // --- Events ---
+
+ event SetOwner(address indexed newOwner);
+ event SetAuthority(address indexed newAuthority);
+ event StopBeamState();
+ event PauseTimelock();
+
+ // --- Modifiers ---
+
+ modifier onlyOwner() {
+ require(msg.sender == owner, "PASMom/not-owner");
+ _;
+ }
+
+ modifier auth() {
+ require(isAuthorized(msg.sender, msg.sig), "PASMom/not-authorized");
+ _;
+ }
+
+ // --- Constructor ---
+
+ constructor(address beamState_, address timelock_) {
+ beamState = BeamStateLike(beamState_);
+ timelock = TimelockLike(timelock_);
+
+ owner = msg.sender;
+ emit SetOwner(msg.sender);
+ }
+
+ // --- Internal functions ---
+
+ function isAuthorized(address src, bytes4 sig) internal view returns (bool) {
+ if (src == owner) {
+ return true;
+ } else if (authority != address(0)) {
+ return AuthorityLike(authority).canCall(src, address(this), sig);
+ }
+ return false;
+ }
+
+ // --- Admin functions ---
+
+ function setOwner(address owner_) external onlyOwner {
+ owner = owner_;
+ emit SetOwner(owner_);
+ }
+
+ function setAuthority(address authority_) external onlyOwner {
+ authority = authority_;
+ emit SetAuthority(authority_);
+ }
+
+ // --- Emergency functions ---
+
+ function stop() external auth {
+ beamState.stop();
+ emit StopBeamState();
+ }
+
+ function pause() external auth {
+ timelock.pause();
+ emit PauseTimelock();
+ }
+}
diff --git a/src/timelock/Bytes32LinkedList.sol b/src/timelock/Bytes32LinkedList.sol
new file mode 100644
index 0000000..58c5179
--- /dev/null
+++ b/src/timelock/Bytes32LinkedList.sol
@@ -0,0 +1,97 @@
+// SPDX-FileCopyrightText: © 2026 Dai Foundation
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+pragma solidity ^0.8.24;
+
+/// @title Bytes32LinkedList
+/// @notice A doubly linked list library for non-zero bytes32 values
+library Bytes32LinkedList {
+ struct Node {
+ bytes32 prev;
+ bytes32 next;
+ }
+
+ struct List {
+ bytes32 first;
+ bytes32 last;
+ uint256 count;
+ mapping(bytes32 => Node) nodes;
+ mapping(bytes32 => bool) exists;
+ }
+
+ /// @notice Add a new item to the end of the list
+ /// @param list The list to add to
+ /// @param id The bytes32 value to add
+ /// @return success True if the item was added, false if it is bytes32(0) or already exists
+ function add(List storage list, bytes32 id) internal returns (bool success) {
+ if (id == bytes32(0) || list.exists[id]) {
+ return false;
+ }
+
+ list.exists[id] = true;
+ list.count++;
+
+ if (list.first == bytes32(0)) {
+ // First item in the list
+ list.first = id;
+ list.last = id;
+ } else {
+ // Append to end
+ list.nodes[id].prev = list.last;
+ list.nodes[list.last].next = id;
+ list.last = id;
+ }
+
+ return true;
+ }
+
+ /// @notice Remove an item from the list
+ /// @param list The list to remove from
+ /// @param id The bytes32 value to remove
+ /// @return success True if the item was removed, false if it didn't exist
+ function remove(List storage list, bytes32 id) internal returns (bool success) {
+ if (!list.exists[id]) {
+ return false;
+ }
+
+ Node storage node = list.nodes[id];
+ bytes32 prevId = node.prev;
+ bytes32 nextId = node.next;
+
+ // Update prev node's next pointer
+ if (prevId != bytes32(0)) {
+ list.nodes[prevId].next = nextId;
+ } else {
+ // Removing first element
+ list.first = nextId;
+ }
+
+ // Update next node's prev pointer
+ if (nextId != bytes32(0)) {
+ list.nodes[nextId].prev = prevId;
+ } else {
+ // Removing last element
+ list.last = prevId;
+ }
+
+ // Clean up
+ delete list.nodes[id];
+ list.exists[id] = false;
+ list.count--;
+
+ return true;
+ }
+}
diff --git a/src/timelock/Timelock.sol b/src/timelock/Timelock.sol
new file mode 100644
index 0000000..540c9cc
--- /dev/null
+++ b/src/timelock/Timelock.sol
@@ -0,0 +1,193 @@
+// SPDX-FileCopyrightText: © 2026 Dai Foundation
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+pragma solidity ^0.8.24;
+
+import { TimelockController } from "@openzeppelin/contracts/governance/TimelockController.sol";
+import { Pausable } from "@openzeppelin/contracts/utils/Pausable.sol";
+import { Bytes32LinkedList } from "./Bytes32LinkedList.sol";
+
+contract Timelock is TimelockController, Pausable {
+ using Bytes32LinkedList for Bytes32LinkedList.List;
+
+ bytes32 public constant PAUSER_ROLE = keccak256("PAUSER_ROLE");
+
+ // Operation tracking for keeper jobs - store parameters for execution
+ struct Operation {
+ address[] targets;
+ uint256[] values;
+ bytes[] payloads;
+ bytes32 predecessor;
+ bytes32 salt;
+ }
+
+ Bytes32LinkedList.List internal _operationIds;
+ mapping(bytes32 id => Operation) internal _operations;
+
+ // Changes from original timelock:
+ // - Do not allow proposers to change admin-only configurations
+ // - Make execution permissionless
+ // - Add pausing logic
+ // - Allow admin to change the min delay immediately
+ // - Do not allow proposals to change the min delay
+
+ // Notes:
+ // - Cancellers can cancel any proposal, not only ones they created. Same assumptions as above apply.
+
+ constructor(
+ uint256 minDelay,
+ address admin
+ ) TimelockController(minDelay, new address[](0), new address[](0), admin) {
+ require(admin != address(0), "Timelock/admin-zero-address");
+
+ _revokeRole(DEFAULT_ADMIN_ROLE, address(this)); // do not allow proposers to change admin-only configurations
+ _grantRole(EXECUTOR_ROLE, address(0)); // allow anyone to execute
+ }
+
+ // ------------------------------------------------------------------------
+ // Pausing logic
+ // ------------------------------------------------------------------------
+
+ function pause() external onlyRole(PAUSER_ROLE) {
+ _pause();
+ }
+
+ function unpause() external onlyRole(DEFAULT_ADMIN_ROLE) {
+ _unpause();
+ }
+
+ // ------------------------------------------------------------------------
+ // Delay management
+ // ------------------------------------------------------------------------
+
+ // Can not override updateDelay as we need an external call to the inherited function to change msg.sender.
+ function updateDelayImmediately(uint256 newDelay) external onlyRole(DEFAULT_ADMIN_ROLE) {
+ this.updateDelay(newDelay);
+ }
+
+ // ------------------------------------------------------------------------
+ // Scheduling (blocked while paused)
+ // ------------------------------------------------------------------------
+
+ function schedule(address, uint256, bytes calldata, bytes32, bytes32, uint256) public pure override {
+ revert("Timelock/use-scheduleBatch");
+ }
+
+ function scheduleBatch(
+ address[] calldata targets,
+ uint256[] calldata values,
+ bytes[] calldata payloads,
+ bytes32 predecessor,
+ bytes32 salt,
+ uint256 delay
+ ) public virtual override whenNotPaused {
+ for (uint256 i = 0; i < targets.length; ++i) {
+ require(targets[i] != address(this), "Timelock/self-calls-disabled");
+ }
+
+ super.scheduleBatch(targets, values, payloads, predecessor, salt, delay);
+
+ // Track operation for keeper jobs
+ bytes32 id = hashOperationBatch(targets, values, payloads, predecessor, salt);
+ require(_operationIds.add(id), "Timelock/add-failed");
+ _operations[id] = Operation(targets, values, payloads, predecessor, salt);
+ }
+
+ // As unpausing requires an admin action anyway, it is fine to block canceling while paused.
+ // If needed, the admin can atomically cancel any proposal right after unpausing.
+ function cancel(bytes32 id) public virtual override whenNotPaused {
+ super.cancel(id);
+ require(_operationIds.remove(id), "Timelock/remove-failed");
+ delete _operations[id];
+ }
+
+ // ------------------------------------------------------------------------
+ // Execution (blocked while paused)
+ // ------------------------------------------------------------------------
+
+ function execute(address, uint256, bytes calldata, bytes32, bytes32) public payable override {
+ revert("Timelock/use-executeBatch");
+ }
+
+ function executeBatch(
+ address[] calldata targets,
+ uint256[] calldata values,
+ bytes[] calldata payloads,
+ bytes32 predecessor,
+ bytes32 salt
+ ) public payable virtual override whenNotPaused {
+ super.executeBatch(targets, values, payloads, predecessor, salt);
+ bytes32 id = hashOperationBatch(targets, values, payloads, predecessor, salt);
+ require(_operationIds.remove(id), "Timelock/remove-failed");
+ delete _operations[id];
+ }
+
+ // ------------------------------------------------------------------------
+ // Operations getters
+ // ------------------------------------------------------------------------
+
+ function getFirstOperationId() external view returns (bytes32) {
+ return _operationIds.first;
+ }
+
+ function getLastOperationId() external view returns (bytes32) {
+ return _operationIds.last;
+ }
+
+ function getOperationsCount() external view returns (uint256) {
+ return _operationIds.count;
+ }
+
+ function getPrevOperationId(bytes32 id) external view returns (bytes32) {
+ return _operationIds.nodes[id].prev;
+ }
+
+ function getNextOperationId(bytes32 id) external view returns (bytes32) {
+ return _operationIds.nodes[id].next;
+ }
+
+ function getOperationExists(bytes32 id) external view returns (bool) {
+ return _operationIds.exists[id];
+ }
+
+ function getOperation(bytes32 id) external view returns (Operation memory op) {
+ return _operations[id];
+ }
+
+ function getOperationLength(bytes32 id) external view returns (uint256) {
+ return _operations[id].targets.length;
+ }
+
+ function getOperationTarget(bytes32 id, uint256 index) external view returns (address) {
+ return _operations[id].targets[index];
+ }
+
+ function getOperationValue(bytes32 id, uint256 index) external view returns (uint256) {
+ return _operations[id].values[index];
+ }
+
+ function getOperationPayload(bytes32 id, uint256 index) external view returns (bytes memory) {
+ return _operations[id].payloads[index];
+ }
+
+ function getOperationPredecessor(bytes32 id) external view returns (bytes32) {
+ return _operations[id].predecessor;
+ }
+
+ function getOperationSalt(bytes32 id) external view returns (bytes32) {
+ return _operations[id].salt;
+ }
+}
diff --git a/src/timelock/helpers/TimelockHelper.sol b/src/timelock/helpers/TimelockHelper.sol
new file mode 100644
index 0000000..8de9e9c
--- /dev/null
+++ b/src/timelock/helpers/TimelockHelper.sol
@@ -0,0 +1,58 @@
+// SPDX-FileCopyrightText: © 2026 Dai Foundation
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+pragma solidity ^0.8.24;
+
+interface TimelockLike {
+ function isOperationReady(bytes32) external view returns (bool);
+ function isOperationDone(bytes32) external view returns (bool);
+ function getFirstOperationId() external view returns (bytes32);
+ function getNextOperationId(bytes32) external view returns (bytes32);
+ function getOperationExists(bytes32) external view returns (bool);
+ function getOperationPredecessor(bytes32) external view returns (bytes32);
+}
+
+contract TimelockHelper {
+
+ TimelockLike immutable public timelock;
+
+ constructor(address timelock_) {
+ timelock = TimelockLike(timelock_);
+ }
+
+ // Operations may still not be executable due to various downstream conditions.
+ // It is assumed that this is not a perfect fetching mechanism and that if needed proposals
+ // can be executed without cron keepers, or canceled in case they are jamming this mechanism.
+ // returns - If found: the executable operation. If not found: the next startId to continue from, or bytes32(0) if exhausted.
+ function getNextExecutableOperationId(bytes32 startId, uint256 maxIterations) external view returns (bool found, bytes32 id) {
+ require(maxIterations > 0, "TimelockHelper/zero-maxIterations");
+ require(startId == bytes32(0) || timelock.getOperationExists(startId), "TimelockHelper/invalid-startId");
+
+ id = startId == bytes32(0) ? timelock.getFirstOperationId() : startId;
+
+ uint256 i = 0;
+ while (id != bytes32(0) && i++ < maxIterations) {
+ if (timelock.isOperationReady(id)) {
+ bytes32 predecessor = timelock.getOperationPredecessor(id);
+ if (predecessor == bytes32(0) || timelock.isOperationDone(predecessor)) return (true, id);
+ }
+
+ id = timelock.getNextOperationId(id);
+ }
+
+ return (false, id);
+ }
+}
diff --git a/test/BeamState.t.sol b/test/BeamState.t.sol
new file mode 100644
index 0000000..841f04b
--- /dev/null
+++ b/test/BeamState.t.sol
@@ -0,0 +1,938 @@
+// SPDX-FileCopyrightText: © 2026 Dai Foundation
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+pragma solidity ^0.8.24;
+
+import "dss-test/DssTest.sol";
+import { BeamState } from "src/BeamState.sol";
+
+contract BeamStateTest is DssTest {
+
+ BeamState beamState;
+
+ address constant USER1 = address(0x1);
+ address constant USER2 = address(0x2);
+ address constant CBEAM1 = address(0xC1);
+ address constant CBEAM2 = address(0xC2);
+ address constant TARGET1 = address(0xA1);
+ address constant TARGET2 = address(0xA2);
+
+ event SetUserRole(address indexed who, uint8 indexed role, bool enabled);
+ event SetRoleAction(uint8 indexed role, bytes4 indexed sig, bool enabled);
+ event Stop();
+ event Start();
+ event SetHop(address indexed rateLimits, uint256 value);
+ event SetMaxChange(address indexed rateLimits, uint256 value);
+ event AddRateLimits(address indexed rateLimits_);
+ event DelRateLimits(address indexed rateLimits_);
+ event AddController(address indexed controller);
+ event DelController(address indexed controller);
+ event AddCBeam(address indexed cBeam);
+ event DelCBeam(address indexed cBeam);
+ event SetCBeamForController(address indexed controller, address indexed cBeam);
+ event UnsetCBeamForController(address indexed controller, address indexed cBeam);
+ event SetCBeamForRateLimits(address indexed rateLimits, address indexed cBeam);
+ event UnsetCBeamForRateLimits(address indexed rateLimits, address indexed cBeam);
+ event AddInitRateLimits(bytes32 indexed key, address indexed rateLimits, uint256 maxAmount, uint256 slope);
+ event DelInitRateLimits(bytes32 indexed key, address indexed rateLimits);
+ event AddInitControllerActions(bytes32 indexed key, address indexed rateLimits);
+ event DelInitControllerActions(bytes32 indexed key, address indexed rateLimits);
+
+ function setUp() public {
+ beamState = new BeamState();
+ }
+
+ // --- Constructor & Initialization Tests ---
+
+ function testConstructor() public {
+ vm.expectEmit();
+ emit Rely(address(this));
+ BeamState beamState2 = new BeamState();
+ assertEq(beamState2.wards(address(this)), 1, "deployer should be ward");
+ }
+
+ // --- Authorization Tests ---
+
+ function testAuth() public {
+ checkAuth(address(beamState), "BeamState");
+ }
+
+ function testModifiers() public {
+ bytes4[] memory authedMethods = new bytes4[](2);
+ authedMethods[0] = beamState.setUserRole.selector;
+ authedMethods[1] = beamState.setRoleAction.selector;
+
+ vm.startPrank(address(0xBEEF));
+ checkModifier(address(beamState), "BeamState/not-authorized", authedMethods);
+ vm.stopPrank();
+ }
+
+ // --- Role Management Tests ---
+
+ function testSetUserRole() public {
+ assertFalse(beamState.hasUserRole(USER1, 0), "USER1 should not have role 0 initially");
+
+ vm.expectEmit();
+ emit SetUserRole(USER1, 0, true);
+ beamState.setUserRole(USER1, 0, true);
+
+ assertTrue(beamState.hasUserRole(USER1, 0), "USER1 should have role 0 after setting");
+ }
+
+ function testSetUserRoleMultiple() public {
+ beamState.setUserRole(USER1, 0, true);
+ beamState.setUserRole(USER1, 5, true);
+ beamState.setUserRole(USER1, 255, true);
+
+ assertTrue(beamState.hasUserRole(USER1, 0), "USER1 should have role 0");
+ assertTrue(beamState.hasUserRole(USER1, 5), "USER1 should have role 5");
+ assertTrue(beamState.hasUserRole(USER1, 255), "USER1 should have role 255");
+ assertFalse(beamState.hasUserRole(USER1, 1), "USER1 should not have role 1");
+ }
+
+ function testUnsetUserRole() public {
+ beamState.setUserRole(USER1, 0, true);
+ assertTrue(beamState.hasUserRole(USER1, 0), "USER1 should have role 0");
+
+ vm.expectEmit();
+ emit SetUserRole(USER1, 0, false);
+ beamState.setUserRole(USER1, 0, false);
+
+ assertFalse(beamState.hasUserRole(USER1, 0), "USER1 should not have role 0 after unsetting");
+ }
+
+ function testSetRoleAction() public {
+ bytes4 sig = bytes4(keccak256("testFunction()"));
+ assertFalse(beamState.isActionInRole(sig, 0), "action should not be in role 0 initially");
+
+ vm.expectEmit();
+ emit SetRoleAction(0, sig, true);
+ beamState.setRoleAction(0, sig, true);
+
+ assertTrue(beamState.isActionInRole(sig, 0), "action should be in role 0 after setting");
+ }
+
+ function testSetRoleActionMultiple() public {
+ bytes4 sig = bytes4(keccak256("testFunction()"));
+ beamState.setRoleAction(0, sig, true);
+ beamState.setRoleAction(5, sig, true);
+ beamState.setRoleAction(255, sig, true);
+
+ assertTrue(beamState.isActionInRole(sig, 0), "action should be in role 0");
+ assertTrue(beamState.isActionInRole(sig, 5), "action should be in role 5");
+ assertTrue(beamState.isActionInRole(sig, 255), "action should be in role 255");
+ assertFalse(beamState.isActionInRole(sig, 1), "action should not be in role 1");
+ }
+
+ function testUnsetRoleAction() public {
+ bytes4 sig = bytes4(keccak256("testFunction()"));
+ beamState.setRoleAction(0, sig, true);
+ assertTrue(beamState.isActionInRole(sig, 0), "action should be in role 0");
+
+ vm.expectEmit();
+ emit SetRoleAction(0, sig, false);
+ beamState.setRoleAction(0, sig, false);
+
+ assertFalse(beamState.isActionInRole(sig, 0), "action should not be in role 0 after unsetting");
+ }
+
+ // --- Role-based Authorization Tests ---
+
+ function testRoleAuthWithWard() public {
+ // Ward can call without role
+ beamState.setHop(TARGET1, 100);
+ assertEq(beamState.hop(TARGET1), 100, "hop should be set by ward");
+ }
+
+ function testRoleAuthWithRole() public {
+ // Set up role
+ beamState.setRoleAction(5, beamState.setHop.selector, true);
+ beamState.setUserRole(USER1, 5, true);
+
+ // User with role can call
+ vm.prank(USER1);
+ beamState.setHop(TARGET1, 200);
+ assertEq(beamState.hop(TARGET1), 200, "hop should be set by role user");
+ }
+
+ function testRoleAuthWithoutRoleOrWard() public {
+ vm.prank(USER1);
+ vm.expectRevert("BeamState/role-not-authorized");
+ beamState.setHop(TARGET1, 100);
+ }
+
+ function testRoleAuthWithWrongRole() public {
+ // Set up role 5 for action
+ beamState.setRoleAction(5, beamState.setHop.selector, true);
+ // Give user role 3 instead
+ beamState.setUserRole(USER1, 3, true);
+
+ vm.prank(USER1);
+ vm.expectRevert("BeamState/role-not-authorized");
+ beamState.setHop(TARGET1, 100);
+ }
+
+ // --- Stop/Start Tests ---
+
+ function testStop() public {
+ assertFalse(beamState.stopped(), "BeamState should not be stopped initially");
+
+ vm.expectEmit();
+ emit Stop();
+ beamState.stop();
+
+ assertTrue(beamState.stopped(), "BeamState should be stopped after calling stop");
+ }
+
+ function testStopRoleAuth() public {
+ beamState.setRoleAction(7, beamState.stop.selector, true);
+ beamState.setUserRole(USER1, 7, true);
+
+ vm.prank(USER1);
+ beamState.stop();
+
+ assertTrue(beamState.stopped(), "BeamState should be stopped by role user");
+ }
+
+ function testStopNotAuthorized() public {
+ vm.prank(USER1);
+ vm.expectRevert("BeamState/role-not-authorized");
+ beamState.stop();
+ }
+
+ function testStart() public {
+ beamState.stop();
+ assertTrue(beamState.stopped(), "BeamState should be stopped");
+
+ vm.expectEmit();
+ emit Start();
+ beamState.start();
+
+ assertFalse(beamState.stopped(), "BeamState should not be stopped after calling start");
+ }
+
+ function testStartRoleAuth() public {
+ beamState.stop();
+
+ beamState.setRoleAction(7, beamState.start.selector, true);
+ beamState.setUserRole(USER1, 7, true);
+
+ vm.prank(USER1);
+ beamState.start();
+
+ assertFalse(beamState.stopped(), "BeamState should be started by role user");
+ }
+
+ function testStartNotAuthorized() public {
+ beamState.stop();
+
+ vm.prank(USER1);
+ vm.expectRevert("BeamState/role-not-authorized");
+ beamState.start();
+ }
+
+ function testMultipleStopCalls() public {
+ beamState.stop();
+ assertTrue(beamState.stopped(), "BeamState should be stopped");
+
+ // Calling stop again should not revert
+ beamState.stop();
+ assertTrue(beamState.stopped(), "BeamState should still be stopped");
+ }
+
+ function testMultipleStartCalls() public {
+ assertFalse(beamState.stopped(), "BeamState should not be stopped initially");
+
+ // Calling start when not stopped should not revert
+ beamState.start();
+ assertFalse(beamState.stopped(), "BeamState should still not be stopped");
+ }
+
+ // --- Hop Configuration Tests ---
+
+ function testSetHop() public {
+ vm.expectEmit();
+ emit SetHop(TARGET1, 86_400);
+ beamState.setHop(TARGET1, 86_400);
+
+ assertEq(beamState.hop(TARGET1), 86_400, "hop should be set for TARGET1");
+ assertEq(beamState.getHop(TARGET1), 86_400, "getHop should return correct value");
+ }
+
+ function testSetHopGlobal() public {
+ beamState.setHop(address(0), 3_600);
+ assertEq(beamState.hop(address(0)), 3_600, "global hop should be set");
+ assertEq(beamState.getHop(address(0)), 3_600, "getHop should return global value");
+ }
+
+ function testGetHopFallback() public {
+ beamState.setHop(address(0), 7_200);
+
+ // TARGET1 has no specific hop, should fallback to global
+ assertEq(beamState.hop(TARGET1), 0, "TARGET1 hop storage should be 0");
+ assertEq(beamState.getHop(TARGET1), 7_200, "getHop should return global fallback");
+
+ // Set specific hop for TARGET1
+ beamState.setHop(TARGET1, 14_400);
+ assertEq(beamState.getHop(TARGET1), 14_400, "getHop should return TARGET1 specific value");
+ }
+
+ function testSetHopRoleAuth() public {
+ beamState.setRoleAction(1, beamState.setHop.selector, true);
+ beamState.setUserRole(USER1, 1, true);
+
+ vm.prank(USER1);
+ beamState.setHop(TARGET1, 1_000);
+
+ assertEq(beamState.hop(TARGET1), 1_000, "hop should be set by role user");
+ }
+
+ // --- MaxChange Configuration Tests ---
+
+ function testSetMaxChange() public {
+ vm.expectEmit();
+ emit SetMaxChange(TARGET1, 2 * WAD);
+ beamState.setMaxChange(TARGET1, 2 * WAD);
+
+ assertEq(beamState.maxChange(TARGET1), 2 * WAD, "maxChange should be set for TARGET1");
+ assertEq(beamState.getMaxChange(TARGET1), 2 * WAD, "getMaxChange should return correct value");
+ }
+
+ function testSetMaxChangeMinimumWad() public {
+ beamState.setMaxChange(TARGET1, WAD);
+ assertEq(beamState.maxChange(TARGET1), WAD, "maxChange should be WAD");
+ }
+
+ function testSetMaxChangeZero() public {
+ beamState.setMaxChange(TARGET1, 2 * WAD);
+ assertEq(beamState.maxChange(TARGET1), 2 * WAD, "maxChange should be set");
+
+ // Setting to 0 clears the specific override
+ beamState.setMaxChange(TARGET1, 0);
+ assertEq(beamState.maxChange(TARGET1), 0, "maxChange should be cleared");
+ }
+
+ function testSetMaxChangeInvalidValue() public {
+ vm.expectRevert("BeamState/invalid-maxChange");
+ beamState.setMaxChange(TARGET1, WAD - 1);
+ }
+
+ function testSetMaxChangeGlobal() public {
+ beamState.setMaxChange(address(0), 15 * WAD / 10); // 1.5x
+ assertEq(beamState.maxChange(address(0)), 15 * WAD / 10, "global maxChange should be set");
+ assertEq(beamState.getMaxChange(address(0)), 15 * WAD / 10, "getMaxChange should return global value");
+ }
+
+ function testGetMaxChangeFallback() public {
+ beamState.setMaxChange(address(0), 2 * WAD);
+
+ // TARGET1 has no specific maxChange, should fallback to global
+ assertEq(beamState.maxChange(TARGET1), 0, "TARGET1 maxChange storage should be 0");
+ assertEq(beamState.getMaxChange(TARGET1), 2 * WAD, "getMaxChange should return global fallback");
+
+ // Set specific maxChange for TARGET1
+ beamState.setMaxChange(TARGET1, 3 * WAD);
+ assertEq(beamState.getMaxChange(TARGET1), 3 * WAD, "getMaxChange should return TARGET1 specific value");
+ }
+
+ function testSetMaxChangeRoleAuth() public {
+ beamState.setRoleAction(2, beamState.setMaxChange.selector, true);
+ beamState.setUserRole(USER1, 2, true);
+
+ vm.prank(USER1);
+ beamState.setMaxChange(TARGET1, 2 * WAD);
+
+ assertEq(beamState.maxChange(TARGET1), 2 * WAD, "maxChange should be set by role user");
+ }
+
+ // --- RateLimits Management Tests ---
+
+ function testAddRateLimits() public {
+ assertEq(beamState.rateLimits(TARGET1), 0, "TARGET1 should not be registered initially");
+
+ vm.expectEmit();
+ emit AddRateLimits(TARGET1);
+ beamState.addRateLimits(TARGET1);
+
+ assertEq(beamState.rateLimits(TARGET1), 1, "TARGET1 should be registered");
+ }
+
+ function testAddRateLimitsRoleAuth() public {
+ beamState.setRoleAction(8, beamState.addRateLimits.selector, true);
+ beamState.setUserRole(USER1, 8, true);
+
+ vm.prank(USER1);
+ beamState.addRateLimits(TARGET1);
+
+ assertEq(beamState.rateLimits(TARGET1), 1, "TARGET1 should be registered by role user");
+ }
+
+ function testDelRateLimits() public {
+ beamState.addRateLimits(TARGET1);
+ assertEq(beamState.rateLimits(TARGET1), 1, "TARGET1 should be registered");
+
+ vm.expectEmit();
+ emit DelRateLimits(TARGET1);
+ beamState.delRateLimits(TARGET1);
+
+ assertEq(beamState.rateLimits(TARGET1), 0, "TARGET1 should not be registered after delete");
+ }
+
+ function testDelRateLimitsRoleAuth() public {
+ beamState.addRateLimits(TARGET1);
+
+ beamState.setRoleAction(8, beamState.delRateLimits.selector, true);
+ beamState.setUserRole(USER1, 8, true);
+
+ vm.prank(USER1);
+ beamState.delRateLimits(TARGET1);
+
+ assertEq(beamState.rateLimits(TARGET1), 0, "TARGET1 should be deleted by role user");
+ }
+
+ function testAddRateLimitsMultiple() public {
+ beamState.addRateLimits(TARGET1);
+ beamState.addRateLimits(TARGET2);
+
+ assertEq(beamState.rateLimits(TARGET1), 1, "TARGET1 should be registered");
+ assertEq(beamState.rateLimits(TARGET2), 1, "TARGET2 should be registered");
+ }
+
+ // --- Controller Management Tests ---
+
+ function testAddController() public {
+ assertEq(beamState.controllers(TARGET1), 0, "TARGET1 should not be registered initially");
+
+ vm.expectEmit();
+ emit AddController(TARGET1);
+ beamState.addController(TARGET1);
+
+ assertEq(beamState.controllers(TARGET1), 1, "TARGET1 should be registered");
+ }
+
+ function testAddControllerRoleAuth() public {
+ beamState.setRoleAction(9, beamState.addController.selector, true);
+ beamState.setUserRole(USER1, 9, true);
+
+ vm.prank(USER1);
+ beamState.addController(TARGET1);
+
+ assertEq(beamState.controllers(TARGET1), 1, "TARGET1 should be registered by role user");
+ }
+
+ function testDelController() public {
+ beamState.addController(TARGET1);
+ assertEq(beamState.controllers(TARGET1), 1, "TARGET1 should be registered");
+
+ vm.expectEmit();
+ emit DelController(TARGET1);
+ beamState.delController(TARGET1);
+
+ assertEq(beamState.controllers(TARGET1), 0, "TARGET1 should not be registered after delete");
+ }
+
+ function testDelControllerRoleAuth() public {
+ beamState.addController(TARGET1);
+
+ beamState.setRoleAction(9, beamState.delController.selector, true);
+ beamState.setUserRole(USER1, 9, true);
+
+ vm.prank(USER1);
+ beamState.delController(TARGET1);
+
+ assertEq(beamState.controllers(TARGET1), 0, "TARGET1 should be deleted by role user");
+ }
+
+ function testAddControllerMultiple() public {
+ beamState.addController(TARGET1);
+ beamState.addController(TARGET2);
+
+ assertEq(beamState.controllers(TARGET1), 1, "TARGET1 should be registered");
+ assertEq(beamState.controllers(TARGET2), 1, "TARGET2 should be registered");
+ }
+
+ // --- cBeam Management Tests ---
+
+ function testAddCBeam() public {
+ assertEq(beamState.cBeams(CBEAM1), 0, "CBEAM1 should not be registered initially");
+
+ vm.expectEmit();
+ emit AddCBeam(CBEAM1);
+ beamState.addCBeam(CBEAM1);
+
+ assertEq(beamState.cBeams(CBEAM1), 1, "CBEAM1 should be registered");
+ }
+
+ function testAddCBeamRoleAuth() public {
+ beamState.setRoleAction(3, beamState.addCBeam.selector, true);
+ beamState.setUserRole(USER1, 3, true);
+
+ vm.prank(USER1);
+ beamState.addCBeam(CBEAM1);
+
+ assertEq(beamState.cBeams(CBEAM1), 1, "CBEAM1 should be registered by role user");
+ }
+
+ function testDelCBeam() public {
+ beamState.addCBeam(CBEAM1);
+ assertEq(beamState.cBeams(CBEAM1), 1, "CBEAM1 should be registered");
+
+ vm.expectEmit();
+ emit DelCBeam(CBEAM1);
+ beamState.delCBeam(CBEAM1);
+
+ assertEq(beamState.cBeams(CBEAM1), 0, "CBEAM1 should not be registered after delete");
+ }
+
+ function testDelCBeamRoleAuth() public {
+ beamState.addCBeam(CBEAM1);
+
+ beamState.setRoleAction(3, beamState.delCBeam.selector, true);
+ beamState.setUserRole(USER1, 3, true);
+
+ vm.prank(USER1);
+ beamState.delCBeam(CBEAM1);
+
+ assertEq(beamState.cBeams(CBEAM1), 0, "CBEAM1 should be deleted by role user");
+ }
+
+ // --- cBeam-RateLimits Mapping Tests ---
+
+ function testSetCBeamForRateLimits() public {
+ beamState.addRateLimits(TARGET1);
+ beamState.addCBeam(CBEAM1);
+
+ assertEq(beamState.rateLimitsCBeams(TARGET1, CBEAM1), 0, "CBEAM1 should not be authorized for TARGET1 initially");
+
+ vm.expectEmit();
+ emit SetCBeamForRateLimits(TARGET1, CBEAM1);
+ beamState.setCBeamForRateLimits(TARGET1, CBEAM1);
+
+ assertEq(beamState.rateLimitsCBeams(TARGET1, CBEAM1), 1, "CBEAM1 should be authorized for TARGET1");
+ }
+
+ function testSetCBeamForRateLimitsNotExisting() public {
+ // Test when rateLimits not registered
+ vm.expectRevert("BeamState/not-existing-rateLimits");
+ beamState.setCBeamForRateLimits(TARGET1, CBEAM1);
+
+ // Test when rateLimits registered but cBeam not registered
+ beamState.addRateLimits(TARGET1);
+ vm.expectRevert("BeamState/not-existing-cBeam");
+ beamState.setCBeamForRateLimits(TARGET1, CBEAM1);
+ }
+
+ function testSetCBeamForRateLimitsRoleAuth() public {
+ beamState.addRateLimits(TARGET1);
+ beamState.addCBeam(CBEAM1);
+
+ beamState.setRoleAction(4, beamState.setCBeamForRateLimits.selector, true);
+ beamState.setUserRole(USER1, 4, true);
+
+ vm.prank(USER1);
+ beamState.setCBeamForRateLimits(TARGET1, CBEAM1);
+
+ assertEq(beamState.rateLimitsCBeams(TARGET1, CBEAM1), 1, "CBEAM1 should be set for TARGET1 by role user");
+ }
+
+ function testUnsetCBeamForRateLimits() public {
+ beamState.addRateLimits(TARGET1);
+ beamState.addCBeam(CBEAM1);
+ beamState.setCBeamForRateLimits(TARGET1, CBEAM1);
+ assertEq(beamState.rateLimitsCBeams(TARGET1, CBEAM1), 1, "CBEAM1 should be authorized for TARGET1");
+
+ vm.expectEmit();
+ emit UnsetCBeamForRateLimits(TARGET1, CBEAM1);
+ beamState.unsetCBeamForRateLimits(TARGET1, CBEAM1);
+
+ assertEq(beamState.rateLimitsCBeams(TARGET1, CBEAM1), 0, "CBEAM1 should not be authorized for TARGET1 after unset");
+ }
+
+ function testUnsetCBeamForRateLimitsRoleAuth() public {
+ beamState.addRateLimits(TARGET1);
+ beamState.addCBeam(CBEAM1);
+ beamState.setCBeamForRateLimits(TARGET1, CBEAM1);
+
+ beamState.setRoleAction(4, beamState.unsetCBeamForRateLimits.selector, true);
+ beamState.setUserRole(USER1, 4, true);
+
+ vm.prank(USER1);
+ beamState.unsetCBeamForRateLimits(TARGET1, CBEAM1);
+
+ assertEq(beamState.rateLimitsCBeams(TARGET1, CBEAM1), 0, "CBEAM1 should be unset for TARGET1 by role user");
+ }
+
+ // --- cBeam-Controller Mapping Tests ---
+
+ function testSetCBeamForController() public {
+ beamState.addController(TARGET1);
+ beamState.addCBeam(CBEAM1);
+
+ assertEq(beamState.controllersCBeams(TARGET1, CBEAM1), 0, "CBEAM1 should not be authorized for TARGET1 initially");
+
+ vm.expectEmit();
+ emit SetCBeamForController(TARGET1, CBEAM1);
+ beamState.setCBeamForController(TARGET1, CBEAM1);
+
+ assertEq(beamState.controllersCBeams(TARGET1, CBEAM1), 1, "CBEAM1 should be authorized for TARGET1");
+ }
+
+ function testSetCBeamForControllerNotExisting() public {
+ // Test when controller not registered
+ vm.expectRevert("BeamState/not-existing-controller");
+ beamState.setCBeamForController(TARGET1, CBEAM1);
+
+ // Test when controller registered but cBeam not registered
+ beamState.addController(TARGET1);
+ vm.expectRevert("BeamState/not-existing-cBeam");
+ beamState.setCBeamForController(TARGET1, CBEAM1);
+ }
+
+ function testSetCBeamForControllerRoleAuth() public {
+ beamState.addController(TARGET1);
+ beamState.addCBeam(CBEAM1);
+
+ beamState.setRoleAction(4, beamState.setCBeamForController.selector, true);
+ beamState.setUserRole(USER1, 4, true);
+
+ vm.prank(USER1);
+ beamState.setCBeamForController(TARGET1, CBEAM1);
+
+ assertEq(beamState.controllersCBeams(TARGET1, CBEAM1), 1, "CBEAM1 should be set for TARGET1 by role user");
+ }
+
+ function testUnsetCBeamForController() public {
+ beamState.addController(TARGET1);
+ beamState.addCBeam(CBEAM1);
+ beamState.setCBeamForController(TARGET1, CBEAM1);
+ assertEq(beamState.controllersCBeams(TARGET1, CBEAM1), 1, "CBEAM1 should be authorized for TARGET1");
+
+ vm.expectEmit();
+ emit UnsetCBeamForController(TARGET1, CBEAM1);
+ beamState.unsetCBeamForController(TARGET1, CBEAM1);
+
+ assertEq(beamState.controllersCBeams(TARGET1, CBEAM1), 0, "CBEAM1 should not be authorized for TARGET1 after unset");
+ }
+
+ function testUnsetCBeamForControllerRoleAuth() public {
+ beamState.addController(TARGET1);
+ beamState.addCBeam(CBEAM1);
+ beamState.setCBeamForController(TARGET1, CBEAM1);
+
+ beamState.setRoleAction(4, beamState.unsetCBeamForController.selector, true);
+ beamState.setUserRole(USER1, 4, true);
+
+ vm.prank(USER1);
+ beamState.unsetCBeamForController(TARGET1, CBEAM1);
+
+ assertEq(beamState.controllersCBeams(TARGET1, CBEAM1), 0, "CBEAM1 should be unset for TARGET1 by role user");
+ }
+
+ // --- Rate Limits Tests ---
+
+ function testAddInitRateLimits() public {
+ bytes32 key = keccak256("test-key");
+
+ vm.expectEmit();
+ emit AddInitRateLimits(key, TARGET1, 1_000 * WAD, 10 * WAD);
+ beamState.addInitRateLimits(key, TARGET1, 1_000 * WAD, 10 * WAD);
+
+ BeamState.DefaultRateLimits memory limits = beamState.getInitRateLimits(key, TARGET1);
+ assertEq(limits.maxAmount, 1_000 * WAD, "maxAmount should be set");
+ assertEq(limits.slope, 10 * WAD, "slope should be set");
+ }
+
+ function testAddInitRateLimitsGlobal() public {
+ bytes32 key = keccak256("global-key");
+
+ beamState.addInitRateLimits(key, address(0), 500 * WAD, 5 * WAD);
+
+ BeamState.DefaultRateLimits memory limits = beamState.getInitRateLimits(key, address(0));
+ assertEq(limits.maxAmount, 500 * WAD, "global maxAmount should be set");
+ assertEq(limits.slope, 5 * WAD, "global slope should be set");
+ }
+
+ function testGetInitRateLimitsFallback() public {
+ bytes32 key = keccak256("fallback-key");
+
+ // Set global default
+ beamState.addInitRateLimits(key, address(0), 100 * WAD, 1 * WAD);
+
+ // Get for specific TARGET should return global
+ BeamState.DefaultRateLimits memory limits = beamState.getInitRateLimits(key, TARGET1);
+ assertEq(limits.maxAmount, 100 * WAD, "should return global maxAmount");
+ assertEq(limits.slope, 1 * WAD, "should return global slope");
+
+ // Set specific for TARGET1
+ beamState.addInitRateLimits(key, TARGET1, 200 * WAD, 2 * WAD);
+
+ // Should now return TARGET1 specific
+ limits = beamState.getInitRateLimits(key, TARGET1);
+ assertEq(limits.maxAmount, 200 * WAD, "should return TARGET1 specific maxAmount");
+ assertEq(limits.slope, 2 * WAD, "should return TARGET1 specific slope");
+ }
+
+ function testGetInitRateLimitsPartialFallback() public {
+ bytes32 key = keccak256("partial-key");
+
+ // Set global default
+ beamState.addInitRateLimits(key, address(0), 100 * WAD, 1 * WAD);
+
+ // Set specific values for TARGET1 (with slope = 0 for unlimited)
+ beamState.addInitRateLimits(key, TARGET1, 200 * WAD, 0);
+
+ // Should NOT fallback because maxAmount is non-zero (only falls back when BOTH are 0)
+ BeamState.DefaultRateLimits memory limits = beamState.getInitRateLimits(key, TARGET1);
+ assertEq(limits.maxAmount, 200 * WAD, "should return TARGET1 specific maxAmount");
+ assertEq(limits.slope, 0, "should return TARGET1 specific slope (unlimited)");
+
+ // Set specific values for TARGET1 (with maxAmount = 0)
+ beamState.addInitRateLimits(key, TARGET1, 0, 1 * WAD);
+
+ // Should NOT fallback because slope is non-zero (only falls back when BOTH are 0)
+ limits = beamState.getInitRateLimits(key, TARGET1);
+ assertEq(limits.maxAmount, 0, "should return TARGET1 specific maxAmount (zero)");
+ assertEq(limits.slope, 1 * WAD, "should return TARGET1 specific slope");
+
+ // Test that fallback only happens when BOTH are zero
+ beamState.addInitRateLimits(key, TARGET2, 0, 0);
+ limits = beamState.getInitRateLimits(key, TARGET2);
+ assertEq(limits.maxAmount, 100 * WAD, "should fallback to global when both are 0");
+ assertEq(limits.slope, 1 * WAD, "should fallback to global when both are 0");
+ }
+
+ function testAddInitRateLimitsRoleAuth() public {
+ bytes32 key = keccak256("auth-key");
+ beamState.setRoleAction(5, beamState.addInitRateLimits.selector, true);
+ beamState.setUserRole(USER1, 5, true);
+
+ vm.prank(USER1);
+ beamState.addInitRateLimits(key, TARGET1, 1_000 * WAD, 10 * WAD);
+
+ BeamState.DefaultRateLimits memory limits = beamState.getInitRateLimits(key, TARGET1);
+ assertEq(limits.maxAmount, 1_000 * WAD, "maxAmount should be set by role user");
+ }
+
+ function testDelInitRateLimits() public {
+ bytes32 key = keccak256("del-key");
+
+ beamState.addInitRateLimits(key, TARGET1, 1_000 * WAD, 10 * WAD);
+ BeamState.DefaultRateLimits memory limits = beamState.getInitRateLimits(key, TARGET1);
+ assertEq(limits.maxAmount, 1_000 * WAD, "maxAmount should be set");
+
+ vm.expectEmit();
+ emit DelInitRateLimits(key, TARGET1);
+ beamState.delInitRateLimits(key, TARGET1);
+
+ limits = beamState.getInitRateLimits(key, TARGET1);
+ assertEq(limits.maxAmount, 0, "maxAmount should be deleted");
+ assertEq(limits.slope, 0, "slope should be deleted");
+ }
+
+ function testDelInitRateLimitsRoleAuth() public {
+ bytes32 key = keccak256("del-auth-key");
+ beamState.addInitRateLimits(key, TARGET1, 1_000 * WAD, 10 * WAD);
+
+ beamState.setRoleAction(5, beamState.delInitRateLimits.selector, true);
+ beamState.setUserRole(USER1, 5, true);
+
+ vm.prank(USER1);
+ beamState.delInitRateLimits(key, TARGET1);
+
+ BeamState.DefaultRateLimits memory limits = beamState.getInitRateLimits(key, TARGET1);
+ assertEq(limits.maxAmount, 0, "maxAmount should be deleted by role user");
+ }
+
+ // --- Controller Actions Tests ---
+
+ function testAddInitControllerActionsFromBytes() public {
+ bytes memory data = abi.encodeWithSignature("testFunction(uint256)", 12345);
+ bytes32 expectedKey = keccak256(data);
+
+ vm.expectEmit();
+ emit AddInitControllerActions(expectedKey, TARGET1);
+ bytes32 returnedKey = beamState.addInitControllerActions(data, TARGET1);
+
+ assertEq(returnedKey, expectedKey, "returned key should match expected");
+ assertTrue(beamState.isControllerActionEnabled(expectedKey, TARGET1), "action should be enabled for TARGET1");
+ }
+
+ function testAddInitControllerActionsGlobal() public {
+ bytes memory data = abi.encodeWithSignature("testFunction(uint256)", 12345);
+ bytes32 key = keccak256(data);
+
+ beamState.addInitControllerActions(data, address(0));
+
+ assertTrue(beamState.isControllerActionEnabled(key, address(0)), "action should be enabled globally");
+ assertTrue(beamState.isControllerActionEnabled(key, TARGET1), "action should be enabled for any TARGET");
+ assertTrue(beamState.isControllerActionEnabled(key, TARGET2), "action should be enabled for any TARGET");
+ }
+
+ function testIsControllerActionEnabledSpecificOverGlobal() public {
+ bytes memory data = abi.encodeWithSignature("testFunction(uint256)", 12345);
+ bytes32 key = keccak256(data);
+
+ beamState.addInitControllerActions(data, TARGET1);
+
+ assertTrue(beamState.isControllerActionEnabled(key, TARGET1), "action should be enabled for TARGET1");
+ assertFalse(beamState.isControllerActionEnabled(key, TARGET2), "action should not be enabled for TARGET2");
+ }
+
+ function testIsControllerActionEnabledBothGlobalAndSpecific() public {
+ bytes memory data = abi.encodeWithSignature("testFunction(uint256)", 12345);
+ bytes32 key = keccak256(data);
+
+ beamState.addInitControllerActions(data, address(0));
+ beamState.addInitControllerActions(data, TARGET1);
+
+ assertTrue(beamState.isControllerActionEnabled(key, TARGET1), "action should be enabled for TARGET1");
+ assertTrue(beamState.isControllerActionEnabled(key, TARGET2), "action should be enabled for TARGET2 via global");
+ }
+
+ function testAddInitControllerActionsRoleAuth() public {
+ bytes memory data = abi.encodeWithSignature("testFunction(uint256)", 12345);
+ bytes32 key = keccak256(data);
+ beamState.setRoleAction(6, beamState.addInitControllerActions.selector, true);
+ beamState.setUserRole(USER1, 6, true);
+
+ vm.prank(USER1);
+ beamState.addInitControllerActions(data, TARGET1);
+
+ assertTrue(beamState.isControllerActionEnabled(key, TARGET1), "action should be enabled by role user");
+ }
+
+ function testDelInitControllerActions() public {
+ bytes memory data = abi.encodeWithSignature("testFunction(uint256)", 12345);
+ bytes32 key = keccak256(data);
+
+ beamState.addInitControllerActions(data, TARGET1);
+ assertTrue(beamState.isControllerActionEnabled(key, TARGET1), "action should be enabled");
+
+ vm.expectEmit();
+ emit DelInitControllerActions(key, TARGET1);
+ beamState.delInitControllerActions(key, TARGET1);
+
+ assertFalse(beamState.isControllerActionEnabled(key, TARGET1), "action should be disabled after delete");
+ }
+
+ function testDelInitControllerActionsRoleAuth() public {
+ bytes memory data = abi.encodeWithSignature("testFunction(uint256)", 12345);
+ bytes32 key = keccak256(data);
+ beamState.addInitControllerActions(data, TARGET1);
+
+ beamState.setRoleAction(6, beamState.delInitControllerActions.selector, true);
+ beamState.setUserRole(USER1, 6, true);
+
+ vm.prank(USER1);
+ beamState.delInitControllerActions(key, TARGET1);
+
+ assertFalse(beamState.isControllerActionEnabled(key, TARGET1), "action should be disabled by role user");
+ }
+
+ // --- Combined functions Tests ---
+
+ function testFullRoleBasedWorkflow() public {
+ // Setup: Create roles for different operations
+
+ // Role 1: Can set hop
+ beamState.setRoleAction(1, beamState.setHop.selector, true);
+ // Role 2: Can manage cBeams and rateLimits
+ beamState.setRoleAction(2, beamState.addCBeam.selector, true);
+ beamState.setRoleAction(2, beamState.addRateLimits.selector, true);
+ beamState.setRoleAction(2, beamState.setCBeamForRateLimits.selector, true);
+ // Role 3: Can set rate limits
+ beamState.setRoleAction(3, beamState.addInitRateLimits.selector, true);
+
+ // Assign roles to users
+ beamState.setUserRole(USER1, 1, true);
+ beamState.setUserRole(USER2, 2, true);
+ beamState.setUserRole(USER2, 3, true); // USER2 has multiple roles
+
+ // USER1 sets hop
+ vm.prank(USER1);
+ beamState.setHop(TARGET1, 3_600);
+ assertEq(beamState.hop(TARGET1), 3_600, "USER1 should be able to set hop");
+
+ // USER2 adds cBeam
+ vm.prank(USER2);
+ beamState.addCBeam(CBEAM1);
+ assertEq(beamState.cBeams(CBEAM1), 1, "USER2 should be able to add cBeam");
+
+ // USER2 adds rateLimits
+ vm.prank(USER2);
+ beamState.addRateLimits(TARGET1);
+ assertEq(beamState.rateLimits(TARGET1), 1, "USER2 should be able to add rateLimits");
+
+ // USER2 sets cBeam for rateLimits
+ vm.prank(USER2);
+ beamState.setCBeamForRateLimits(TARGET1, CBEAM1);
+ assertEq(beamState.rateLimitsCBeams(TARGET1, CBEAM1), 1, "USER2 should be able to set rate limits cBeam for TARGET");
+
+ // USER2 adds rate limits
+ bytes32 key = keccak256("integration-key");
+ vm.prank(USER2);
+ beamState.addInitRateLimits(key, TARGET1, 1_000 * WAD, 10 * WAD);
+ BeamState.DefaultRateLimits memory limits = beamState.getInitRateLimits(key, TARGET1);
+ assertEq(limits.maxAmount, 1_000 * WAD, "USER2 should be able to add rate limits");
+
+ // USER1 cannot add cBeam (wrong role)
+ vm.prank(USER1);
+ vm.expectRevert("BeamState/role-not-authorized");
+ beamState.addCBeam(CBEAM2);
+ }
+
+ function testMultipleTargetConfiguration() public {
+ bytes32 key = keccak256("multi-target-key");
+
+ // Configure different settings for different TARGETs
+ beamState.setHop(TARGET1, 1_000);
+ beamState.setHop(TARGET2, 2_000);
+ beamState.setMaxChange(TARGET1, 2 * WAD);
+ beamState.setMaxChange(TARGET2, 3 * WAD);
+ beamState.addInitRateLimits(key, TARGET1, 100 * WAD, 1 * WAD);
+ beamState.addInitRateLimits(key, TARGET2, 200 * WAD, 2 * WAD);
+
+ // Verify each TARGET has its own configuration
+ assertEq(beamState.getHop(TARGET1), 1_000, "TARGET1 hop should be 1_000");
+ assertEq(beamState.getHop(TARGET2), 2_000, "TARGET2 hop should be 2_000");
+ assertEq(beamState.getMaxChange(TARGET1), 2 * WAD, "TARGET1 maxChange should be 2x");
+ assertEq(beamState.getMaxChange(TARGET2), 3 * WAD, "TARGET2 maxChange should be 3x");
+
+ BeamState.DefaultRateLimits memory limits1 = beamState.getInitRateLimits(key, TARGET1);
+ BeamState.DefaultRateLimits memory limits2 = beamState.getInitRateLimits(key, TARGET2);
+ assertEq(limits1.maxAmount, 100 * WAD, "TARGET1 maxAmount should be 100");
+ assertEq(limits2.maxAmount, 200 * WAD, "TARGET2 maxAmount should be 200");
+ }
+
+ function testUnlimitedRateLimitConfiguration() public {
+ bytes32 key = keccak256("unlimited-key");
+
+ // Set unlimited as global default
+ beamState.addInitRateLimits(key, address(0), type(uint256).max, 0);
+
+ // Now set unlimited for specific TARGET
+ beamState.addInitRateLimits(key, TARGET1, type(uint256).max, 0);
+
+ BeamState.DefaultRateLimits memory limits = beamState.getInitRateLimits(key, TARGET1);
+ assertEq(limits.maxAmount, type(uint256).max, "TARGET1 maxAmount should be unlimited");
+ assertEq(limits.slope, 0, "TARGET1 slope should be 0 for unlimited");
+
+ // Test that global unlimited also works for TARGET2 (fallback)
+ limits = beamState.getInitRateLimits(key, TARGET2);
+ assertEq(limits.maxAmount, type(uint256).max, "TARGET2 should fallback to global unlimited maxAmount");
+ assertEq(limits.slope, 0, "TARGET2 should fallback to global slope of 0 for unlimited");
+ }
+}
diff --git a/test/Configurator.t.sol b/test/Configurator.t.sol
new file mode 100644
index 0000000..1f05b36
--- /dev/null
+++ b/test/Configurator.t.sol
@@ -0,0 +1,987 @@
+// SPDX-FileCopyrightText: © 2026 Dai Foundation
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+pragma solidity ^0.8.24;
+
+import "dss-test/DssTest.sol";
+import { Configurator, BeamStateLike, RateLimitsLike } from "src/Configurator.sol";
+import { BeamState } from "src/BeamState.sol";
+
+// Mock TARGET contract implementing RateLimits interface
+contract MockTarget {
+ mapping(bytes32 key => RateLimitsLike.RateLimitData) public rateLimitData;
+ bool public shouldFail;
+ bytes public lastCallData;
+
+ function _min(uint256 a, uint256 b) internal pure returns (uint256) {
+ return a < b ? a : b;
+ }
+
+ function setRateLimitData(
+ bytes32 key,
+ uint256 maxAmount,
+ uint256 slope,
+ uint256 lastAmount,
+ uint256 lastUpdated
+ ) external {
+ rateLimitData[key] = RateLimitsLike.RateLimitData(maxAmount, slope, lastAmount, lastUpdated);
+ }
+
+ function setUnlimitedRateLimitData(bytes32 key) external {
+ rateLimitData[key] = RateLimitsLike.RateLimitData(type(uint256).max, 0, type(uint256).max, block.timestamp);
+ }
+
+ function getRateLimitData(bytes32 key) external view returns (RateLimitsLike.RateLimitData memory) {
+ return rateLimitData[key];
+ }
+
+ // Mimics real RateLimits: regenerates based on slope and elapsed time
+ function getCurrentRateLimit(bytes32 key) external view returns (uint256) {
+ RateLimitsLike.RateLimitData memory d = rateLimitData[key];
+ if (d.maxAmount == type(uint256).max) {
+ return type(uint256).max;
+ }
+ return _min(
+ d.slope * (block.timestamp - d.lastUpdated) + d.lastAmount,
+ d.maxAmount
+ );
+ }
+
+ function setShouldFail(bool _fail) external {
+ shouldFail = _fail;
+ }
+
+ // Mock controller function for testing callControllerAction
+ function controllerFunction(uint256 value) external returns (uint256) {
+ lastCallData = msg.data;
+ require(!shouldFail, "MockTarget/controller-function-failed");
+ return value * 2;
+ }
+}
+
+contract ConfiguratorTest is DssTest {
+
+ Configurator configurator;
+ BeamState beamState;
+ MockTarget target1;
+ MockTarget target2;
+
+ address constant CBEAM1 = address(0xC1);
+ address constant CBEAM2 = address(0xC2);
+ address constant USER1 = address(0x1);
+
+ event SetRateLimit(address indexed target, bytes32 indexed key, uint256 maxAmount, uint256 slope);
+ event CallControllerAction(address indexed controller, bytes data);
+
+ function setUp() public {
+ beamState = new BeamState();
+ configurator = new Configurator(address(beamState));
+
+ target1 = new MockTarget();
+ target2 = new MockTarget();
+
+ // Setup default configuration
+ beamState.setHop(address(0), 86_400); // 1 day global default
+ beamState.setMaxChange(address(0), 15 * WAD / 10); // 1.5x global default
+ }
+
+ // --- Constructor Tests ---
+
+ function testConstructor() public view {
+ assertEq(address(configurator.beamState()), address(beamState), "beamState should be set");
+ }
+
+ // --- Helper Functions ---
+
+ function _setupCBeam(address target, address cBeam) internal {
+ beamState.addController(target);
+ beamState.addRateLimits(target);
+ beamState.addCBeam(cBeam);
+ beamState.setCBeamForController(target, cBeam);
+ beamState.setCBeamForRateLimits(target, cBeam);
+ }
+
+ function _setupRateLimitData(
+ MockTarget target,
+ bytes32 key,
+ uint256 maxAmount,
+ uint256 slope,
+ uint256 lastAmount,
+ uint256 lastUpdated
+ ) internal {
+ target.setRateLimitData(key, maxAmount, slope, lastAmount, lastUpdated);
+ }
+
+ function _setupDefaultRateLimits(bytes32 key, address target, uint256 maxAmount, uint256 slope) internal {
+ beamState.addInitRateLimits(key, target, maxAmount, slope);
+ }
+
+ // --- Authorization Tests ---
+
+ function testAuthNotAuthorizedCBeam() public {
+ bytes32 key = keccak256("test-key");
+
+ vm.prank(CBEAM1);
+ vm.expectRevert("Configurator/not-authorized-ratelimits-cBeam");
+ configurator.setRateLimit(address(target1), key, 1_000 * WAD, 10 * WAD);
+ }
+
+ function testAuthWithAuthorizedCBeam() public {
+ bytes32 key = keccak256("test-key");
+ _setupCBeam(address(target1), CBEAM1);
+ _setupDefaultRateLimits(key, address(target1), 1_000 * WAD, 10 * WAD);
+ _setupRateLimitData(target1, key, 500 * WAD, 5 * WAD, 500 * WAD, block.timestamp);
+
+ vm.prank(CBEAM1);
+ configurator.setRateLimit(address(target1), key, 400 * WAD, 4 * WAD);
+
+ RateLimitsLike.RateLimitData memory data = target1.getRateLimitData(key);
+ assertEq(data.maxAmount, 400 * WAD, "maxAmount should be updated by authorized cBeam");
+ assertEq(data.slope, 4 * WAD, "slope should be updated by authorized cBeam");
+ }
+
+ function testAuthCBeamCannotConfigureDifferentTarget() public {
+ bytes32 key = keccak256("test-key");
+ _setupCBeam(address(target1), CBEAM1);
+
+ vm.prank(CBEAM1);
+ vm.expectRevert("Configurator/not-authorized-ratelimits-cBeam");
+ configurator.setRateLimit(address(target2), key, 1_000 * WAD, 10 * WAD);
+ }
+
+ // --- Decrease Rate Limit Tests (no hop required) ---
+
+ function testSetRateLimitDecreasing() public {
+ bytes32 key = keccak256("decreasing-key");
+ _setupCBeam(address(target1), CBEAM1);
+ _setupDefaultRateLimits(key, address(target1), 1_000 * WAD, 10 * WAD);
+ _setupRateLimitData(target1, key, 500 * WAD, 5 * WAD, 500 * WAD, block.timestamp);
+
+ vm.prank(CBEAM1);
+ vm.expectEmit();
+ emit SetRateLimit(address(target1), key, 400 * WAD, 4 * WAD);
+ configurator.setRateLimit(address(target1), key, 400 * WAD, 4 * WAD);
+
+ RateLimitsLike.RateLimitData memory data = target1.getRateLimitData(key);
+ assertEq(data.maxAmount, 400 * WAD, "maxAmount should decrease immediately");
+ assertEq(data.slope, 4 * WAD, "slope should decrease immediately");
+ }
+
+ function testSetRateLimitWithinDefaults() public {
+ bytes32 key = keccak256("within-defaults-key");
+ _setupCBeam(address(target1), CBEAM1);
+ _setupDefaultRateLimits(key, address(target1), 1_000 * WAD, 10 * WAD);
+ _setupRateLimitData(target1, key, 500 * WAD, 5 * WAD, 500 * WAD, block.timestamp);
+
+ vm.warp(block.timestamp + 86_400); // Any increase requires hop
+
+ // Increase but stay within defaults (ceiling is max of current*maxChange and default)
+ vm.prank(CBEAM1);
+ configurator.setRateLimit(address(target1), key, 800 * WAD, 8 * WAD);
+
+ RateLimitsLike.RateLimitData memory data = target1.getRateLimitData(key);
+ assertEq(data.maxAmount, 800 * WAD, "maxAmount should be set within defaults");
+ assertEq(data.slope, 8 * WAD, "slope should be set within defaults");
+ }
+
+ function testSetRateLimitDecreasingOneParameter() public {
+ bytes32 key = keccak256("partial-decrease-key");
+ _setupCBeam(address(target1), CBEAM1);
+ _setupDefaultRateLimits(key, address(target1), 1_000 * WAD, 10 * WAD);
+ _setupRateLimitData(target1, key, 500 * WAD, 5 * WAD, 500 * WAD, block.timestamp);
+
+ // Decrease maxAmount, keep slope same - no hop required
+ vm.prank(CBEAM1);
+ configurator.setRateLimit(address(target1), key, 400 * WAD, 5 * WAD);
+
+ RateLimitsLike.RateLimitData memory data = target1.getRateLimitData(key);
+ assertEq(data.maxAmount, 400 * WAD, "maxAmount should decrease");
+ assertEq(data.slope, 5 * WAD, "slope should stay same");
+ }
+
+ // --- Increase Rate Limit Tests (hop required) ---
+
+ function testSetRateLimitIncreasingTooSoon() public {
+ bytes32 key = keccak256("too-soon-key");
+ _setupCBeam(address(target1), CBEAM1);
+ _setupDefaultRateLimits(key, address(target1), 1_200 * WAD, 12 * WAD);
+ _setupRateLimitData(target1, key, 1_000 * WAD, 10 * WAD, 1_000 * WAD, block.timestamp);
+ beamState.setHop(address(target1), 3_600); // 1 hour hop
+ beamState.setMaxChange(address(target1), 15 * WAD / 10); // 1.5x
+
+ vm.warp(block.timestamp + 3_600); // Warp to allow first increase
+
+ // First increase (respects maxChange ceiling)
+ vm.prank(CBEAM1);
+ configurator.setRateLimit(address(target1), key, 1_300 * WAD, 13 * WAD); // 1.3x from current
+
+ // Try to increase again immediately (should fail)
+ vm.prank(CBEAM1);
+ vm.expectRevert("Configurator/increment-too-soon");
+ configurator.setRateLimit(address(target1), key, 1_400 * WAD, 14 * WAD);
+
+ // Fails also if one of the parameters is decreasing
+ vm.prank(CBEAM1);
+ vm.expectRevert("Configurator/increment-too-soon");
+ configurator.setRateLimit(address(target1), key, 1_400 * WAD, 12 * WAD);
+ vm.prank(CBEAM1);
+ vm.expectRevert("Configurator/increment-too-soon");
+ configurator.setRateLimit(address(target1), key, 1_200 * WAD, 14 * WAD);
+ }
+
+ function testSetRateLimitIncreasingAfterHop() public {
+ bytes32 key = keccak256("after-hop-key");
+ _setupCBeam(address(target1), CBEAM1);
+ _setupDefaultRateLimits(key, address(target1), 2_000 * WAD, 20 * WAD);
+ _setupRateLimitData(target1, key, 2_000 * WAD, 20 * WAD, 2_000 * WAD, block.timestamp);
+ beamState.setHop(address(target1), 3_600); // 1 hour hop
+ beamState.setMaxChange(address(target1), 15 * WAD / 10); // 1.5x
+
+ vm.warp(block.timestamp + 3_600); // Warp to allow first increase
+
+ // First increase (respects maxChange ceiling 1.5x)
+ vm.prank(CBEAM1);
+ configurator.setRateLimit(address(target1), key, 3_000 * WAD, 25 * WAD); // Exactly at defaults, 1.5x maxAmount, 1.25x slope
+
+ // Wait for hop period
+ vm.warp(block.timestamp + 3_600);
+
+ // Should succeed after hop (respecting maxChange 1.5x from last)
+ vm.prank(CBEAM1);
+ configurator.setRateLimit(address(target1), key, 4_500 * WAD, 37 * WAD); // Exactly 1.5x from 3_000, ~1.48x slope
+
+ RateLimitsLike.RateLimitData memory data = target1.getRateLimitData(key);
+ assertEq(data.maxAmount, 4_500 * WAD, "maxAmount should increase after hop");
+ assertEq(data.slope, 37 * WAD, "slope should increase after hop");
+ }
+
+ function testSetRateLimitMaxChangeEnforcedMaxAmount() public {
+ bytes32 key = keccak256("maxchange-max-key");
+ _setupCBeam(address(target1), CBEAM1);
+ _setupDefaultRateLimits(key, address(target1), 1_000 * WAD, 10 * WAD);
+ _setupRateLimitData(target1, key, 800 * WAD, 8 * WAD, 800 * WAD, block.timestamp);
+ beamState.setHop(address(target1), 1); // Minimal hop delay
+ beamState.setMaxChange(address(target1), 15 * WAD / 10); // 1.5x
+
+ vm.warp(block.timestamp + 1); // Warp past hop to allow attempt
+
+ // Try to increase more than ceiling allows
+ vm.prank(CBEAM1);
+ vm.expectRevert("Configurator/exceeds-max-amount");
+ configurator.setRateLimit(address(target1), key, 1_300 * WAD, 8 * WAD); // 1.625x increase (> 1.5x)
+ }
+
+ function testSetRateLimitMaxChangeEnforcedSlope() public {
+ bytes32 key = keccak256("maxchange-slope-key");
+ _setupCBeam(address(target1), CBEAM1);
+ _setupDefaultRateLimits(key, address(target1), 1_000 * WAD, 10 * WAD);
+ _setupRateLimitData(target1, key, 1_000 * WAD, 8 * WAD, 1_000 * WAD, block.timestamp);
+ beamState.setHop(address(target1), 1); // Minimal hop delay
+ beamState.setMaxChange(address(target1), 15 * WAD / 10); // 1.5x
+
+ vm.warp(block.timestamp + 1); // Warp past hop to allow attempt
+
+ // Try to increase slope more than ceiling allows
+ vm.prank(CBEAM1);
+ vm.expectRevert("Configurator/exceeds-max-slope");
+ configurator.setRateLimit(address(target1), key, 1_000 * WAD, 13 * WAD); // 1.625x increase (> 1.5x)
+ }
+
+ function testSetRateLimitMaxChangeAtLimit() public {
+ bytes32 key = keccak256("maxchange-exact-key");
+ _setupCBeam(address(target1), CBEAM1);
+ _setupDefaultRateLimits(key, address(target1), 1_000 * WAD, 100 * WAD);
+ _setupRateLimitData(target1, key, 1_000 * WAD, 10 * WAD, 1_000 * WAD, block.timestamp);
+ beamState.setHop(address(target1), 3_600);
+ beamState.setMaxChange(address(target1), 15 * WAD / 10); // 1.5x
+
+ vm.warp(block.timestamp + 3_600);
+
+ // Exactly at maxChange limit should succeed
+ vm.prank(CBEAM1);
+ configurator.setRateLimit(address(target1), key, 1_500 * WAD, 15 * WAD);
+
+ RateLimitsLike.RateLimitData memory data = target1.getRateLimitData(key);
+ assertEq(data.maxAmount, 1_500 * WAD, "maxAmount should be at maxChange limit");
+ assertEq(data.slope, 15 * WAD, "slope should be at maxChange limit");
+ }
+
+ // --- Timestamp Tracking Tests ---
+
+ function testZzzTimestampNotSetOnDecrease() public {
+ bytes32 key = keccak256("zzz-decrease-key");
+ _setupCBeam(address(target1), CBEAM1);
+ _setupDefaultRateLimits(key, address(target1), 1_000 * WAD, 100 * WAD);
+ _setupRateLimitData(target1, key, 1_000 * WAD, 10 * WAD, 1_000 * WAD, block.timestamp);
+
+ vm.prank(CBEAM1);
+ configurator.setRateLimit(address(target1), key, 800 * WAD, 8 * WAD);
+
+ assertEq(configurator.zzz(address(target1), key), 0, "zzz should not be set on decrease");
+ }
+
+ function testZzzTimestampUpdatedOnSecondIncrease() public {
+ bytes32 key = keccak256("zzz-second-key");
+ _setupCBeam(address(target1), CBEAM1);
+ _setupDefaultRateLimits(key, address(target1), 1_000 * WAD, 100 * WAD);
+ _setupRateLimitData(target1, key, 1_000 * WAD, 10 * WAD, 1_000 * WAD, block.timestamp);
+ beamState.setHop(address(target1), 3_600);
+ vm.warp(block.timestamp + 3_600);
+
+ uint256 firstTime = block.timestamp;
+ vm.prank(CBEAM1);
+ configurator.setRateLimit(address(target1), key, 1_100 * WAD, 11 * WAD);
+ assertEq(configurator.zzz(address(target1), key), firstTime, "zzz should be set to first increase time");
+
+ vm.warp(block.timestamp + 3_600);
+ uint256 secondTime = block.timestamp;
+
+ vm.prank(CBEAM1);
+ configurator.setRateLimit(address(target1), key, 1_200 * WAD, 12 * WAD);
+ assertEq(configurator.zzz(address(target1), key), secondTime, "zzz should be updated to second increase time");
+ }
+
+ // --- Unlimited Rate Limit Tests ---
+
+ function testSetUnlimitedRateLimit() public {
+ bytes32 key = keccak256("unlimited-key");
+ _setupCBeam(address(target1), CBEAM1);
+ // Set unlimited for specific TARGET
+ _setupDefaultRateLimits(key, address(target1), type(uint256).max, 0);
+ _setupRateLimitData(target1, key, 1_000 * WAD, 10 * WAD, 1_000 * WAD, block.timestamp);
+
+ vm.prank(CBEAM1);
+ vm.expectEmit();
+ emit SetRateLimit(address(target1), key, type(uint256).max, 0);
+ configurator.setRateLimit(address(target1), key, type(uint256).max, 0);
+
+ RateLimitsLike.RateLimitData memory data = target1.getRateLimitData(key);
+ assertEq(data.maxAmount, type(uint256).max, "maxAmount should be unlimited");
+ assertEq(data.slope, 0, "slope should be 0");
+ }
+
+ function testRevertSetLimitedWhenDefaultsUnlimited() public {
+ bytes32 key = keccak256("unlimited-revert-key");
+ _setupCBeam(address(target1), CBEAM1);
+ _setupRateLimitData(target1, key, 1_000 * WAD, 10 * WAD, 1_000 * WAD, block.timestamp);
+
+ // Set defaults to unlimited
+ _setupDefaultRateLimits(key, address(target1), type(uint256).max, 0);
+
+ // Should revert when passing limited values with unlimited defaults
+ vm.prank(CBEAM1);
+ vm.expectRevert("Configurator/unlimited-incorrect-params");
+ configurator.setRateLimit(address(target1), key, 1_000 * WAD, 10 * WAD);
+ }
+
+ function testRevertLowerWhenCurrentUnlimitedAndUnregistered() public {
+ bytes32 key = keccak256("current-unlimited-key");
+ _setupCBeam(address(target1), CBEAM1);
+ // Key is currently unlimited on-chain but NOT registered in beamState (no defaults)
+ _setupRateLimitData(target1, key, type(uint256).max, 0, type(uint256).max, block.timestamp);
+
+ // cBeam must not be able to lower an existing unlimited key
+ vm.prank(CBEAM1);
+ vm.expectRevert("Configurator/unlimited-incorrect-params");
+ configurator.setRateLimit(address(target1), key, 1_000 * WAD, 10 * WAD);
+ }
+
+ function testSetUnlimitedWhenCurrentUnlimitedAndUnregistered() public {
+ bytes32 key = keccak256("current-unlimited-idempotent-key");
+ _setupCBeam(address(target1), CBEAM1);
+ // Currently unlimited on-chain, not registered in beamState
+ _setupRateLimitData(target1, key, type(uint256).max, 0, type(uint256).max, block.timestamp);
+
+ // Passing unlimited is accepted (the only value allowed while locked)
+ vm.prank(CBEAM1);
+ vm.expectEmit();
+ emit SetRateLimit(address(target1), key, type(uint256).max, 0);
+ configurator.setRateLimit(address(target1), key, type(uint256).max, 0);
+
+ RateLimitsLike.RateLimitData memory data = target1.getRateLimitData(key);
+ assertEq(data.maxAmount, type(uint256).max, "maxAmount should remain unlimited");
+ assertEq(data.slope, 0, "slope should remain 0");
+ }
+
+ function testLowerAllowedWhenCurrentUnlimitedButGeneralDefaultSet() public {
+ bytes32 key = keccak256("current-unlimited-with-general-key");
+ _setupCBeam(address(target1), CBEAM1);
+ // Currently unlimited on-chain
+ _setupRateLimitData(target1, key, type(uint256).max, 0, type(uint256).max, block.timestamp);
+ // A general (address(0)) bounded default exists for this key -> the lock does not apply
+ _setupDefaultRateLimits(key, address(0), 500 * WAD, 5 * WAD);
+
+ // cBeam can lower it (no increase, so no hop needed)
+ vm.prank(CBEAM1);
+ configurator.setRateLimit(address(target1), key, 1_000 * WAD, 0);
+
+ RateLimitsLike.RateLimitData memory data = target1.getRateLimitData(key);
+ assertEq(data.maxAmount, 1_000 * WAD, "maxAmount should be lowered from unlimited");
+ assertEq(data.slope, 0, "slope should be 0");
+ }
+
+ function testLowerAllowedWhenCurrentMaxAmountUnlimitedButSlopeNonZero() public {
+ bytes32 key = keccak256("current-maxamount-only-key");
+ _setupCBeam(address(target1), CBEAM1);
+ // maxAmount is max but slope is non-zero -> not the canonical unlimited encoding, so not locked
+ _setupRateLimitData(target1, key, type(uint256).max, 10 * WAD, type(uint256).max, block.timestamp);
+
+ vm.prank(CBEAM1);
+ configurator.setRateLimit(address(target1), key, 1_000 * WAD, 0);
+
+ RateLimitsLike.RateLimitData memory data = target1.getRateLimitData(key);
+ assertEq(data.maxAmount, 1_000 * WAD, "maxAmount should be lowered");
+ assertEq(data.slope, 0, "slope should be 0");
+ }
+
+ // --- Overflow Protection Tests ---
+
+ function testSetRateLimitNoOverflowWhenCurrentMaxAmountIsMax() public {
+ bytes32 key = keccak256("overflow-key");
+ _setupCBeam(address(target1), CBEAM1);
+ _setupDefaultRateLimits(key, address(target1), 500 * WAD, 10 * WAD);
+ // Current maxAmount is type(uint256).max — would overflow in `current.maxAmount * maxChange / WAD`
+ _setupRateLimitData(target1, key, type(uint256).max, 10 * WAD, type(uint256).max, block.timestamp);
+
+ // Decrease to finite value above defaults — should not overflow
+ vm.prank(CBEAM1);
+ configurator.setRateLimit(address(target1), key, 1_000 * WAD, 10 * WAD);
+
+ RateLimitsLike.RateLimitData memory data = target1.getRateLimitData(key);
+ assertEq(data.maxAmount, 1_000 * WAD, "maxAmount should decrease from type(uint256).max");
+ assertEq(data.slope, 10 * WAD, "slope should be set correctly");
+ }
+
+ function testSetRateLimitNoOverflowWhenCurrentSlopeIsMax() public {
+ bytes32 key = keccak256("overflow-slope-key");
+ _setupCBeam(address(target1), CBEAM1);
+ _setupDefaultRateLimits(key, address(target1), 1_000 * WAD, 500 * WAD);
+ // Current slope is type(uint256).max — would overflow in `current.slope * maxChange / WAD`
+ _setupRateLimitData(target1, key, 1_000 * WAD, type(uint256).max, 1_000 * WAD, block.timestamp);
+
+ // Decrease slope to finite value above defaults — should not overflow
+ vm.prank(CBEAM1);
+ configurator.setRateLimit(address(target1), key, 1_000 * WAD, 1_000 * WAD);
+
+ RateLimitsLike.RateLimitData memory data = target1.getRateLimitData(key);
+ assertEq(data.maxAmount, 1_000 * WAD, "maxAmount should be set correctly");
+ assertEq(data.slope, 1_000 * WAD, "slope should decrease from type(uint256).max");
+ }
+
+ // --- LastAmount Capping Tests ---
+
+ function testLastAmountCappedAtMaxAmount() public {
+ bytes32 key = keccak256("cap-key");
+ _setupCBeam(address(target1), CBEAM1);
+ _setupDefaultRateLimits(key, address(target1), 1_000 * WAD, 10 * WAD);
+ // Current: maxAmount=2000, slope=20, lastAmount=1500
+ // getCurrentRateLimit (no time elapsed) = min(20*0 + 1500, 2000) = 1500
+ _setupRateLimitData(target1, key, 2_000 * WAD, 20 * WAD, 1_500 * WAD, block.timestamp);
+
+ // Decrease maxAmount below current rate limit (1500 -> 1000)
+ // lastAmount = min(newMaxAmount, currentRateLimit) = min(1000, 1500) = 1000
+ vm.prank(CBEAM1);
+ configurator.setRateLimit(address(target1), key, 1_000 * WAD, 10 * WAD);
+
+ RateLimitsLike.RateLimitData memory data = target1.getRateLimitData(key);
+ assertEq(data.lastAmount, 1_000 * WAD, "lastAmount should be capped at new maxAmount");
+ }
+
+ function testLastAmountPreservedWhenBelowMax() public {
+ bytes32 key = keccak256("preserve-key");
+ _setupCBeam(address(target1), CBEAM1);
+ _setupDefaultRateLimits(key, address(target1), 2_000 * WAD, 20 * WAD);
+ // Current: maxAmount=1000, slope=10, lastAmount=100
+ // Use small lastAmount so regeneration after hop doesn't hit maxAmount
+ _setupRateLimitData(target1, key, 1_000 * WAD, 10 * WAD, 100 * WAD, block.timestamp);
+ beamState.setHop(address(target1), 1); // 1 second hop
+
+ vm.warp(block.timestamp + 1); // Minimal warp to satisfy hop
+ // After 1s: getCurrentRateLimit = min(10*1 + 100, 1000) = 110 WAD
+
+ // Increase maxAmount to 1500 (above current rate limit of 110)
+ // lastAmount = min(newMaxAmount, currentRateLimit) = min(1500, 110) = 110
+ vm.prank(CBEAM1);
+ configurator.setRateLimit(address(target1), key, 1_500 * WAD, 15 * WAD);
+
+ RateLimitsLike.RateLimitData memory data = target1.getRateLimitData(key);
+ assertEq(data.lastAmount, 110 * WAD, "lastAmount should be preserved when below new max");
+ }
+
+ // --- Global Default Fallback Tests ---
+
+ function testGlobalDefaultsUsedWhenNoSpecific() public {
+ bytes32 key = keccak256("global-key");
+ _setupCBeam(address(target1), CBEAM1);
+ _setupDefaultRateLimits(key, address(0), 1_000 * WAD, 10 * WAD); // Global defaults
+ _setupRateLimitData(target1, key, 500 * WAD, 5 * WAD, 500 * WAD, block.timestamp);
+
+ vm.warp(block.timestamp + 86_400); // Any increase requires hop
+
+ // Should use global defaults
+ vm.prank(CBEAM1);
+ configurator.setRateLimit(address(target1), key, 900 * WAD, 9 * WAD);
+
+ RateLimitsLike.RateLimitData memory data = target1.getRateLimitData(key);
+ assertEq(data.maxAmount, 900 * WAD, "should work with global defaults 1");
+ assertEq(data.slope, 9 * WAD, "should work with global defaults 2");
+ }
+
+ function testSpecificDefaultsOverrideGlobal() public {
+ bytes32 key = keccak256("specific-override-key");
+ _setupCBeam(address(target1), CBEAM1);
+ _setupDefaultRateLimits(key, address(0), 1_000 * WAD, 10 * WAD); // Global
+ _setupDefaultRateLimits(key, address(target1), 2_000 * WAD, 20 * WAD); // Specific
+ _setupRateLimitData(target1, key, 500 * WAD, 5 * WAD, 500 * WAD, block.timestamp);
+
+ vm.warp(block.timestamp + 86_400); // Any increase requires hop
+
+ // Should use TARGET1 specific defaults (higher limits)
+ vm.prank(CBEAM1);
+ configurator.setRateLimit(address(target1), key, 1_500 * WAD, 15 * WAD);
+
+ RateLimitsLike.RateLimitData memory data = target1.getRateLimitData(key);
+ assertEq(data.maxAmount, 1_500 * WAD, "should work with specific defaults 1");
+ assertEq(data.slope, 15 * WAD, "should work with specific defaults 2");
+ }
+
+ function testGlobalHopUsedWhenNoSpecific() public {
+ bytes32 key = keccak256("global-hop-key");
+ _setupCBeam(address(target1), CBEAM1);
+ _setupDefaultRateLimits(key, address(target1), 1_000 * WAD, 10 * WAD);
+ _setupRateLimitData(target1, key, 900 * WAD, 9 * WAD, 900 * WAD, block.timestamp);
+
+ // Only global hop set (in setUp)
+ assertEq(beamState.hop(address(target1)), 0, "TARGET1 specific hop should be 0");
+ assertEq(beamState.getHop(address(target1)), 86_400, "should fallback to global hop");
+
+ vm.warp(block.timestamp + 86_400); // Warp to allow first increase
+
+ // First increase (sets zzz)
+ vm.prank(CBEAM1);
+ configurator.setRateLimit(address(target1), key, 1_100 * WAD, 11 * WAD);
+
+ // Try second increase immediately - should fail due to hop
+ vm.prank(CBEAM1);
+ vm.expectRevert("Configurator/increment-too-soon");
+ configurator.setRateLimit(address(target1), key, 1_200 * WAD, 12 * WAD);
+ }
+
+ function testGlobalMaxChangeUsedWhenNoSpecific() public {
+ bytes32 key = keccak256("global-maxchange-key");
+ _setupCBeam(address(target1), CBEAM1);
+ _setupDefaultRateLimits(key, address(target1), 1_000 * WAD, 10 * WAD);
+ _setupRateLimitData(target1, key, 900 * WAD, 9 * WAD, 900 * WAD, block.timestamp);
+
+ // Only global maxChange set (in setUp to 1.5x)
+ assertEq(beamState.maxChange(address(target1)), 0, "TARGET1 specific maxChange should be 0");
+ assertEq(beamState.getMaxChange(address(target1)), 15 * WAD / 10, "should fallback to global maxChange");
+
+ vm.warp(block.timestamp + 86_400); // Wait for hop
+
+ // Try to increase by 1.67x - should fail due to global maxChange ceiling of 1.5x
+ vm.prank(CBEAM1);
+ vm.expectRevert("Configurator/exceeds-max-amount");
+ configurator.setRateLimit(address(target1), key, 1_500 * WAD, 9 * WAD);
+ }
+
+ function testSpecificHopUsedOverGlobal() public {
+ bytes32 key = keccak256("specific-hop-key");
+ _setupCBeam(address(target1), CBEAM1);
+ _setupDefaultRateLimits(key, address(target1), 1_000 * WAD, 10 * WAD);
+ _setupRateLimitData(target1, key, 900 * WAD, 9 * WAD, 900 * WAD, block.timestamp);
+
+ // Set specific hop shorter than global (1 hour vs 1 day)
+ beamState.setHop(address(target1), 3_600);
+
+ assertGt(beamState.hop(address(0)), 3_600, "global hop should be greater than 3_600");
+ assertEq(beamState.hop(address(target1)), 3_600, "TARGET1 specific hop should be 3600");
+ assertEq(beamState.getHop(address(target1)), 3_600, "should use specific hop");
+
+ vm.warp(block.timestamp + 3_600); // Warp to specific hop (shorter than global)
+
+ // First increase (sets zzz)
+ vm.prank(CBEAM1);
+ configurator.setRateLimit(address(target1), key, 1_100 * WAD, 11 * WAD);
+
+ // Try second increase immediately - should fail due to hop
+ vm.prank(CBEAM1);
+ vm.expectRevert("Configurator/increment-too-soon");
+ configurator.setRateLimit(address(target1), key, 1_200 * WAD, 12 * WAD);
+
+ // Wait for specific hop period (not the global 86_400)
+ vm.warp(block.timestamp + 3_600);
+
+ // Should succeed after specific hop period
+ vm.prank(CBEAM1);
+ configurator.setRateLimit(address(target1), key, 1_200 * WAD, 12 * WAD);
+
+ RateLimitsLike.RateLimitData memory data = target1.getRateLimitData(key);
+ assertEq(data.maxAmount, 1_200 * WAD, "maxAmount should increase after specific hop");
+ assertEq(data.slope, 12 * WAD, "slope should increase after specific hop");
+ }
+
+ function testSpecificMaxChangeUsedOverGlobal() public {
+ bytes32 key = keccak256("specific-maxchange-key");
+ _setupCBeam(address(target1), CBEAM1);
+ _setupDefaultRateLimits(key, address(target1), 1_000 * WAD, 10 * WAD);
+ _setupRateLimitData(target1, key, 900 * WAD, 9 * WAD, 900 * WAD, block.timestamp);
+
+ // Set specific maxChange higher than global (2x vs 1.5x)
+ beamState.setMaxChange(address(target1), 2 * WAD);
+
+ assertLt(beamState.maxChange(address(0)), 2 * WAD, "global maxChange should be less than 2x");
+ assertEq(beamState.maxChange(address(target1)), 2 * WAD, "TARGET1 specific maxChange should be 2x");
+ assertEq(beamState.getMaxChange(address(target1)), 2 * WAD, "should use specific maxChange");
+
+ vm.warp(block.timestamp + 86_400); // Wait for hop
+
+ // Increase by 1.67x - would fail with global 1.5x, but succeeds with specific 2x
+ vm.prank(CBEAM1);
+ configurator.setRateLimit(address(target1), key, 1_500 * WAD, 15 * WAD);
+
+ RateLimitsLike.RateLimitData memory data = target1.getRateLimitData(key);
+ assertEq(data.maxAmount, 1_500 * WAD, "should allow 1.67x increase with specific 2x maxChange");
+ assertEq(data.slope, 15 * WAD, "should allow 1.67x increase with specific 2x slope");
+
+ vm.warp(block.timestamp + 86_400); // Wait for next hop
+
+ // Try to exceed the specific maxChange (2x) - should fail
+ vm.prank(CBEAM1);
+ vm.expectRevert("Configurator/exceeds-max-amount");
+ configurator.setRateLimit(address(target1), key, 3_100 * WAD, 15 * WAD); // >2x from 1500
+ }
+
+ // --- Controller Action Tests ---
+
+ function testCallControllerAction() public {
+ bytes memory data = abi.encodeWithSignature("controllerFunction(uint256)", 42);
+
+ _setupCBeam(address(target1), CBEAM1);
+ beamState.addInitControllerActions(data, address(target1));
+
+ vm.prank(CBEAM1);
+ vm.expectEmit();
+ emit CallControllerAction(address(target1), data);
+ bytes memory ret = configurator.callControllerAction(address(target1), data);
+
+ uint256 result = abi.decode(ret, (uint256));
+ assertEq(result, 84, "controller function should return 42 * 2");
+ assertEq(target1.lastCallData(), data, "correct data should be passed to TARGET");
+ }
+
+ function testCallControllerActionNotWhitelisted() public {
+ bytes memory data = abi.encodeWithSignature("controllerFunction(uint256)", 42);
+
+ _setupCBeam(address(target1), CBEAM1);
+ // Don't whitelist the action
+
+ vm.prank(CBEAM1);
+ vm.expectRevert("Configurator/not-valid-data");
+ configurator.callControllerAction(address(target1), data);
+ }
+
+ function testCallControllerActionNotAuthorized() public {
+ bytes memory data = abi.encodeWithSignature("controllerFunction(uint256)", 42);
+
+ beamState.addInitControllerActions(data, address(target1));
+ // Don't setup cBeam authorization
+
+ vm.prank(CBEAM1);
+ vm.expectRevert("Configurator/not-authorized-controller-cBeam");
+ configurator.callControllerAction(address(target1), data);
+ }
+
+ function testCallControllerActionGlobalWhitelist() public {
+ bytes memory data = abi.encodeWithSignature("controllerFunction(uint256)", 100);
+
+ _setupCBeam(address(target1), CBEAM1);
+ _setupCBeam(address(target2), CBEAM1);
+ beamState.addInitControllerActions(data, address(0)); // Global whitelist
+
+ // Should work for any TARGET
+ vm.prank(CBEAM1);
+ vm.expectEmit();
+ emit CallControllerAction(address(target1), data);
+ bytes memory ret1 = configurator.callControllerAction(address(target1), data);
+ assertEq(abi.decode(ret1, (uint256)), 200, "should work with global whitelist for target1");
+
+ vm.prank(CBEAM1);
+ vm.expectEmit();
+ emit CallControllerAction(address(target2), data);
+ bytes memory ret2 = configurator.callControllerAction(address(target2), data);
+ assertEq(abi.decode(ret2, (uint256)), 200, "should work with global whitelist for target2");
+ }
+
+ function testCallControllerActionFails() public {
+ bytes memory data = abi.encodeWithSignature("controllerFunction(uint256)", 42);
+
+ _setupCBeam(address(target1), CBEAM1);
+ beamState.addInitControllerActions(data, address(target1));
+ target1.setShouldFail(true);
+
+ vm.prank(CBEAM1);
+ vm.expectRevert("Configurator/call-failed");
+ configurator.callControllerAction(address(target1), data);
+ }
+
+ // --- Combined functions Tests ---
+
+ function testFullRateLimitWorkflow() public {
+ bytes32 key = keccak256("workflow-key");
+ _setupCBeam(address(target1), CBEAM1);
+ _setupDefaultRateLimits(key, address(target1), 1_000 * WAD, 10 * WAD);
+ _setupRateLimitData(target1, key, 800 * WAD, 8 * WAD, 800 * WAD, block.timestamp);
+ beamState.setHop(address(target1), 3_600);
+ beamState.setMaxChange(address(target1), 2 * WAD); // 2x
+
+ vm.warp(block.timestamp + 3_600); // Warp to allow first increase
+
+ // 1. Decrease limits (immediate, no hop required)
+ vm.prank(CBEAM1);
+ configurator.setRateLimit(address(target1), key, 700 * WAD, 7 * WAD);
+
+ RateLimitsLike.RateLimitData memory data = target1.getRateLimitData(key);
+ assertEq(data.maxAmount, 700 * WAD, "step 1: decrease should work immediately");
+
+ // 2. Increase limits (sets zzz timestamp)
+ vm.prank(CBEAM1);
+ configurator.setRateLimit(address(target1), key, 1_200 * WAD, 12 * WAD);
+
+ data = target1.getRateLimitData(key);
+ assertEq(data.maxAmount, 1_200 * WAD, "step 2: first increase should work");
+ uint256 firstIncrease = configurator.zzz(address(target1), key);
+ assertGt(firstIncrease, 0, "step 2: zzz should be set");
+
+ // 3. Try immediate second increase (should fail)
+ vm.prank(CBEAM1);
+ vm.expectRevert("Configurator/increment-too-soon");
+ configurator.setRateLimit(address(target1), key, 1_400 * WAD, 14 * WAD);
+
+ // 4. Wait hop period
+ vm.warp(block.timestamp + 3_600);
+
+ // 5. Increase again (should work, respecting maxChange)
+ vm.prank(CBEAM1);
+ configurator.setRateLimit(address(target1), key, 2_400 * WAD, 24 * WAD); // Exactly 2x
+
+ data = target1.getRateLimitData(key);
+ assertEq(data.maxAmount, 2_400 * WAD, "step 5: second increase should work after hop");
+ assertEq(data.slope, 24 * WAD, "step 5: slope should increase");
+
+ // 6. Try to exceed maxChange
+ vm.warp(block.timestamp + 3_600);
+ vm.prank(CBEAM1);
+ vm.expectRevert("Configurator/exceeds-max-amount");
+ configurator.setRateLimit(address(target1), key, 4_900 * WAD, 24 * WAD); // >2x (2.04x)
+ }
+
+ function testMultipleCBeamsMultipleTargets() public {
+ bytes32 key = keccak256("multi-key");
+
+ // Setup CBEAM1 for TARGET1
+ _setupCBeam(address(target1), CBEAM1);
+ _setupDefaultRateLimits(key, address(target1), 1_000 * WAD, 10 * WAD);
+ _setupRateLimitData(target1, key, 500 * WAD, 5 * WAD, 500 * WAD, block.timestamp);
+
+ // Setup CBEAM2 for TARGET2
+ _setupCBeam(address(target2), CBEAM2);
+ _setupDefaultRateLimits(key, address(target2), 2_000 * WAD, 20 * WAD);
+ _setupRateLimitData(target2, key, 1_000 * WAD, 10 * WAD, 1_000 * WAD, block.timestamp);
+
+ vm.warp(block.timestamp + 86_400); // Any increase requires hop
+
+ // CBEAM1 configures TARGET1
+ vm.prank(CBEAM1);
+ configurator.setRateLimit(address(target1), key, 600 * WAD, 6 * WAD);
+
+ // CBEAM2 configures TARGET2
+ vm.prank(CBEAM2);
+ configurator.setRateLimit(address(target2), key, 1_200 * WAD, 12 * WAD);
+
+ // Verify independent operation
+ RateLimitsLike.RateLimitData memory data1 = target1.getRateLimitData(key);
+ RateLimitsLike.RateLimitData memory data2 = target2.getRateLimitData(key);
+
+ assertEq(data1.maxAmount, 600 * WAD, "TARGET1 should have its own limits");
+ assertEq(data2.maxAmount, 1_200 * WAD, "TARGET2 should have its own limits");
+
+ // CBEAM1 cannot configure TARGET2
+ vm.prank(CBEAM1);
+ vm.expectRevert("Configurator/not-authorized-ratelimits-cBeam");
+ configurator.setRateLimit(address(target2), key, 1_500 * WAD, 15 * WAD);
+ }
+
+ function testIncreaseThenDecreaseThenIncrease() public {
+ bytes32 key = keccak256("toggle-key");
+ _setupCBeam(address(target1), CBEAM1);
+ _setupDefaultRateLimits(key, address(target1), 1_000 * WAD, 10 * WAD);
+ _setupRateLimitData(target1, key, 900 * WAD, 9 * WAD, 900 * WAD, block.timestamp);
+ beamState.setHop(address(target1), 3_600);
+
+ vm.warp(block.timestamp + 3_600); // Warp to allow first increase
+
+ // Increase, sets zzz
+ vm.prank(CBEAM1);
+ configurator.setRateLimit(address(target1), key, 1_100 * WAD, 11 * WAD);
+ uint256 zzzAfterIncrease = configurator.zzz(address(target1), key);
+ assertGt(zzzAfterIncrease, 0, "zzz should be set after increase");
+
+ // Decrease (doesn't update zzz)
+ vm.prank(CBEAM1);
+ configurator.setRateLimit(address(target1), key, 900 * WAD, 9 * WAD);
+ assertEq(configurator.zzz(address(target1), key), zzzAfterIncrease, "zzz should not change on decrease");
+
+ // Immediate increase doesn't work, requires hop
+ vm.prank(CBEAM1);
+ vm.expectRevert("Configurator/increment-too-soon");
+ configurator.setRateLimit(address(target1), key, 1_000 * WAD, 10 * WAD);
+
+ // After hop, increase within defaults should work
+ vm.warp(block.timestamp + 3_600);
+ vm.prank(CBEAM1);
+ configurator.setRateLimit(address(target1), key, 1_000 * WAD, 10 * WAD);
+
+ // Immediate increase should fail (hop just used)
+ vm.prank(CBEAM1);
+ vm.expectRevert("Configurator/increment-too-soon");
+ configurator.setRateLimit(address(target1), key, 1_200 * WAD, 12 * WAD);
+
+ // After another hop, increase should work
+ vm.warp(block.timestamp + 3_600);
+ vm.prank(CBEAM1);
+ configurator.setRateLimit(address(target1), key, 1_200 * WAD, 12 * WAD);
+
+ RateLimitsLike.RateLimitData memory data = target1.getRateLimitData(key);
+ assertEq(data.maxAmount, 1_200 * WAD, "should be able to increase after hop");
+ }
+
+ function testEdgeCaseZeroValues() public {
+ bytes32 key = keccak256("zero-key");
+ _setupCBeam(address(target1), CBEAM1);
+ _setupDefaultRateLimits(key, address(target1), 1_000 * WAD, 10 * WAD);
+ _setupRateLimitData(target1, key, 500 * WAD, 5 * WAD, 500 * WAD, block.timestamp);
+
+ // Set to zero (should be allowed as it's decreasing)
+ vm.prank(CBEAM1);
+ configurator.setRateLimit(address(target1), key, 0, 0);
+
+ RateLimitsLike.RateLimitData memory data = target1.getRateLimitData(key);
+ assertEq(data.maxAmount, 0, "maxAmount can be set to 0");
+ assertEq(data.slope, 0, "slope can be set to 0");
+ }
+
+ function testRevertHopNotSet() public {
+ bytes32 key = keccak256("hop-not-set-key");
+ _setupCBeam(address(target1), CBEAM1);
+ _setupDefaultRateLimits(key, address(target1), 1_000 * WAD, 10 * WAD);
+ _setupRateLimitData(target1, key, 500 * WAD, 5 * WAD, 500 * WAD, block.timestamp);
+
+ // Clear global hop to simulate hop-not-set scenario
+ beamState.setHop(address(0), 0);
+ // Ensure no specific hop for target1
+ beamState.setHop(address(target1), 0);
+
+ vm.warp(block.timestamp + 86_400); // Warp time (doesn't matter since hop is 0)
+
+ // Try to increase rate limits - should revert
+ vm.prank(CBEAM1);
+ vm.expectRevert("Configurator/hop-not-set");
+ configurator.setRateLimit(address(target1), key, 600 * WAD, 6 * WAD);
+ }
+
+ function testDifferentKeysIndependentZzz() public {
+ bytes32 key1 = keccak256("key-1");
+ bytes32 key2 = keccak256("key-2");
+
+ _setupCBeam(address(target1), CBEAM1);
+ _setupDefaultRateLimits(key1, address(target1), 1_000 * WAD, 10 * WAD);
+ _setupDefaultRateLimits(key2, address(target1), 1_000 * WAD, 10 * WAD);
+ _setupRateLimitData(target1, key1, 900 * WAD, 9 * WAD, 900 * WAD, block.timestamp);
+ _setupRateLimitData(target1, key2, 900 * WAD, 9 * WAD, 900 * WAD, block.timestamp);
+ beamState.setHop(address(target1), 3_600);
+
+ vm.warp(block.timestamp + 3_600); // Warp to allow first increase
+
+ // Increase key1 (sets zzz for key1)
+ vm.prank(CBEAM1);
+ configurator.setRateLimit(address(target1), key1, 1_100 * WAD, 11 * WAD);
+
+ // Increase key2 immediately should work (different key)
+ vm.prank(CBEAM1);
+ configurator.setRateLimit(address(target1), key2, 1_100 * WAD, 11 * WAD);
+
+ // But can't increase key1 again immediately
+ vm.prank(CBEAM1);
+ vm.expectRevert("Configurator/increment-too-soon");
+ configurator.setRateLimit(address(target1), key1, 1_200 * WAD, 12 * WAD);
+
+ assertGt(configurator.zzz(address(target1), key1), 0, "key1 should have zzz set");
+ assertGt(configurator.zzz(address(target1), key2), 0, "key2 should have zzz set");
+ }
+
+ // --- Stop/Start Tests ---
+
+ function testSetRateLimitStoppedAndAfterRestart() public {
+ bytes32 key = keccak256("test-key");
+
+ _setupCBeam(address(target1), CBEAM1);
+ _setupDefaultRateLimits(key, address(target1), 1_000 * WAD, 10 * WAD);
+ _setupRateLimitData(target1, key, 500 * WAD, 5 * WAD, 500 * WAD, block.timestamp);
+
+ vm.warp(block.timestamp + 86_400); // Any increase requires hop
+
+ beamState.stop();
+
+ // Try to set rate limit - should revert
+ vm.prank(CBEAM1);
+ vm.expectRevert("Configurator/stopped");
+ configurator.setRateLimit(address(target1), key, 600 * WAD, 6 * WAD);
+
+ beamState.start();
+
+ // Now it should work
+ vm.prank(CBEAM1);
+ configurator.setRateLimit(address(target1), key, 600 * WAD, 6 * WAD);
+
+ RateLimitsLike.RateLimitData memory result = target1.getRateLimitData(key);
+ assertEq(result.maxAmount, 600 * WAD, "maxAmount should be updated after restart");
+ }
+
+ function testCallControllerActionStoppedAndAfterRestart() public {
+ _setupCBeam(address(target1), CBEAM1);
+
+ bytes memory data = abi.encodeWithSignature("controllerFunction(uint256)", 123);
+ beamState.addInitControllerActions(data, address(0)); // Global whitelist
+
+ beamState.stop();
+
+ // Try to call controller action - should revert
+ vm.prank(CBEAM1);
+ vm.expectRevert("Configurator/stopped");
+ configurator.callControllerAction(address(target1), data);
+
+ beamState.start();
+
+ // Now it should work
+ vm.prank(CBEAM1);
+ bytes memory ret = configurator.callControllerAction(address(target1), data);
+
+ uint256 result = abi.decode(ret, (uint256));
+ assertEq(result, 246, "controller function should return 123 * 2");
+ assertEq(target1.lastCallData(), data, "correct data should be passed to target");
+ }
+}
diff --git a/test/Integration.t.sol b/test/Integration.t.sol
new file mode 100644
index 0000000..c2d2cda
--- /dev/null
+++ b/test/Integration.t.sol
@@ -0,0 +1,1198 @@
+// SPDX-FileCopyrightText: © 2026 Dai Foundation
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+pragma solidity ^0.8.24;
+
+import "dss-test/DssTest.sol";
+import { MCD, DssInstance } from "dss-test/MCD.sol";
+import { PASInstance } from "deploy/PASInstance.sol";
+import { PASDeploy } from "deploy/PASDeploy.sol";
+import { PASInit, InitRateLimitConfig, InitControllerActionConfig, InitCBeamConfig } from "deploy/PASInit.sol";
+import { BeamState } from "src/BeamState.sol";
+import { Configurator, RateLimitsLike } from "src/Configurator.sol";
+import { Timelock } from "src/timelock/Timelock.sol";
+import { PASMom } from "src/PASMom.sol";
+
+interface ChiefLike {
+ function hat() external view returns (address);
+}
+
+// Interface for SparkController
+interface ControllerLike {
+ function rateLimits() external view returns (address);
+ function setMintRecipient(uint32 domain, bytes32 mintRecipient) external;
+ function mintRecipients(uint32 domain) external view returns (bytes32);
+ function grantRole(bytes32 role, address account) external;
+}
+
+// Extended RateLimitsLike with role management
+interface RateLimitsWithRolesLike {
+ struct RateLimitData {
+ uint256 maxAmount;
+ uint256 slope;
+ uint256 lastAmount;
+ uint256 lastUpdated;
+ }
+
+ function getRateLimitData(bytes32) external view returns (RateLimitData memory);
+ function getCurrentRateLimit(bytes32) external view returns (uint256);
+ function setRateLimitData(bytes32, uint256, uint256, uint256, uint256) external;
+ function setUnlimitedRateLimitData(bytes32) external;
+ function grantRole(bytes32 role, address account) external;
+}
+
+contract IntegrationTest is DssTest {
+
+ // Mainnet addresses
+ address constant SPARK_CONTROLLER = 0xE52d643B27601D4d2BAB2052f30cf936ed413cec;
+ address constant SPARK_PROXY = 0x3300f198988e4C9C63F75dF86De36421f06af8c4;
+ address constant CHAINLOG = 0xdA0Ab1e0017DEbCd72Be8599041a2aa3bA7e740F;
+ bytes32 constant OZ_DEFAULT_ADMIN_ROLE = bytes32(0);
+
+ // Fetched from controller
+ address SPARK_RATE_LIMITS;
+
+ DssInstance dss;
+ PASInstance pas;
+ BeamState beamState;
+ Configurator configurator;
+ Timelock timelock;
+ PASMom mom;
+ ChiefLike chief;
+
+ address pauseProxy; // Timelock admin
+ address coreCouncil; // Has IMMEDIATE role on BeamState, proposer/canceller on Timelock
+ address canceller;
+ address pauser;
+ address cBeam; // Mock cBeam address
+
+ uint256 constant MIN_DELAY = 1 days;
+ bytes32 constant SALT = keccak256("integration-test");
+
+ function setUp() public {
+ // Fork mainnet at specified block
+ vm.createSelectFork(vm.envString("ETH_RPC_URL"));
+
+ // Load DssInstance from chainlog
+ dss = MCD.loadFromChainlog(CHAINLOG);
+
+ // Fetch SPARK_RATE_LIMITS from controller
+ SPARK_RATE_LIMITS = ControllerLike(SPARK_CONTROLLER).rateLimits();
+
+ pauseProxy = dss.chainlog.getAddress("MCD_PAUSE_PROXY");
+
+ coreCouncil = address(0x1);
+ canceller = address(0x2);
+ pauser = address(0x3);
+ cBeam = address(0x4);
+
+ // Deploy using PASDeploy
+ pas = PASDeploy.deploy(address(this), pauseProxy, MIN_DELAY);
+ // Cast to typed contracts
+ beamState = BeamState(pas.beamState);
+ configurator = Configurator(pas.configurator);
+ timelock = Timelock(payable(pas.timelock));
+ // Deploy Mom separately
+ mom = PASMom(PASDeploy.deployMom(pauseProxy, pas.beamState, pas.timelock));
+
+ chief = ChiefLike(dss.chainlog.getAddress("MCD_ADM"));
+
+ // Initialize using PASInit (must be called by pauseProxy who has auth on BeamState and Timelock)
+ address[] memory cancellers = new address[](1);
+ cancellers[0] = canceller;
+
+ address[] memory pausers = new address[](1);
+ pausers[0] = pauser;
+
+ vm.startPrank(pauseProxy);
+ PASInit.init(pas, MIN_DELAY, coreCouncil, cancellers, pausers);
+ PASInit.addCoreToChainlog(dss, pas, "PAS_STATE", "PAS_CONFIGURATOR", "PAS_TIMELOCK");
+ PASInit.initMom(dss, pas, address(mom), "PAS_MOM");
+ vm.stopPrank();
+ }
+
+ // ============================================================================
+ // Deployment and Initialization Tests
+ // ============================================================================
+
+ function testDeploymentAddresses() public view {
+ assertTrue(pas.beamState != address(0), "beamState should be deployed");
+ assertTrue(pas.configurator != address(0), "configurator should be deployed");
+ assertTrue(pas.timelock != address(0), "timelock should be deployed");
+ assertTrue(address(mom) != address(0), "mom should be deployed");
+ }
+
+ function testConfiguratorLinkedToBeamState() public view {
+ assertEq(address(configurator.beamState()), address(beamState), "configurator should reference beamState");
+ }
+
+ function testMomLinkedCorrectly() public view {
+ assertEq(address(mom.beamState()), address(beamState), "mom should reference beamState");
+ assertEq(address(mom.timelock()), address(timelock), "mom should reference timelock");
+ assertEq(mom.owner(), pauseProxy, "mom should be owned by pauseProxy");
+ }
+
+ function testChainlogEntriesAfterInit() public view {
+ assertEq(dss.chainlog.getAddress("PAS_STATE"), address(beamState), "PAS_STATE should be set in chainlog");
+ assertEq(dss.chainlog.getAddress("PAS_CONFIGURATOR"), address(configurator), "PAS_CONFIGURATOR should be set in chainlog");
+ assertEq(dss.chainlog.getAddress("PAS_TIMELOCK"), address(timelock), "PAS_TIMELOCK should be set in chainlog");
+ assertEq(dss.chainlog.getAddress("PAS_MOM"), address(mom), "PAS_MOM should be set in chainlog");
+ }
+
+ function testBeamStateActionsConfigured() public view {
+ // DELAYED role actions (timelocked)
+ assertTrue(beamState.isActionInRole(BeamState.start.selector, uint8(PASInit.Role.DELAYED)), "start should be DELAYED");
+ assertTrue(beamState.isActionInRole(BeamState.setHop.selector, uint8(PASInit.Role.DELAYED)), "setHop should be DELAYED");
+ assertTrue(beamState.isActionInRole(BeamState.setMaxChange.selector, uint8(PASInit.Role.DELAYED)), "setMaxChange should be DELAYED");
+ assertTrue(beamState.isActionInRole(BeamState.addRateLimits.selector, uint8(PASInit.Role.DELAYED)), "addRateLimits should be DELAYED");
+ assertTrue(beamState.isActionInRole(BeamState.addController.selector, uint8(PASInit.Role.DELAYED)), "addController should be DELAYED");
+ assertTrue(beamState.isActionInRole(BeamState.addCBeam.selector, uint8(PASInit.Role.DELAYED)), "addCBeam should be DELAYED");
+ assertTrue(beamState.isActionInRole(BeamState.addInitRateLimits.selector, uint8(PASInit.Role.DELAYED)), "addInitRateLimits should be DELAYED");
+ assertTrue(beamState.isActionInRole(BeamState.addInitControllerActions.selector, uint8(PASInit.Role.DELAYED)), "addInitControllerActions should be DELAYED");
+
+ // IMMEDIATE role actions (direct)
+ assertTrue(beamState.isActionInRole(BeamState.stop.selector, uint8(PASInit.Role.IMMEDIATE)), "stop should be IMMEDIATE");
+ assertTrue(beamState.isActionInRole(BeamState.delRateLimits.selector, uint8(PASInit.Role.IMMEDIATE)), "delRateLimits should be IMMEDIATE");
+ assertTrue(beamState.isActionInRole(BeamState.delController.selector, uint8(PASInit.Role.IMMEDIATE)), "delController should be IMMEDIATE");
+ assertTrue(beamState.isActionInRole(BeamState.delCBeam.selector, uint8(PASInit.Role.IMMEDIATE)), "delCBeam should be IMMEDIATE");
+ assertTrue(beamState.isActionInRole(BeamState.setCBeamForRateLimits.selector, uint8(PASInit.Role.IMMEDIATE)), "setCBeamForRateLimits should be IMMEDIATE");
+ assertTrue(beamState.isActionInRole(BeamState.unsetCBeamForRateLimits.selector, uint8(PASInit.Role.IMMEDIATE)), "unsetCBeamForRateLimits should be IMMEDIATE");
+ assertTrue(beamState.isActionInRole(BeamState.setCBeamForController.selector, uint8(PASInit.Role.IMMEDIATE)), "setCBeamForController should be IMMEDIATE");
+ assertTrue(beamState.isActionInRole(BeamState.unsetCBeamForController.selector, uint8(PASInit.Role.IMMEDIATE)), "unsetCBeamForController should be IMMEDIATE");
+ assertTrue(beamState.isActionInRole(BeamState.delInitRateLimits.selector, uint8(PASInit.Role.IMMEDIATE)), "delInitRateLimits should be IMMEDIATE");
+ assertTrue(beamState.isActionInRole(BeamState.delInitControllerActions.selector, uint8(PASInit.Role.IMMEDIATE)), "delInitControllerActions should be IMMEDIATE");
+ }
+
+ function testBeamStateRolesAfterInit() public view {
+ // Timelock has DELAYED role
+ assertTrue(beamState.hasUserRole(address(timelock), uint8(PASInit.Role.DELAYED)), "timelock should have DELAYED role");
+ // CoreCouncil has IMMEDIATE role
+ assertTrue(beamState.hasUserRole(coreCouncil, uint8(PASInit.Role.IMMEDIATE)), "coreCouncil should have IMMEDIATE role");
+ }
+
+ function testTimelockRolesAfterInit() public view {
+ assertTrue(timelock.hasRole(timelock.DEFAULT_ADMIN_ROLE(), pauseProxy), "pauseProxy should be admin");
+ assertTrue(timelock.hasRole(timelock.PROPOSER_ROLE(), coreCouncil), "coreCouncil should be proposer");
+ assertTrue(timelock.hasRole(timelock.CANCELLER_ROLE(), coreCouncil), "coreCouncil should be canceller");
+ assertTrue(timelock.hasRole(timelock.CANCELLER_ROLE(), canceller), "canceller should be canceller");
+ assertTrue(timelock.hasRole(timelock.PAUSER_ROLE(), pauser), "pauser should be pauser");
+ assertTrue(timelock.hasRole(timelock.PAUSER_ROLE(), address(mom)), "mom should be pauser");
+ }
+
+ function testMomConfigAfterInit() public view {
+ assertEq(mom.owner(), pauseProxy, "mom owner should be pauseProxy");
+ assertEq(mom.authority(), address(chief), "mom authority should be MCD_ADM");
+ assertEq(beamState.wards(address(mom)), 1, "mom should have wards on beamState");
+ }
+
+
+ function testPauseThenUnpauseTimelock() public {
+ PASInstance memory freshPas = PASDeploy.deploy(address(this), pauseProxy, MIN_DELAY);
+ Timelock freshTimelock = Timelock(payable(freshPas.timelock));
+
+ vm.startPrank(pauseProxy);
+ PASInit.init(freshPas, MIN_DELAY, coreCouncil, new address[](0), new address[](0));
+ // init alone does not pause the timelock
+ assertFalse(freshTimelock.paused(), "timelock should not be paused by init alone");
+ PASInit.pauseTimelock(freshPas.timelock, pauseProxy);
+ vm.stopPrank();
+
+ assertTrue(freshTimelock.paused(), "timelock should be paused after pauseTimelock");
+ assertFalse(freshTimelock.hasRole(freshTimelock.PAUSER_ROLE(), pauseProxy), "admin should not retain PAUSER_ROLE");
+ assertTrue(freshTimelock.hasRole(freshTimelock.PROPOSER_ROLE(), coreCouncil), "coreCouncil should still be configured as proposer");
+
+ // Configured but frozen: scheduling blocked while paused
+ address[] memory targets = new address[](1);
+ targets[0] = freshPas.beamState;
+ uint256[] memory values = new uint256[](1);
+ bytes[] memory payloads = new bytes[](1);
+ payloads[0] = abi.encodeWithSelector(BeamState.addCBeam.selector, cBeam);
+ vm.prank(coreCouncil);
+ vm.expectRevert();
+ freshTimelock.scheduleBatch(targets, values, payloads, bytes32(0), SALT, MIN_DELAY);
+
+ // unpause() resumes operations (needs only DEFAULT_ADMIN_ROLE, so the admin calls it directly)
+ vm.prank(pauseProxy);
+ freshTimelock.unpause();
+ assertFalse(freshTimelock.paused(), "timelock should be unpaused");
+
+ vm.prank(coreCouncil);
+ freshTimelock.scheduleBatch(targets, values, payloads, bytes32(0), SALT, MIN_DELAY);
+ bytes32 opId = freshTimelock.hashOperationBatch(targets, values, payloads, bytes32(0), SALT);
+ assertTrue(freshTimelock.isOperationPending(opId), "operation should schedule after unpause");
+ }
+
+ function testInitExtras() public {
+ // Deploy a fresh PAS instance for this test
+ PASInstance memory freshPas = PASDeploy.deploy(address(this), pauseProxy, MIN_DELAY);
+ BeamState freshBeamState = BeamState(freshPas.beamState);
+
+ // Setup test data
+ uint256 hop = 2 hours;
+ uint256 maxChange = 1.5 ether; // 150% in WAD
+
+ address[] memory testCBeams = new address[](2);
+ testCBeams[0] = address(0x10);
+ testCBeams[1] = address(0x11);
+
+ address[] memory testRateLimits = new address[](2);
+ testRateLimits[0] = SPARK_RATE_LIMITS;
+ testRateLimits[1] = address(0x21);
+
+ address[] memory testControllers = new address[](2);
+ testControllers[0] = SPARK_CONTROLLER;
+ testControllers[1] = address(0x22);
+
+ // cBeam[0] is paired with both rateLimits and both controllers; cBeam[1] gets only the second rateLimits
+ InitCBeamConfig[] memory cBeamConfigs = new InitCBeamConfig[](2);
+ cBeamConfigs[0] = InitCBeamConfig({
+ cBeam: testCBeams[0],
+ rateLimits: testRateLimits,
+ controllers: testControllers
+ });
+ address[] memory cBeam1RateLimits = new address[](1);
+ cBeam1RateLimits[0] = testRateLimits[1];
+ cBeamConfigs[1] = InitCBeamConfig({
+ cBeam: testCBeams[1],
+ rateLimits: cBeam1RateLimits,
+ controllers: new address[](0)
+ });
+
+ vm.startPrank(pauseProxy);
+ PASInit.initExtras(freshPas, hop, maxChange, testRateLimits, testControllers, cBeamConfigs);
+ vm.stopPrank();
+
+ // Verify default hop and maxChange are set
+ assertEq(freshBeamState.getHop(address(0)), hop, "default hop should be set");
+ assertEq(freshBeamState.getMaxChange(address(0)), maxChange, "default maxChange should be set");
+
+ // Verify rateLimits are added
+ assertEq(freshBeamState.rateLimits(testRateLimits[0]), 1, "first rateLimits should be added");
+ assertEq(freshBeamState.rateLimits(testRateLimits[1]), 1, "second rateLimits should be added");
+
+ // Verify controllers are added
+ assertEq(freshBeamState.controllers(testControllers[0]), 1, "first controller should be added");
+ assertEq(freshBeamState.controllers(testControllers[1]), 1, "second controller should be added");
+
+ // Verify cBeams are added
+ assertEq(freshBeamState.cBeams(testCBeams[0]), 1, "first cBeam should be added");
+ assertEq(freshBeamState.cBeams(testCBeams[1]), 1, "second cBeam should be added");
+
+ // Verify cBeam pairings
+ assertEq(freshBeamState.rateLimitsCBeams(testRateLimits[0], testCBeams[0]), 1, "cBeam0<->rateLimits0 paired");
+ assertEq(freshBeamState.rateLimitsCBeams(testRateLimits[1], testCBeams[0]), 1, "cBeam0<->rateLimits1 paired");
+ assertEq(freshBeamState.controllersCBeams(testControllers[0], testCBeams[0]), 1, "cBeam0<->controller0 paired");
+ assertEq(freshBeamState.controllersCBeams(testControllers[1], testCBeams[0]), 1, "cBeam0<->controller1 paired");
+ assertEq(freshBeamState.rateLimitsCBeams(testRateLimits[0], testCBeams[1]), 0, "cBeam1<->rateLimits0 not paired");
+ assertEq(freshBeamState.rateLimitsCBeams(testRateLimits[1], testCBeams[1]), 1, "cBeam1<->rateLimits1 paired");
+ assertEq(freshBeamState.controllersCBeams(testControllers[0], testCBeams[1]), 0, "cBeam1<->controller0 not paired");
+ assertEq(freshBeamState.controllersCBeams(testControllers[1], testCBeams[1]), 0, "cBeam1<->controller1 not paired");
+ }
+
+ function initExtras() external {
+ PASInstance memory freshPas = PASDeploy.deploy(address(this), address(this), MIN_DELAY);
+ PASInit.initExtras(freshPas, 0, 1.5 ether, new address[](0), new address[](0), new InitCBeamConfig[](0));
+ }
+
+ function testInitExtrasRevertsWhenHopIsZero() public {
+ vm.expectRevert("PASInit/hop-is-zero");
+ this.initExtras();
+ }
+
+ function testInitLimitsAndControllerData() public {
+ PASInstance memory freshPas = PASDeploy.deploy(address(this), pauseProxy, MIN_DELAY);
+
+ // Setup rate limit configs
+ InitRateLimitConfig[] memory rlConfigs = new InitRateLimitConfig[](2);
+ rlConfigs[0] = InitRateLimitConfig({
+ key: bytes32(0),
+ rateLimits: SPARK_RATE_LIMITS,
+ maxAmount: 1_000_000 ether,
+ slope: 100 ether
+ });
+ rlConfigs[1] = InitRateLimitConfig({
+ key: bytes32(uint256(1)),
+ rateLimits: address(0x42),
+ maxAmount: 500_000 ether,
+ slope: 50 ether
+ });
+
+ // Setup controller action configs
+ bytes memory actionData1 = abi.encodeWithSelector(bytes4(0xdeadbeef), uint256(123));
+ bytes memory actionData2 = abi.encodeWithSelector(bytes4(0xcafebabe), address(0x99));
+ InitControllerActionConfig[] memory caConfigs = new InitControllerActionConfig[](2);
+ caConfigs[0] = InitControllerActionConfig({data: actionData1, controller: SPARK_CONTROLLER});
+ caConfigs[1] = InitControllerActionConfig({data: actionData2, controller: address(0)});
+
+ BeamState freshBeamState = BeamState(freshPas.beamState);
+
+ vm.startPrank(pauseProxy);
+ PASInit.initLimitsAndControllerData(freshPas, rlConfigs, caConfigs);
+ vm.stopPrank();
+
+ // Verify init rate limits
+ (uint256 maxAmount0, uint256 slope0) = freshBeamState.initRateLimits(bytes32(0), SPARK_RATE_LIMITS);
+ assertEq(maxAmount0, 1_000_000 ether, "first rate limit maxAmount");
+ assertEq(slope0, 100 ether, "first rate limit slope");
+
+ (uint256 maxAmount1, uint256 slope1) = freshBeamState.initRateLimits(bytes32(uint256(1)), address(0x42));
+ assertEq(maxAmount1, 500_000 ether, "second rate limit maxAmount");
+ assertEq(slope1, 50 ether, "second rate limit slope");
+
+ // Verify init controller actions
+ assertEq(freshBeamState.initControllerActions(keccak256(actionData1), SPARK_CONTROLLER), 1, "first controller action");
+ assertEq(freshBeamState.initControllerActions(keccak256(actionData2), address(0)), 1, "second controller action");
+ }
+
+ // ============================================================================
+ // CoreCouncil Direct Actions (IMMEDIATE Role) Tests
+ // ============================================================================
+
+ function testCoreCouncilCanStop() public {
+ assertFalse(beamState.stopped(), "should not be stopped initially");
+
+ vm.prank(coreCouncil);
+ beamState.stop();
+
+ assertTrue(beamState.stopped(), "should be stopped after coreCouncil calls stop");
+ }
+
+ function testCoreCouncilCanDelRateLimits() public {
+ // Setup: pauseProxy adds rateLimits
+ vm.prank(pauseProxy);
+ beamState.addRateLimits(SPARK_RATE_LIMITS);
+ assertEq(beamState.rateLimits(SPARK_RATE_LIMITS), 1, "rateLimits should be added");
+
+ // CoreCouncil can delete (role 2)
+ vm.prank(coreCouncil);
+ beamState.delRateLimits(SPARK_RATE_LIMITS);
+
+ assertEq(beamState.rateLimits(SPARK_RATE_LIMITS), 0, "rateLimits should be deleted");
+ }
+
+ function testCoreCouncilCanDelController() public {
+ // Setup: pauseProxy adds controller
+ vm.prank(pauseProxy);
+ beamState.addController(SPARK_CONTROLLER);
+ assertEq(beamState.controllers(SPARK_CONTROLLER), 1, "controller should be added");
+
+ // CoreCouncil can delete (role 2)
+ vm.prank(coreCouncil);
+ beamState.delController(SPARK_CONTROLLER);
+
+ assertEq(beamState.controllers(SPARK_CONTROLLER), 0, "controller should be deleted");
+ }
+
+ function testCoreCouncilCanDelCBeam() public {
+ // pauseProxy adds cBeam directly (has auth/wards)
+ vm.prank(pauseProxy);
+ beamState.addCBeam(cBeam);
+ assertEq(beamState.cBeams(cBeam), 1, "cBeam should be added");
+
+ // CoreCouncil can delete (role 2 action)
+ vm.prank(coreCouncil);
+ beamState.delCBeam(cBeam);
+ assertEq(beamState.cBeams(cBeam), 0, "cBeam should be deleted");
+ }
+
+ function testCoreCouncilCanSetAndUnsetCBeamForRateLimits() public {
+ // Setup: pauseProxy adds rateLimits and cBeam, then sets association
+ vm.startPrank(pauseProxy);
+ beamState.addRateLimits(SPARK_RATE_LIMITS);
+ beamState.addCBeam(cBeam);
+ vm.stopPrank();
+
+ vm.prank(coreCouncil);
+ beamState.setCBeamForRateLimits(SPARK_RATE_LIMITS, cBeam);
+ assertEq(beamState.rateLimitsCBeams(SPARK_RATE_LIMITS, cBeam), 1, "cBeam should be set");
+
+ // CoreCouncil can unset association (role 2)
+ vm.prank(coreCouncil);
+ beamState.unsetCBeamForRateLimits(SPARK_RATE_LIMITS, cBeam);
+ assertEq(beamState.rateLimitsCBeams(SPARK_RATE_LIMITS, cBeam), 0, "cBeam should be unset for rateLimits");
+ }
+
+ function testCoreCouncilCanSetAndUnsetCBeamForController() public {
+ // Setup: pauseProxy adds controller and cBeam, then sets association
+ vm.startPrank(pauseProxy);
+ beamState.addController(SPARK_CONTROLLER);
+ beamState.addCBeam(cBeam);
+ vm.stopPrank();
+
+ vm.prank(coreCouncil);
+ beamState.setCBeamForController(SPARK_CONTROLLER, cBeam);
+ assertEq(beamState.controllersCBeams(SPARK_CONTROLLER, cBeam), 1, "cBeam should be set");
+
+ // CoreCouncil can unset association (role 2)
+ vm.prank(coreCouncil);
+ beamState.unsetCBeamForController(SPARK_CONTROLLER, cBeam);
+
+ assertEq(beamState.controllersCBeams(SPARK_CONTROLLER, cBeam), 0, "cBeam should be unset for controller");
+ }
+
+ function testCoreCouncilCanDelInitRateLimits() public {
+ bytes32 key = keccak256("test-rate-limit-key");
+
+ // Setup: pauseProxy adds init rate limits
+ vm.prank(pauseProxy);
+ beamState.addInitRateLimits(key, SPARK_RATE_LIMITS, 1_000 ether, 100 ether);
+
+ BeamState.DefaultRateLimits memory limits = beamState.getInitRateLimits(key, SPARK_RATE_LIMITS);
+ assertEq(limits.maxAmount, 1_000 ether, "maxAmount should be set");
+ assertEq(limits.slope, 100 ether, "slope should be set");
+
+ // CoreCouncil can delete (role 2)
+ vm.prank(coreCouncil);
+ beamState.delInitRateLimits(key, SPARK_RATE_LIMITS);
+ limits = beamState.getInitRateLimits(key, SPARK_RATE_LIMITS);
+ assertEq(limits.maxAmount, 0, "maxAmount should be deleted");
+ assertEq(limits.slope, 0, "slope should be deleted");
+ }
+
+ function testCoreCouncilCanDelInitControllerActions() public {
+ bytes memory actionData = abi.encodeWithSignature("someAction(uint256)", 42);
+
+ // Setup: pauseProxy adds init controller action
+ vm.prank(pauseProxy);
+ bytes32 key = beamState.addInitControllerActions(actionData, SPARK_CONTROLLER);
+
+ assertTrue(beamState.isControllerActionEnabled(key, SPARK_CONTROLLER), "action should be enabled");
+
+ // CoreCouncil can delete (role 2)
+ vm.prank(coreCouncil);
+ beamState.delInitControllerActions(key, SPARK_CONTROLLER);
+ assertFalse(beamState.isControllerActionEnabled(key, SPARK_CONTROLLER), "action should be disabled");
+ }
+
+ function testNonAuthorizedCannotDoRole2Actions() public {
+ address unauthorized = address(0x999);
+
+ vm.prank(unauthorized);
+ vm.expectRevert("BeamState/role-not-authorized");
+ beamState.stop();
+ }
+
+ // ============================================================================
+ // Timelock Flow Tests (DELAYED Role Actions)
+ // ============================================================================
+
+ function _scheduleAndExecute(bytes memory payload, bytes32 salt) internal {
+ address[] memory targets = new address[](1);
+ targets[0] = address(beamState);
+ uint256[] memory values = new uint256[](1);
+ bytes[] memory payloads = new bytes[](1);
+ payloads[0] = payload;
+
+ vm.prank(coreCouncil);
+ timelock.scheduleBatch(targets, values, payloads, bytes32(0), salt, MIN_DELAY);
+
+ vm.warp(block.timestamp + MIN_DELAY);
+ timelock.executeBatch(targets, values, payloads, bytes32(0), salt);
+ }
+
+ function testTimelockCanStart() public {
+ // First stop via coreCouncil (role 2)
+ vm.prank(coreCouncil);
+ beamState.stop();
+ assertTrue(beamState.stopped(), "should be stopped");
+
+ // Schedule and execute start via timelock (role 1)
+ _scheduleAndExecute(abi.encodeWithSelector(BeamState.start.selector), keccak256("start"));
+
+ assertFalse(beamState.stopped(), "should be started via timelock");
+ }
+
+ function testTimelockCanSetHop() public {
+ uint256 newHop = 4 hours;
+
+ _scheduleAndExecute(
+ abi.encodeWithSelector(BeamState.setHop.selector, address(0), newHop),
+ keccak256("setHop")
+ );
+
+ assertEq(beamState.getHop(address(0)), newHop, "hop should be set via timelock");
+ }
+
+ function testTimelockCanSetMaxChange() public {
+ uint256 newMaxChange = 2 ether; // 200% in WAD
+
+ _scheduleAndExecute(
+ abi.encodeWithSelector(BeamState.setMaxChange.selector, address(0), newMaxChange),
+ keccak256("setMaxChange")
+ );
+
+ assertEq(beamState.getMaxChange(address(0)), newMaxChange, "maxChange should be set via timelock");
+ }
+
+ function testTimelockCanAddRateLimits() public {
+ address newRateLimits = address(0xA1);
+
+ _scheduleAndExecute(
+ abi.encodeWithSelector(BeamState.addRateLimits.selector, newRateLimits),
+ keccak256("addRateLimits")
+ );
+
+ assertEq(beamState.rateLimits(newRateLimits), 1, "rateLimits should be added via timelock");
+ }
+
+ function testTimelockCanAddController() public {
+ address newController = address(0xC0);
+
+ _scheduleAndExecute(
+ abi.encodeWithSelector(BeamState.addController.selector, newController),
+ keccak256("addController")
+ );
+
+ assertEq(beamState.controllers(newController), 1, "controller should be added via timelock");
+ }
+
+ function testTimelockCanAddCBeam() public {
+ _scheduleAndExecute(
+ abi.encodeWithSelector(BeamState.addCBeam.selector, cBeam),
+ keccak256("addCBeam")
+ );
+
+ assertEq(beamState.cBeams(cBeam), 1, "cBeam should be added via timelock");
+ }
+
+ function testTimelockCanAddInitRateLimits() public {
+ bytes32 key = keccak256("test-key");
+ uint256 maxAmount = 1_000 ether;
+ uint256 slope = 100 ether;
+
+ _scheduleAndExecute(
+ abi.encodeWithSelector(BeamState.addInitRateLimits.selector, key, SPARK_RATE_LIMITS, maxAmount, slope),
+ keccak256("addInitRateLimits")
+ );
+
+ BeamState.DefaultRateLimits memory limits = beamState.getInitRateLimits(key, SPARK_RATE_LIMITS);
+ assertEq(limits.maxAmount, maxAmount, "maxAmount should be set via timelock");
+ assertEq(limits.slope, slope, "slope should be set via timelock");
+ }
+
+ function testTimelockCanAddInitControllerActions() public {
+ bytes memory actionData = abi.encodeWithSignature("someAction(uint256)", 42);
+
+ _scheduleAndExecute(
+ abi.encodeWithSelector(BeamState.addInitControllerActions.selector, actionData, SPARK_CONTROLLER),
+ keccak256("addInitControllerActions")
+ );
+
+ bytes32 key = keccak256(actionData);
+ assertTrue(beamState.isControllerActionEnabled(key, SPARK_CONTROLLER), "action should be enabled via timelock");
+ }
+
+ // ============================================================================
+ // Cancellation Tests
+ // ============================================================================
+
+ function testCoreCouncilCanCancelOperation() public {
+ // Schedule operation
+ bytes32 operationId = _scheduleDirect(
+ abi.encodeWithSelector(BeamState.addCBeam.selector, cBeam),
+ bytes32(0),
+ SALT
+ );
+
+ assertTrue(timelock.isOperationPending(operationId), "operation should be pending");
+
+ // Cancel
+ vm.prank(coreCouncil);
+ timelock.cancel(operationId);
+
+ assertFalse(timelock.isOperationPending(operationId), "operation should be cancelled");
+ assertEq(timelock.getOperationsCount(), 0, "operation count should be 0");
+ }
+
+ function testCancellerCanCancelOperation() public {
+ bytes32 operationId = _scheduleDirect(
+ abi.encodeWithSelector(BeamState.addCBeam.selector, cBeam),
+ bytes32(0),
+ SALT
+ );
+
+ vm.prank(canceller);
+ timelock.cancel(operationId);
+
+ assertFalse(timelock.isOperationPending(operationId), "operation should be cancelled by canceller");
+ }
+
+ // ============================================================================
+ // Pausing Tests
+ // ============================================================================
+
+ function testPauserCanPauseTimelock() public {
+ vm.prank(pauser);
+ timelock.pause();
+
+ assertTrue(timelock.paused(), "timelock should be paused");
+ }
+
+ function testPausedTimelockBlocksScheduling() public {
+ vm.prank(pauser);
+ timelock.pause();
+
+ address[] memory targets = new address[](1);
+ targets[0] = address(beamState);
+ uint256[] memory values = new uint256[](1);
+ bytes[] memory payloads = new bytes[](1);
+ payloads[0] = abi.encodeWithSelector(BeamState.addCBeam.selector, cBeam);
+
+ vm.prank(coreCouncil);
+ vm.expectRevert();
+ timelock.scheduleBatch(targets, values, payloads, bytes32(0), SALT, MIN_DELAY);
+ }
+
+ function testPausedTimelockBlocksExecution() public {
+ // Schedule first
+ bytes32 operationId = _scheduleDirect(
+ abi.encodeWithSelector(BeamState.addCBeam.selector, cBeam),
+ bytes32(0),
+ SALT
+ );
+
+ vm.warp(block.timestamp + MIN_DELAY);
+
+ // Pause
+ vm.prank(pauser);
+ timelock.pause();
+
+ // Try to execute
+ Timelock.Operation memory op = timelock.getOperation(operationId);
+ vm.expectRevert();
+ timelock.executeBatch(op.targets, op.values, op.payloads, op.predecessor, op.salt);
+ }
+
+ function testAdminCanUnpause() public {
+ vm.prank(pauser);
+ timelock.pause();
+
+ vm.prank(pauseProxy);
+ timelock.unpause();
+
+ assertFalse(timelock.paused(), "timelock should be unpaused");
+ }
+
+ function testPauserCannotUnpause() public {
+ vm.prank(pauser);
+ timelock.pause();
+
+ vm.prank(pauser);
+ vm.expectRevert();
+ timelock.unpause();
+ }
+
+ // ============================================================================
+ // Predecessor Chain Tests
+ // ============================================================================
+
+ function testOperationsWithPredecessor() public {
+ // Schedule first operation: addRateLimits
+ bytes memory payload1 = abi.encodeWithSelector(BeamState.addRateLimits.selector, SPARK_RATE_LIMITS);
+ address[] memory targets = new address[](1);
+ targets[0] = address(beamState);
+ uint256[] memory values = new uint256[](1);
+ bytes[] memory payloads1 = new bytes[](1);
+ payloads1[0] = payload1;
+
+ vm.prank(coreCouncil);
+ timelock.scheduleBatch(targets, values, payloads1, bytes32(0), keccak256("op1"), MIN_DELAY);
+ bytes32 id1 = timelock.hashOperationBatch(targets, values, payloads1, bytes32(0), keccak256("op1"));
+
+ // Schedule second operation: addController with predecessor
+ bytes memory payload2 = abi.encodeWithSelector(BeamState.addController.selector, SPARK_CONTROLLER);
+ bytes[] memory payloads2 = new bytes[](1);
+ payloads2[0] = payload2;
+
+ vm.prank(coreCouncil);
+ timelock.scheduleBatch(targets, values, payloads2, id1, keccak256("op2"), MIN_DELAY);
+
+ vm.warp(block.timestamp + MIN_DELAY);
+
+ // Try to execute second before first - should fail
+ vm.expectRevert();
+ timelock.executeBatch(targets, values, payloads2, id1, keccak256("op2"));
+
+ // Execute first
+ timelock.executeBatch(targets, values, payloads1, bytes32(0), keccak256("op1"));
+ assertEq(beamState.rateLimits(SPARK_RATE_LIMITS), 1, "rateLimits should be added");
+
+ // Now execute second
+ timelock.executeBatch(targets, values, payloads2, id1, keccak256("op2"));
+ assertEq(beamState.controllers(SPARK_CONTROLLER), 1, "controller should be added");
+ }
+
+ // ============================================================================
+ // Full Workflow Test
+ // ============================================================================
+
+ function testFullOnboardingWorkflow() public {
+ // 1. CoreCouncil schedules adding a cBeam via timelock
+ bytes32 addCBeamOp = _scheduleDirect(
+ abi.encodeWithSelector(BeamState.addCBeam.selector, cBeam),
+ bytes32(0),
+ keccak256("step1")
+ );
+
+ // 2. Schedule addRateLimits with predecessor
+ bytes32 addRateLimitsOp = _scheduleDirect(
+ abi.encodeWithSelector(BeamState.addRateLimits.selector, SPARK_RATE_LIMITS),
+ addCBeamOp,
+ keccak256("step2")
+ );
+
+ // 3. Schedule setHop with predecessor
+ bytes32 setHopOp = _scheduleDirect(
+ abi.encodeWithSelector(BeamState.setHop.selector, SPARK_RATE_LIMITS, 4 hours),
+ addRateLimitsOp,
+ keccak256("step3")
+ );
+
+ // Wait for delay
+ vm.warp(block.timestamp + MIN_DELAY);
+
+ // 4. Execute in order
+ Timelock.Operation memory op1 = timelock.getOperation(addCBeamOp);
+ timelock.executeBatch(op1.targets, op1.values, op1.payloads, op1.predecessor, op1.salt);
+ assertEq(beamState.cBeams(cBeam), 1, "cBeam added");
+
+ Timelock.Operation memory op2 = timelock.getOperation(addRateLimitsOp);
+ timelock.executeBatch(op2.targets, op2.values, op2.payloads, op2.predecessor, op2.salt);
+ assertEq(beamState.rateLimits(SPARK_RATE_LIMITS), 1, "rateLimits added");
+
+ Timelock.Operation memory op3 = timelock.getOperation(setHopOp);
+ timelock.executeBatch(op3.targets, op3.values, op3.payloads, op3.predecessor, op3.salt);
+ assertEq(beamState.getHop(SPARK_RATE_LIMITS), 4 hours, "hop set");
+
+ // 5. CoreCouncil can now directly set cBeam for rateLimits (role 2)
+ vm.prank(coreCouncil);
+ beamState.setCBeamForRateLimits(SPARK_RATE_LIMITS, cBeam);
+ assertEq(beamState.rateLimitsCBeams(SPARK_RATE_LIMITS, cBeam), 1, "cBeam associated with rateLimits");
+
+ // 6. Emergency stop by coreCouncil (direct, no timelock)
+ vm.prank(coreCouncil);
+ beamState.stop();
+ assertTrue(beamState.stopped(), "system stopped");
+
+ // 7. Restart requires timelock
+ bytes32 startOp = _scheduleDirect(
+ abi.encodeWithSelector(BeamState.start.selector),
+ bytes32(0),
+ keccak256("restart")
+ );
+
+ vm.warp(block.timestamp + MIN_DELAY);
+
+ Timelock.Operation memory restartOp = timelock.getOperation(startOp);
+ timelock.executeBatch(restartOp.targets, restartOp.values, restartOp.payloads, restartOp.predecessor, restartOp.salt);
+ assertFalse(beamState.stopped(), "system restarted");
+ }
+
+ // ============================================================================
+ // Admin Delay Change Test
+ // ============================================================================
+
+ function testAdminCanChangeDelayImmediately() public {
+ uint256 newDelay = 2 days;
+
+ vm.prank(pauseProxy);
+ timelock.updateDelayImmediately(newDelay);
+
+ assertEq(timelock.getMinDelay(), newDelay, "delay should be updated immediately");
+ }
+
+ function testNonAdminCannotChangeDelay() public {
+ vm.prank(coreCouncil);
+ vm.expectRevert();
+ timelock.updateDelayImmediately(2 days);
+ }
+
+ // ============================================================================
+ // PASMom Integration Tests
+ // ============================================================================
+
+ function testMomOwnerCanStopBeamState() public {
+ assertFalse(beamState.stopped(), "should not be stopped initially");
+
+ vm.prank(pauseProxy);
+ mom.stop();
+
+ assertTrue(beamState.stopped(), "beamState should be stopped via mom");
+ }
+
+ function testMomOwnerCanPauseTimelock() public {
+ assertFalse(timelock.paused(), "timelock should not be paused initially");
+
+ vm.prank(pauseProxy);
+ mom.pause();
+
+ assertTrue(timelock.paused(), "timelock should be paused via mom");
+ }
+
+ function testMomHatCanStopBeamState() public {
+ address hat = chief.hat();
+ assertFalse(beamState.stopped(), "should not be stopped initially");
+
+ vm.prank(hat);
+ mom.stop();
+
+ assertTrue(beamState.stopped(), "beamState should be stopped via mom by hat");
+ }
+
+ function testMomHatCanPauseTimelock() public {
+ address hat = chief.hat();
+ assertFalse(timelock.paused(), "timelock should not be paused initially");
+
+ vm.prank(hat);
+ mom.pause();
+
+ assertTrue(timelock.paused(), "timelock should be paused via mom by hat");
+ }
+
+ function testMomUnauthorizedCannotStop() public {
+ address unauthorized = address(0x999);
+
+ vm.prank(unauthorized);
+ vm.expectRevert("PASMom/not-authorized");
+ mom.stop();
+ }
+
+ function testMomUnauthorizedCannotPause() public {
+ address unauthorized = address(0x999);
+
+ vm.prank(unauthorized);
+ vm.expectRevert("PASMom/not-authorized");
+ mom.pause();
+ }
+
+ function testMomEmergencyStopBlocksOperations() public {
+ // Setup: onboard a cBeam and associate it with rateLimits
+ bytes32 rateLimitKey = keccak256("mom-test-key");
+
+ vm.startPrank(pauseProxy);
+ beamState.addCBeam(cBeam);
+ beamState.addRateLimits(SPARK_RATE_LIMITS);
+ beamState.addInitRateLimits(rateLimitKey, SPARK_RATE_LIMITS, 1_000_000e18, 100_000e18);
+ beamState.setHop(SPARK_RATE_LIMITS, 1 hours); // Set hop for rate limit increases
+ vm.stopPrank();
+
+ vm.prank(coreCouncil);
+ beamState.setCBeamForRateLimits(SPARK_RATE_LIMITS, cBeam);
+
+ // Grant configurator admin role
+ vm.prank(SPARK_PROXY);
+ RateLimitsWithRolesLike(SPARK_RATE_LIMITS).grantRole(OZ_DEFAULT_ADMIN_ROLE, address(configurator));
+
+ // cBeam can operate normally
+ vm.prank(cBeam);
+ configurator.setRateLimit(SPARK_RATE_LIMITS, rateLimitKey, 500_000e18, 50_000e18);
+
+ // Hat triggers emergency stop via mom
+ address hat = chief.hat();
+ vm.prank(hat);
+ mom.stop();
+
+ // cBeam operations are now blocked
+ vm.prank(cBeam);
+ vm.expectRevert("Configurator/stopped");
+ configurator.setRateLimit(SPARK_RATE_LIMITS, rateLimitKey, 400_000e18, 40_000e18);
+ }
+
+ function testMomPauseBlocksTimelockScheduling() public {
+ // Hat pauses timelock via mom
+ address hat = chief.hat();
+ vm.prank(hat);
+ mom.pause();
+
+ // Scheduling via timelock is blocked
+ address[] memory targets = new address[](1);
+ targets[0] = address(beamState);
+ uint256[] memory values = new uint256[](1);
+ bytes[] memory payloads = new bytes[](1);
+ payloads[0] = abi.encodeWithSelector(BeamState.addCBeam.selector, cBeam);
+
+ vm.prank(coreCouncil);
+ vm.expectRevert();
+ timelock.scheduleBatch(targets, values, payloads, bytes32(0), SALT, MIN_DELAY);
+ }
+
+ function testMomPauseBlocksTimelockExecution() public {
+ // Schedule an operation first
+ bytes32 operationId = _scheduleDirect(
+ abi.encodeWithSelector(BeamState.addCBeam.selector, cBeam),
+ bytes32(0),
+ SALT
+ );
+
+ vm.warp(block.timestamp + MIN_DELAY);
+
+ // Hat pauses timelock via mom
+ address hat = chief.hat();
+ vm.prank(hat);
+ mom.pause();
+
+ // Execution is blocked
+ Timelock.Operation memory op = timelock.getOperation(operationId);
+ vm.expectRevert();
+ timelock.executeBatch(op.targets, op.values, op.payloads, op.predecessor, op.salt);
+ }
+
+ // ============================================================================
+ // Post-Onboarding cBeam Operations Test with Real Contracts
+ // ============================================================================
+
+ function _scheduleDirect(bytes memory payload, bytes32 predecessor, bytes32 salt) internal returns (bytes32) {
+ address[] memory targets = new address[](1);
+ targets[0] = address(beamState);
+ uint256[] memory values = new uint256[](1);
+ bytes[] memory payloads = new bytes[](1);
+ payloads[0] = payload;
+
+ vm.prank(coreCouncil);
+ timelock.scheduleBatch(targets, values, payloads, predecessor, salt, MIN_DELAY);
+
+ return timelock.hashOperationBatch(targets, values, payloads, predecessor, salt);
+ }
+
+ function testCBeamCanOperateAfterOnboarding() public {
+ bytes32 rateLimitKey = keccak256("integration-test-key");
+
+ // Controller action: setMintRecipient for domain 6
+ address testRecipient = address(0xDEADBEEF);
+ bytes memory setMintRecipientAction = abi.encodeWithSelector(
+ ControllerLike.setMintRecipient.selector,
+ uint32(6), // domain
+ bytes32(uint256(uint160(testRecipient)))
+ );
+
+ // ========================================
+ // Phase 0: Init some defaults via spell (PASInit.initLimitsAndControllerData)
+ // ========================================
+ bytes32 spellRateLimitKey = keccak256("spell-init-key");
+ bytes memory spellControllerAction = abi.encodeWithSelector(
+ ControllerLike.setMintRecipient.selector,
+ uint32(7), // different domain than the timelock-onboarded action
+ bytes32(uint256(uint160(testRecipient)))
+ );
+ {
+ InitRateLimitConfig[] memory rlConfigs = new InitRateLimitConfig[](1);
+ rlConfigs[0] = InitRateLimitConfig({
+ key: spellRateLimitKey,
+ rateLimits: SPARK_RATE_LIMITS,
+ maxAmount: 2_000_000e18,
+ slope: 200_000e18
+ });
+
+ InitControllerActionConfig[] memory caConfigs = new InitControllerActionConfig[](1);
+ caConfigs[0] = InitControllerActionConfig({data: spellControllerAction, controller: SPARK_CONTROLLER});
+
+ vm.startPrank(pauseProxy);
+ PASInit.initLimitsAndControllerData(pas, rlConfigs, caConfigs);
+ vm.stopPrank();
+ }
+
+ // ========================================
+ // Phase 1: Onboard via Timelock (Role 1)
+ // ========================================
+ bytes32[] memory opIds = new bytes32[](7);
+ {
+ // 1a. Schedule addCBeam
+ opIds[0] = _scheduleDirect(
+ abi.encodeWithSelector(BeamState.addCBeam.selector, cBeam),
+ bytes32(0), keccak256("addCBeam")
+ );
+
+ // 1b. Schedule addRateLimits for SPARK_RATE_LIMITS
+ opIds[1] = _scheduleDirect(
+ abi.encodeWithSelector(BeamState.addRateLimits.selector, SPARK_RATE_LIMITS),
+ opIds[0], keccak256("addRateLimits")
+ );
+
+ // 1c. Schedule addController for SPARK_CONTROLLER
+ opIds[2] = _scheduleDirect(
+ abi.encodeWithSelector(BeamState.addController.selector, SPARK_CONTROLLER),
+ opIds[1], keccak256("addController")
+ );
+
+ // 1d. Schedule setHop for rateLimits
+ opIds[3] = _scheduleDirect(
+ abi.encodeWithSelector(BeamState.setHop.selector, SPARK_RATE_LIMITS, 1 hours),
+ opIds[2], keccak256("setHop")
+ );
+
+ // 1e. Schedule setMaxChange for rateLimits
+ opIds[4] = _scheduleDirect(
+ abi.encodeWithSelector(BeamState.setMaxChange.selector, SPARK_RATE_LIMITS, 2 ether),
+ opIds[3], keccak256("setMaxChange")
+ );
+
+ // 1f. Schedule addInitRateLimits
+ opIds[5] = _scheduleDirect(
+ abi.encodeWithSelector(BeamState.addInitRateLimits.selector, rateLimitKey, SPARK_RATE_LIMITS, uint256(1_000_000e18), uint256(100_000e18)),
+ opIds[4], keccak256("addInitRateLimits")
+ );
+
+ // 1g. Schedule addInitControllerActions
+ opIds[6] = _scheduleDirect(
+ abi.encodeWithSelector(BeamState.addInitControllerActions.selector, setMintRecipientAction, SPARK_CONTROLLER),
+ opIds[5], keccak256("addInitControllerActions")
+ );
+ }
+
+ // Wait for delay
+ vm.warp(block.timestamp + MIN_DELAY);
+
+ // Execute all operations in order
+ for (uint256 i = 0; i < opIds.length; i++) {
+ Timelock.Operation memory op = timelock.getOperation(opIds[i]);
+ timelock.executeBatch(op.targets, op.values, op.payloads, op.predecessor, op.salt);
+ }
+
+ // Verify onboarding results
+ assertEq(beamState.cBeams(cBeam), 1, "cBeam should be added");
+ assertEq(beamState.rateLimits(SPARK_RATE_LIMITS), 1, "rateLimits should be added");
+ assertEq(beamState.controllers(SPARK_CONTROLLER), 1, "controller should be added");
+ assertEq(beamState.getHop(SPARK_RATE_LIMITS), 1 hours, "hop should be set");
+ assertEq(beamState.getMaxChange(SPARK_RATE_LIMITS), 2 ether, "maxChange should be set");
+ {
+ BeamState.DefaultRateLimits memory limits = beamState.getInitRateLimits(rateLimitKey, SPARK_RATE_LIMITS);
+ assertEq(limits.maxAmount, 1_000_000e18, "init rate limit maxAmount should be set");
+ assertEq(limits.slope, 100_000e18, "init rate limit slope should be set");
+ }
+ assertTrue(beamState.isControllerActionEnabled(keccak256(setMintRecipientAction), SPARK_CONTROLLER), "controller action should be enabled");
+ // Verify spell-configured defaults
+ {
+ BeamState.DefaultRateLimits memory spellLimits = beamState.getInitRateLimits(spellRateLimitKey, SPARK_RATE_LIMITS);
+ assertEq(spellLimits.maxAmount, 2_000_000e18, "spell init rate limit maxAmount should be set");
+ assertEq(spellLimits.slope, 200_000e18, "spell init rate limit slope should be set");
+ }
+ assertTrue(beamState.isControllerActionEnabled(keccak256(spellControllerAction), SPARK_CONTROLLER), "spell controller action should be enabled");
+
+ // ========================================
+ // Phase 2: Grant admin role to configurator and associate cBeam (Role 2 - direct)
+ // ========================================
+
+ // Grant OZ_DEFAULT_ADMIN_ROLE to configurator on both controller and rate limits
+ // This allows configurator to call functions on these contracts
+ vm.startPrank(SPARK_PROXY);
+ ControllerLike(SPARK_CONTROLLER).grantRole(OZ_DEFAULT_ADMIN_ROLE, address(configurator));
+ RateLimitsWithRolesLike(SPARK_RATE_LIMITS).grantRole(OZ_DEFAULT_ADMIN_ROLE, address(configurator));
+ vm.stopPrank();
+
+ vm.startPrank(coreCouncil);
+ beamState.setCBeamForRateLimits(SPARK_RATE_LIMITS, cBeam);
+ beamState.setCBeamForController(SPARK_CONTROLLER, cBeam);
+ vm.stopPrank();
+
+ assertEq(beamState.rateLimitsCBeams(SPARK_RATE_LIMITS, cBeam), 1, "cBeam should be associated with rateLimits");
+ assertEq(beamState.controllersCBeams(SPARK_CONTROLLER, cBeam), 1, "cBeam should be associated with controller");
+
+ // ========================================
+ // Phase 3: cBeam operates via Configurator
+ // ========================================
+
+ // 3a. cBeam sets rate limit
+ vm.prank(cBeam);
+ configurator.setRateLimit(SPARK_RATE_LIMITS, rateLimitKey, 500_000e18, 50_000e18);
+ {
+ RateLimitsLike.RateLimitData memory data = RateLimitsLike(SPARK_RATE_LIMITS).getRateLimitData(rateLimitKey);
+ assertEq(data.maxAmount, 500_000e18, "rate limit maxAmount should be set by cBeam on real contract");
+ assertEq(data.slope, 50_000e18, "rate limit slope should be set by cBeam on real contract");
+ }
+
+ // 3b. cBeam calls controller action
+ vm.prank(cBeam);
+ configurator.callControllerAction(SPARK_CONTROLLER, setMintRecipientAction);
+
+ // Verify
+ bytes32 expectedRecipient = bytes32(uint256(uint160(testRecipient)));
+ assertEq(ControllerLike(SPARK_CONTROLLER).mintRecipients(6), expectedRecipient, "mintRecipient should be set on real controller");
+
+ // 3c. cBeam sets rate limit using spell-configured defaults
+ vm.prank(cBeam);
+ configurator.setRateLimit(SPARK_RATE_LIMITS, spellRateLimitKey, 1_500_000e18, 150_000e18);
+ {
+ RateLimitsLike.RateLimitData memory data = RateLimitsLike(SPARK_RATE_LIMITS).getRateLimitData(spellRateLimitKey);
+ assertEq(data.maxAmount, 1_500_000e18, "spell rate limit maxAmount should be set by cBeam on real contract");
+ assertEq(data.slope, 150_000e18, "spell rate limit slope should be set by cBeam on real contract");
+ }
+
+ // 3d. cBeam calls spell-configured controller action
+ vm.prank(cBeam);
+ configurator.callControllerAction(SPARK_CONTROLLER, spellControllerAction);
+ assertEq(ControllerLike(SPARK_CONTROLLER).mintRecipients(7), expectedRecipient, "spell mintRecipient should be set on real controller");
+
+ // ========================================
+ // Phase 4: Verify restrictions
+ // ========================================
+
+ // 4a. Unauthorized cBeam cannot set rate limit
+ address unauthorizedCBeam = address(0x999);
+ vm.prank(unauthorizedCBeam);
+ vm.expectRevert("Configurator/not-authorized-ratelimits-cBeam");
+ configurator.setRateLimit(SPARK_RATE_LIMITS, rateLimitKey, 100_000e18, 10_000e18);
+
+ // 4b. Unauthorized cBeam cannot call controller action
+ vm.prank(unauthorizedCBeam);
+ vm.expectRevert("Configurator/not-authorized-controller-cBeam");
+ configurator.callControllerAction(SPARK_CONTROLLER, setMintRecipientAction);
+
+ // 4c. cBeam cannot call non-whitelisted action
+ bytes memory nonWhitelistedAction = abi.encodeWithSelector(
+ ControllerLike.setMintRecipient.selector,
+ uint32(8), // domain not whitelisted by either timelock or spell
+ bytes32(uint256(uint160(testRecipient)))
+ );
+ vm.prank(cBeam);
+ vm.expectRevert("Configurator/not-valid-data");
+ configurator.callControllerAction(SPARK_CONTROLLER, nonWhitelistedAction);
+
+ // 4d. Operations blocked when stopped
+ vm.prank(coreCouncil);
+ beamState.stop();
+
+ vm.prank(cBeam);
+ vm.expectRevert("Configurator/stopped");
+ configurator.setRateLimit(SPARK_RATE_LIMITS, rateLimitKey, 400_000e18, 40_000e18);
+
+ vm.prank(cBeam);
+ vm.expectRevert("Configurator/stopped");
+ configurator.callControllerAction(SPARK_CONTROLLER, setMintRecipientAction);
+
+ // 4e. Operations resume after restart (via timelock)
+ bytes32 startOp = _scheduleDirect(
+ abi.encodeWithSelector(BeamState.start.selector),
+ bytes32(0),
+ keccak256("restart")
+ );
+
+ vm.warp(block.timestamp + MIN_DELAY);
+ {
+ Timelock.Operation memory op = timelock.getOperation(startOp);
+ timelock.executeBatch(op.targets, op.values, op.payloads, op.predecessor, op.salt);
+ }
+
+ assertFalse(beamState.stopped(), "system should be running");
+
+ // cBeam can operate again on real contracts
+ vm.prank(cBeam);
+ configurator.setRateLimit(SPARK_RATE_LIMITS, rateLimitKey, 400_000e18, 40_000e18);
+ {
+ RateLimitsLike.RateLimitData memory data = RateLimitsLike(SPARK_RATE_LIMITS).getRateLimitData(rateLimitKey);
+ assertEq(data.maxAmount, 400_000e18, "rate limit should be updated after restart on real contract");
+ }
+ }
+}
diff --git a/test/L2PASSpell.t.sol b/test/L2PASSpell.t.sol
new file mode 100644
index 0000000..6b623e6
--- /dev/null
+++ b/test/L2PASSpell.t.sol
@@ -0,0 +1,85 @@
+// SPDX-FileCopyrightText: © 2026 Dai Foundation
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+pragma solidity ^0.8.24;
+
+import "dss-test/DssTest.sol";
+import { PASInstance } from "deploy/PASInstance.sol";
+import { PASDeploy } from "deploy/PASDeploy.sol";
+import { L2PASSpell } from "deploy/L2PASSpell.sol";
+import { BeamState } from "src/BeamState.sol";
+import { Timelock } from "src/timelock/Timelock.sol";
+
+// Minimal stand-in for the governance proxy that the L2GovernanceRelay executes spells through.
+// It holds the admin/ward roles and delegatecalls into the spell, so inside the spell `address(this)`
+// (and the msg.sender of its calls) is this proxy - mirroring real spell execution.
+contract MockGovProxy {
+ function exec(address spell, bytes memory data) external {
+ (bool ok, bytes memory ret) = spell.delegatecall(data);
+ if (!ok) {
+ assembly { revert(add(ret, 0x20), mload(ret)) }
+ }
+ }
+}
+
+contract L2PASSpellTest is DssTest {
+
+ PASInstance pas;
+ MockGovProxy proxy;
+ L2PASSpell spell;
+ Timelock timelock;
+
+ address coreCouncil = address(0x1);
+
+ uint256 constant MIN_DELAY = 1 days;
+
+ function setUp() public {
+ proxy = new MockGovProxy();
+
+ // Deploy PAS owned by the proxy: switchOwner relies the proxy on BeamState and the Timelock
+ // is constructed with the proxy as admin - exactly the roles the spell relies on at execution.
+ pas = PASDeploy.deploy(address(this), address(proxy), MIN_DELAY);
+ timelock = Timelock(payable(pas.timelock));
+
+ spell = new L2PASSpell(pas.beamState, pas.configurator, pas.timelock);
+ }
+
+ // Runs the spell the way the relay would: the proxy delegatecalls into it.
+ function _init(bool startPaused) internal {
+ proxy.exec(
+ address(spell),
+ abi.encodeCall(L2PASSpell.init, (MIN_DELAY, coreCouncil, new address[](0), new address[](0), startPaused))
+ );
+ }
+
+ function testInitOperational() public {
+ _init(false);
+
+ assertFalse(timelock.paused(), "timelock should not be paused");
+ assertTrue(timelock.hasRole(timelock.PROPOSER_ROLE(), coreCouncil), "coreCouncil should be proposer");
+ assertTrue(BeamState(pas.beamState).hasUserRole(address(timelock), uint8(1)), "timelock should have DELAYED role");
+ }
+
+ function testInitStartPaused() public {
+ _init(true);
+
+ assertTrue(timelock.paused(), "timelock should be paused");
+ // The proxy temporarily held PAUSER_ROLE to pause; it must not retain it.
+ assertFalse(timelock.hasRole(timelock.PAUSER_ROLE(), address(proxy)), "proxy should not retain PAUSER_ROLE");
+ // Still fully configured despite being paused.
+ assertTrue(timelock.hasRole(timelock.PROPOSER_ROLE(), coreCouncil), "coreCouncil should be proposer");
+ }
+}
diff --git a/test/PASAuthorizeInPAU.t.sol b/test/PASAuthorizeInPAU.t.sol
new file mode 100644
index 0000000..5750296
--- /dev/null
+++ b/test/PASAuthorizeInPAU.t.sol
@@ -0,0 +1,101 @@
+// SPDX-FileCopyrightText: © 2026 Dai Foundation
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+pragma solidity ^0.8.24;
+
+import "dss-test/DssTest.sol";
+
+import { PASAuthorizeInPAU } from "deploy/PASAuthorizeInPAU.sol";
+
+import { AccessControl } from "@openzeppelin/contracts/access/AccessControl.sol";
+import { IAccessControl } from "@openzeppelin/contracts/access/IAccessControl.sol";
+
+// Minimal stand-in for a PAU AccessControls / RateLimits contract: both are stock OZ
+// AccessControl to this library, so one mock serves for both roles.
+contract MockAccessControlled is AccessControl {
+ constructor(address admin) {
+ _grantRole(DEFAULT_ADMIN_ROLE, admin);
+ }
+}
+
+// Stands in for the Star's governance spell: `authorize` is an internal library function, so it
+// inlines here and `grantRole` is called with msg.sender == this harness. The harness must hold
+// DEFAULT_ADMIN_ROLE on the targets, mirroring the Star proxy in production.
+contract Authorizer {
+ function run(address configurator, address accessControls, address rateLimits) external {
+ PASAuthorizeInPAU.authorize(configurator, accessControls, rateLimits);
+ }
+}
+
+contract PASAuthorizeInPAUTest is DssTest {
+ bytes32 constant DEFAULT_ADMIN_ROLE = 0x00;
+
+ Authorizer authorizer;
+ address configurator;
+
+ function setUp() public {
+ authorizer = new Authorizer();
+ configurator = makeAddr("configurator");
+ }
+
+ function testAuthorizeGrantsBothRoles() public {
+ MockAccessControlled accessControls = new MockAccessControlled(address(authorizer));
+ MockAccessControlled rateLimits = new MockAccessControlled(address(authorizer));
+
+ // Pre-state: the configurator holds nothing.
+ assertFalse(accessControls.hasRole(DEFAULT_ADMIN_ROLE, configurator));
+ assertFalse(rateLimits.hasRole(DEFAULT_ADMIN_ROLE, configurator));
+
+ authorizer.run(configurator, address(accessControls), address(rateLimits));
+
+ // Both targets are granted, not just one.
+ assertTrue(accessControls.hasRole(DEFAULT_ADMIN_ROLE, configurator));
+ assertTrue(rateLimits.hasRole(DEFAULT_ADMIN_ROLE, configurator));
+ }
+
+ function testAuthorizeRevertsWhenGrantorNotAdminOnAccessControls() public {
+ // accessControls admin is someone else -> the first grant reverts.
+ MockAccessControlled accessControls = new MockAccessControlled(makeAddr("otherAdmin"));
+ MockAccessControlled rateLimits = new MockAccessControlled(address(authorizer));
+
+ vm.expectRevert(
+ abi.encodeWithSelector(
+ IAccessControl.AccessControlUnauthorizedAccount.selector,
+ address(authorizer),
+ DEFAULT_ADMIN_ROLE
+ )
+ );
+ authorizer.run(configurator, address(accessControls), address(rateLimits));
+ }
+
+ function testAuthorizeRevertsWhenGrantorNotAdminOnRateLimits() public {
+ // accessControls grant succeeds, then the rateLimits grant reverts (and rolls back).
+ MockAccessControlled accessControls = new MockAccessControlled(address(authorizer));
+ MockAccessControlled rateLimits = new MockAccessControlled(makeAddr("otherAdmin"));
+
+ vm.expectRevert(
+ abi.encodeWithSelector(
+ IAccessControl.AccessControlUnauthorizedAccount.selector,
+ address(authorizer),
+ DEFAULT_ADMIN_ROLE
+ )
+ );
+ authorizer.run(configurator, address(accessControls), address(rateLimits));
+
+ // The whole call reverted, so the earlier accessControls grant did not persist.
+ assertFalse(accessControls.hasRole(DEFAULT_ADMIN_ROLE, configurator));
+ }
+}
diff --git a/test/PASMom.t.sol b/test/PASMom.t.sol
new file mode 100644
index 0000000..330faa1
--- /dev/null
+++ b/test/PASMom.t.sol
@@ -0,0 +1,141 @@
+// SPDX-FileCopyrightText: © 2026 Dai Foundation
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+pragma solidity ^0.8.24;
+
+import "dss-test/DssTest.sol";
+
+import { BeamState } from "src/BeamState.sol";
+import { Timelock } from "src/timelock/Timelock.sol";
+import { PASMom } from "src/PASMom.sol";
+
+contract MockChief {
+ address hat;
+
+ constructor(address hat_) {
+ hat = hat_;
+ }
+
+ function canCall(address caller, address, bytes4) external view returns (bool ok) {
+ ok = caller == hat;
+ }
+}
+
+contract PASMomTest is DssTest {
+ address owner;
+ address hat;
+ MockChief chief;
+ BeamState beamState;
+ Timelock timelock;
+ PASMom mom;
+
+ uint256 constant MIN_DELAY = 1 days;
+
+ event SetOwner(address indexed newOwner);
+ event SetAuthority(address indexed newAuthority);
+ event StopBeamState();
+ event PauseTimelock();
+
+ function setUp() public {
+ owner = makeAddr("owner");
+ hat = makeAddr("hat");
+ chief = new MockChief(hat);
+
+ vm.startPrank(owner);
+ beamState = new BeamState();
+ timelock = new Timelock(MIN_DELAY, owner);
+ mom = new PASMom(address(beamState), address(timelock));
+
+ beamState.rely(address(mom));
+ timelock.grantRole(timelock.PAUSER_ROLE(), address(mom));
+ mom.setAuthority(address(chief));
+ vm.stopPrank();
+ }
+
+ function testConstructor() public {
+ vm.expectEmit();
+ emit SetOwner(address(this));
+ PASMom mom2 = new PASMom(address(beamState), address(timelock));
+
+ assertEq(address(mom2.beamState()), address(beamState));
+ assertEq(address(mom2.timelock()), address(timelock));
+ assertEq(mom2.owner(), address(this));
+ }
+
+ function testOnlyOwnerMethods() public {
+ checkModifier(
+ address(mom), "PASMom/not-owner", [PASMom.setOwner.selector, PASMom.setAuthority.selector]
+ );
+ }
+
+ function testAuthMethods() public {
+ checkModifier(address(mom), "PASMom/not-authorized", [PASMom.stop.selector, PASMom.pause.selector]);
+
+ vm.prank(owner);
+ mom.setAuthority(address(0));
+ checkModifier(address(mom), "PASMom/not-authorized", [PASMom.stop.selector, PASMom.pause.selector]);
+ }
+
+ function testSetOwner() public {
+ vm.prank(owner);
+ vm.expectEmit();
+ emit SetOwner(address(0x1234));
+ mom.setOwner(address(0x1234));
+ assertEq(mom.owner(), address(0x1234));
+ }
+
+ function testSetAuthority() public {
+ vm.prank(owner);
+ vm.expectEmit();
+ emit SetAuthority(address(0x123));
+ mom.setAuthority(address(0x123));
+ assertEq(mom.authority(), address(0x123));
+ }
+
+ function _checkStop(address who) internal {
+ assertEq(beamState.stopped(), false);
+ vm.prank(who);
+ vm.expectEmit();
+ emit StopBeamState();
+ mom.stop();
+ assertEq(beamState.stopped(), true);
+ }
+
+ function testStopOwner() public {
+ _checkStop(owner);
+ }
+
+ function testStopHat() public {
+ _checkStop(hat);
+ }
+
+ function _checkPause(address who) internal {
+ assertEq(timelock.paused(), false);
+ vm.prank(who);
+ vm.expectEmit();
+ emit PauseTimelock();
+ mom.pause();
+ assertEq(timelock.paused(), true);
+ }
+
+ function testPauseOwner() public {
+ _checkPause(owner);
+ }
+
+ function testPauseHat() public {
+ _checkPause(hat);
+ }
+}
diff --git a/test/timelock/Bytes32LinkedList.t.sol b/test/timelock/Bytes32LinkedList.t.sol
new file mode 100644
index 0000000..fb0a8ec
--- /dev/null
+++ b/test/timelock/Bytes32LinkedList.t.sol
@@ -0,0 +1,405 @@
+// SPDX-FileCopyrightText: © 2026 Dai Foundation
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+pragma solidity ^0.8.24;
+
+import { Test } from "forge-std/Test.sol";
+import { Bytes32LinkedList } from "src/timelock/Bytes32LinkedList.sol";
+
+contract LinkedListWrapper {
+ using Bytes32LinkedList for Bytes32LinkedList.List;
+
+ Bytes32LinkedList.List internal list;
+
+ function add(bytes32 id) external returns (bool) {
+ return list.add(id);
+ }
+
+ function remove(bytes32 id) external returns (bool) {
+ return list.remove(id);
+ }
+
+ function exists(bytes32 id) external view returns (bool) {
+ return list.exists[id];
+ }
+
+ function count() external view returns (uint256) {
+ return list.count;
+ }
+
+ function first() external view returns (bytes32) {
+ return list.first;
+ }
+
+ function last() external view returns (bytes32) {
+ return list.last;
+ }
+
+ function prev(bytes32 id) external view returns (bytes32) {
+ return list.nodes[id].prev;
+ }
+
+ function next(bytes32 id) external view returns (bytes32) {
+ return list.nodes[id].next;
+ }
+}
+
+contract Bytes32LinkedListTest is Test {
+ LinkedListWrapper public wrapper;
+
+ bytes32 public constant ID_A = keccak256("A");
+ bytes32 public constant ID_B = keccak256("B");
+ bytes32 public constant ID_C = keccak256("C");
+ bytes32 public constant ID_D = keccak256("D");
+
+ function setUp() public {
+ wrapper = new LinkedListWrapper();
+ }
+
+ // ============================================================================
+ // Empty List Tests
+ // ============================================================================
+
+ function testEmptyListState() public view {
+ assertEq(wrapper.count(), 0);
+ assertEq(wrapper.first(), bytes32(0));
+ assertEq(wrapper.last(), bytes32(0));
+ assertFalse(wrapper.exists(ID_A));
+ }
+
+ function testRemoveFromEmptyList() public {
+ bool success = wrapper.remove(ID_A);
+ assertFalse(success);
+ assertEq(wrapper.count(), 0);
+ }
+
+ // ============================================================================
+ // Add Tests
+ // ============================================================================
+
+ function testAddSingleElement() public {
+ bool success = wrapper.add(ID_A);
+
+ assertTrue(success);
+ assertEq(wrapper.count(), 1);
+ assertEq(wrapper.first(), ID_A);
+ assertEq(wrapper.last(), ID_A);
+ assertTrue(wrapper.exists(ID_A));
+ assertEq(wrapper.prev(ID_A), bytes32(0));
+ assertEq(wrapper.next(ID_A), bytes32(0));
+ }
+
+ function testAddTwoElements() public {
+ wrapper.add(ID_A);
+ wrapper.add(ID_B);
+
+ assertEq(wrapper.count(), 2);
+ assertEq(wrapper.first(), ID_A);
+ assertEq(wrapper.last(), ID_B);
+
+ // A <-> B
+ assertEq(wrapper.prev(ID_A), bytes32(0));
+ assertEq(wrapper.next(ID_A), ID_B);
+ assertEq(wrapper.prev(ID_B), ID_A);
+ assertEq(wrapper.next(ID_B), bytes32(0));
+ }
+
+ function testAddThreeElements() public {
+ wrapper.add(ID_A);
+ wrapper.add(ID_B);
+ wrapper.add(ID_C);
+
+ assertEq(wrapper.count(), 3);
+ assertEq(wrapper.first(), ID_A);
+ assertEq(wrapper.last(), ID_C);
+
+ // A <-> B <-> C
+ assertEq(wrapper.prev(ID_A), bytes32(0));
+ assertEq(wrapper.next(ID_A), ID_B);
+ assertEq(wrapper.prev(ID_B), ID_A);
+ assertEq(wrapper.next(ID_B), ID_C);
+ assertEq(wrapper.prev(ID_C), ID_B);
+ assertEq(wrapper.next(ID_C), bytes32(0));
+ }
+
+ function testAddDuplicateReturnsFalse() public {
+ assertTrue(wrapper.add(ID_A));
+ assertFalse(wrapper.add(ID_A));
+
+ assertEq(wrapper.count(), 1);
+ }
+
+ function testAddingBytes32ZeroIsNoOp() public {
+ assertFalse(wrapper.add(bytes32(0)));
+
+ assertEq(wrapper.count(), 0);
+ }
+
+ // ============================================================================
+ // Remove Tests
+ // ============================================================================
+
+ function testRemoveSingleElement() public {
+ wrapper.add(ID_A);
+
+ bool success = wrapper.remove(ID_A);
+
+ assertTrue(success);
+ assertEq(wrapper.count(), 0);
+ assertEq(wrapper.first(), bytes32(0));
+ assertEq(wrapper.last(), bytes32(0));
+ assertFalse(wrapper.exists(ID_A));
+ }
+
+ function testRemoveFirstOfTwo() public {
+ wrapper.add(ID_A);
+ wrapper.add(ID_B);
+
+ wrapper.remove(ID_A);
+
+ assertEq(wrapper.count(), 1);
+ assertEq(wrapper.first(), ID_B);
+ assertEq(wrapper.last(), ID_B);
+ assertFalse(wrapper.exists(ID_A));
+ assertTrue(wrapper.exists(ID_B));
+ assertEq(wrapper.prev(ID_B), bytes32(0));
+ assertEq(wrapper.next(ID_B), bytes32(0));
+ }
+
+ function testRemoveLastOfTwo() public {
+ wrapper.add(ID_A);
+ wrapper.add(ID_B);
+
+ wrapper.remove(ID_B);
+
+ assertEq(wrapper.count(), 1);
+ assertEq(wrapper.first(), ID_A);
+ assertEq(wrapper.last(), ID_A);
+ assertTrue(wrapper.exists(ID_A));
+ assertFalse(wrapper.exists(ID_B));
+ assertEq(wrapper.prev(ID_A), bytes32(0));
+ assertEq(wrapper.next(ID_A), bytes32(0));
+ }
+
+ function testRemoveMiddleOfThree() public {
+ wrapper.add(ID_A);
+ wrapper.add(ID_B);
+ wrapper.add(ID_C);
+
+ wrapper.remove(ID_B);
+
+ assertEq(wrapper.count(), 2);
+ assertEq(wrapper.first(), ID_A);
+ assertEq(wrapper.last(), ID_C);
+ assertTrue(wrapper.exists(ID_A));
+ assertFalse(wrapper.exists(ID_B));
+ assertTrue(wrapper.exists(ID_C));
+
+ // A <-> C (B removed)
+ assertEq(wrapper.prev(ID_A), bytes32(0));
+ assertEq(wrapper.next(ID_A), ID_C);
+ assertEq(wrapper.prev(ID_C), ID_A);
+ assertEq(wrapper.next(ID_C), bytes32(0));
+ }
+
+ function testRemoveFirstOfThree() public {
+ wrapper.add(ID_A);
+ wrapper.add(ID_B);
+ wrapper.add(ID_C);
+
+ wrapper.remove(ID_A);
+
+ assertEq(wrapper.count(), 2);
+ assertEq(wrapper.first(), ID_B);
+ assertEq(wrapper.last(), ID_C);
+
+ // B <-> C
+ assertEq(wrapper.prev(ID_B), bytes32(0));
+ assertEq(wrapper.next(ID_B), ID_C);
+ assertEq(wrapper.prev(ID_C), ID_B);
+ assertEq(wrapper.next(ID_C), bytes32(0));
+ }
+
+ function testRemoveLastOfThree() public {
+ wrapper.add(ID_A);
+ wrapper.add(ID_B);
+ wrapper.add(ID_C);
+
+ wrapper.remove(ID_C);
+
+ assertEq(wrapper.count(), 2);
+ assertEq(wrapper.first(), ID_A);
+ assertEq(wrapper.last(), ID_B);
+
+ // A <-> B
+ assertEq(wrapper.prev(ID_A), bytes32(0));
+ assertEq(wrapper.next(ID_A), ID_B);
+ assertEq(wrapper.prev(ID_B), ID_A);
+ assertEq(wrapper.next(ID_B), bytes32(0));
+ }
+
+ function testRemoveNonExistentReturnsFalse() public {
+ wrapper.add(ID_A);
+
+ bool success = wrapper.remove(ID_B);
+
+ assertFalse(success);
+ assertEq(wrapper.count(), 1);
+ }
+
+ function testRemoveAlreadyRemovedReturnsFalse() public {
+ wrapper.add(ID_A);
+ wrapper.remove(ID_A);
+
+ bool success = wrapper.remove(ID_A);
+
+ assertFalse(success);
+ assertEq(wrapper.count(), 0);
+ }
+
+ // ============================================================================
+ // Order Preservation Tests
+ // ============================================================================
+
+ function testOrderPreservedAfterMiddleRemoval() public {
+ // Add A, B, C, D
+ wrapper.add(ID_A);
+ wrapper.add(ID_B);
+ wrapper.add(ID_C);
+ wrapper.add(ID_D);
+
+ // Remove B and C
+ wrapper.remove(ID_B);
+ wrapper.remove(ID_C);
+
+ // Should have A <-> D
+ assertEq(wrapper.count(), 2);
+ assertEq(wrapper.first(), ID_A);
+ assertEq(wrapper.last(), ID_D);
+ assertEq(wrapper.next(ID_A), ID_D);
+ assertEq(wrapper.prev(ID_D), ID_A);
+ }
+
+ function testCanReaddRemovedElement() public {
+ wrapper.add(ID_A);
+ wrapper.add(ID_B);
+ wrapper.remove(ID_A);
+
+ // Re-add A, should go to end
+ assertTrue(wrapper.add(ID_A));
+
+ assertEq(wrapper.count(), 2);
+ assertEq(wrapper.first(), ID_B);
+ assertEq(wrapper.last(), ID_A);
+
+ // B <-> A
+ assertEq(wrapper.next(ID_B), ID_A);
+ assertEq(wrapper.prev(ID_A), ID_B);
+ }
+
+ // ============================================================================
+ // Traversal Tests
+ // ============================================================================
+
+ function testForwardTraversal() public {
+ wrapper.add(ID_A);
+ wrapper.add(ID_B);
+ wrapper.add(ID_C);
+
+ bytes32 current = wrapper.first();
+ assertEq(current, ID_A);
+
+ current = wrapper.next(current);
+ assertEq(current, ID_B);
+
+ current = wrapper.next(current);
+ assertEq(current, ID_C);
+
+ current = wrapper.next(current);
+ assertEq(current, bytes32(0));
+ }
+
+ function testBackwardTraversal() public {
+ wrapper.add(ID_A);
+ wrapper.add(ID_B);
+ wrapper.add(ID_C);
+
+ bytes32 current = wrapper.last();
+ assertEq(current, ID_C);
+
+ current = wrapper.prev(current);
+ assertEq(current, ID_B);
+
+ current = wrapper.prev(current);
+ assertEq(current, ID_A);
+
+ current = wrapper.prev(current);
+ assertEq(current, bytes32(0));
+ }
+
+ // ============================================================================
+ // Edge Cases
+ // ============================================================================
+
+ function testRemoveAllElementsOneByOne() public {
+ wrapper.add(ID_A);
+ wrapper.add(ID_B);
+ wrapper.add(ID_C);
+
+ wrapper.remove(ID_A);
+ assertEq(wrapper.count(), 2);
+ assertEq(wrapper.first(), ID_B);
+ assertEq(wrapper.last(), ID_C);
+
+ wrapper.remove(ID_B);
+ assertEq(wrapper.count(), 1);
+ assertEq(wrapper.first(), ID_C);
+ assertEq(wrapper.last(), ID_C);
+
+ wrapper.remove(ID_C);
+ assertEq(wrapper.count(), 0);
+ assertEq(wrapper.first(), bytes32(0));
+ assertEq(wrapper.last(), bytes32(0));
+ }
+
+ function testRemoveInReverseOrder() public {
+ wrapper.add(ID_A);
+ wrapper.add(ID_B);
+ wrapper.add(ID_C);
+
+ wrapper.remove(ID_C);
+ assertEq(wrapper.last(), ID_B);
+
+ wrapper.remove(ID_B);
+ assertEq(wrapper.last(), ID_A);
+
+ wrapper.remove(ID_A);
+ assertEq(wrapper.count(), 0);
+ }
+
+ function testNodeCleanupAfterRemoval() public {
+ wrapper.add(ID_A);
+ wrapper.add(ID_B);
+ wrapper.add(ID_C);
+
+ wrapper.remove(ID_B);
+
+ // B's node data should be cleared
+ assertEq(wrapper.prev(ID_B), bytes32(0));
+ assertEq(wrapper.next(ID_B), bytes32(0));
+ assertFalse(wrapper.exists(ID_B));
+ }
+}
diff --git a/test/timelock/Timelock.t.sol b/test/timelock/Timelock.t.sol
new file mode 100644
index 0000000..7f5b9c1
--- /dev/null
+++ b/test/timelock/Timelock.t.sol
@@ -0,0 +1,1126 @@
+// SPDX-FileCopyrightText: © 2026 Dai Foundation
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+pragma solidity ^0.8.24;
+
+import { Test, stdStorage, StdStorage } from "forge-std/Test.sol";
+import { Timelock } from "src/timelock/Timelock.sol";
+import { TimelockController } from "@openzeppelin/contracts/governance/TimelockController.sol";
+import { Pausable } from "@openzeppelin/contracts/utils/Pausable.sol";
+import { IAccessControl } from "@openzeppelin/contracts/access/IAccessControl.sol";
+
+contract MockTarget {
+ uint256 public value;
+ bool public called;
+ uint256 public callCount;
+ uint256 public lastEthReceived;
+
+ function setValue(uint256 _value) external {
+ value = _value;
+ called = true;
+ callCount++;
+ }
+
+ function receiveEther() external payable {
+ lastEthReceived = msg.value;
+ called = true;
+ callCount++;
+ }
+
+ function revertCall() external pure {
+ revert("MockTarget: intentional revert");
+ }
+}
+
+contract TimelockTest is Test {
+ using stdStorage for StdStorage;
+
+ Timelock public timelock;
+ MockTarget public mockTarget;
+ MockTarget public mockTarget2;
+
+ address public admin;
+ address public proposer;
+ address public proposer2;
+ address public canceller;
+ address public pauser;
+ address public other;
+
+ uint256 public constant MIN_DELAY = 1 days;
+ bytes32 public constant SALT = keccak256("test-salt");
+
+ event CallScheduled(
+ bytes32 indexed id,
+ uint256 indexed index,
+ address target,
+ uint256 value,
+ bytes data,
+ bytes32 predecessor,
+ uint256 delay
+ );
+ event CallExecuted(bytes32 indexed id, uint256 indexed index, address target, uint256 value, bytes data);
+ event Cancelled(bytes32 indexed id);
+ event MinDelayChange(uint256 oldDuration, uint256 newDuration);
+ event Paused(address account);
+ event Unpaused(address account);
+
+ function setUp() public {
+ admin = address(0x1);
+ proposer = address(0x2);
+ proposer2 = address(0x22);
+ canceller = address(0x3);
+ pauser = address(0x4);
+ other = address(0x5);
+
+ timelock = new Timelock(MIN_DELAY, admin);
+ mockTarget = new MockTarget();
+ mockTarget2 = new MockTarget();
+
+ // Grant roles after deployment
+ vm.startPrank(admin);
+ timelock.grantRole(timelock.PROPOSER_ROLE(), proposer);
+ timelock.grantRole(timelock.PROPOSER_ROLE(), proposer2);
+ // Proposers also get canceller role (matches original TimelockController behavior)
+ timelock.grantRole(timelock.CANCELLER_ROLE(), proposer);
+ timelock.grantRole(timelock.CANCELLER_ROLE(), proposer2);
+ timelock.grantRole(timelock.CANCELLER_ROLE(), canceller);
+ timelock.grantRole(timelock.PAUSER_ROLE(), pauser);
+ vm.stopPrank();
+
+ vm.deal(address(timelock), 100 ether);
+ }
+
+ // ============================================================================
+ // Helper Functions
+ // ============================================================================
+
+ function _schedule(
+ address target,
+ uint256 value,
+ bytes memory data,
+ bytes32 predecessor,
+ bytes32 salt,
+ uint256 delay
+ ) internal returns (bytes32) {
+ address[] memory targets = new address[](1);
+ targets[0] = target;
+ uint256[] memory values = new uint256[](1);
+ values[0] = value;
+ bytes[] memory payloads = new bytes[](1);
+ payloads[0] = data;
+ timelock.scheduleBatch(targets, values, payloads, predecessor, salt, delay);
+ return timelock.hashOperationBatch(targets, values, payloads, predecessor, salt);
+ }
+
+ function _execute(
+ address target,
+ uint256 value,
+ bytes memory data,
+ bytes32 predecessor,
+ bytes32 salt
+ ) internal {
+ address[] memory targets = new address[](1);
+ targets[0] = target;
+ uint256[] memory values = new uint256[](1);
+ values[0] = value;
+ bytes[] memory payloads = new bytes[](1);
+ payloads[0] = data;
+ timelock.executeBatch{value: value}(targets, values, payloads, predecessor, salt);
+ }
+
+ function _hashOperation(
+ address target,
+ uint256 value,
+ bytes memory data,
+ bytes32 predecessor,
+ bytes32 salt
+ ) internal view returns (bytes32) {
+ address[] memory targets = new address[](1);
+ targets[0] = target;
+ uint256[] memory values = new uint256[](1);
+ values[0] = value;
+ bytes[] memory payloads = new bytes[](1);
+ payloads[0] = data;
+ return timelock.hashOperationBatch(targets, values, payloads, predecessor, salt);
+ }
+
+ // ============================================================================
+ // Constructor and Initialization Tests
+ // ============================================================================
+
+ function testConstructorInitialization() public view {
+ assertEq(timelock.getMinDelay(), MIN_DELAY);
+ assertTrue(timelock.hasRole(timelock.DEFAULT_ADMIN_ROLE(), admin));
+ assertTrue(timelock.hasRole(timelock.PROPOSER_ROLE(), proposer));
+ assertTrue(timelock.hasRole(timelock.PROPOSER_ROLE(), proposer2));
+ assertTrue(timelock.hasRole(timelock.CANCELLER_ROLE(), proposer));
+ assertTrue(timelock.hasRole(timelock.CANCELLER_ROLE(), proposer2));
+ assertTrue(timelock.hasRole(timelock.CANCELLER_ROLE(), canceller));
+ assertTrue(timelock.hasRole(timelock.PAUSER_ROLE(), pauser));
+ assertTrue(timelock.hasRole(timelock.EXECUTOR_ROLE(), address(0)));
+ assertFalse(timelock.hasRole(timelock.DEFAULT_ADMIN_ROLE(), address(timelock)));
+ }
+
+ function testConstructorRevertsIfAdminIsZero() public {
+ vm.expectRevert("Timelock/admin-zero-address");
+ new Timelock(MIN_DELAY, address(0));
+ }
+
+ // ============================================================================
+ // Role Management Tests
+ // ============================================================================
+
+ function testAdminCanGrantRole() public {
+ address newProposer = address(0x999);
+ bytes32 proposerRole = timelock.PROPOSER_ROLE();
+
+ assertFalse(timelock.hasRole(proposerRole, newProposer));
+
+ vm.prank(admin);
+ timelock.grantRole(proposerRole, newProposer);
+
+ assertTrue(timelock.hasRole(proposerRole, newProposer));
+
+ // Verify the new proposer can actually schedule
+ bytes memory data = abi.encodeWithSelector(MockTarget.setValue.selector, 42);
+ vm.prank(newProposer);
+ _schedule(address(mockTarget), 0, data, bytes32(0), SALT, MIN_DELAY);
+
+ assertEq(timelock.getOperationsCount(), 1);
+ }
+
+ function testAdminCanRevokeRole() public {
+ bytes32 proposerRole = timelock.PROPOSER_ROLE();
+
+ assertTrue(timelock.hasRole(proposerRole, proposer));
+
+ vm.prank(admin);
+ timelock.revokeRole(proposerRole, proposer);
+
+ assertFalse(timelock.hasRole(proposerRole, proposer));
+
+ // Verify the revoked proposer can no longer schedule
+ bytes memory data = abi.encodeWithSelector(MockTarget.setValue.selector, 42);
+ vm.prank(proposer);
+ vm.expectRevert(abi.encodeWithSelector(IAccessControl.AccessControlUnauthorizedAccount.selector, proposer, proposerRole));
+ _schedule(address(mockTarget), 0, data, bytes32(0), SALT, MIN_DELAY);
+ }
+
+ function testNonAdminCannotGrantRole() public {
+ address newProposer = address(0x999);
+ bytes32 proposerRole = timelock.PROPOSER_ROLE();
+ bytes32 adminRole = timelock.DEFAULT_ADMIN_ROLE();
+
+ vm.prank(other);
+ vm.expectRevert(abi.encodeWithSelector(IAccessControl.AccessControlUnauthorizedAccount.selector, other, adminRole));
+ timelock.grantRole(proposerRole, newProposer);
+ }
+
+ function testNonAdminCannotRevokeRole() public {
+ bytes32 proposerRole = timelock.PROPOSER_ROLE();
+ bytes32 adminRole = timelock.DEFAULT_ADMIN_ROLE();
+
+ vm.prank(other);
+ vm.expectRevert(abi.encodeWithSelector(IAccessControl.AccessControlUnauthorizedAccount.selector, other, adminRole));
+ timelock.revokeRole(proposerRole, proposer);
+ }
+
+ function testUserCanRenounceOwnRole() public {
+ bytes32 proposerRole = timelock.PROPOSER_ROLE();
+
+ assertTrue(timelock.hasRole(proposerRole, proposer));
+
+ vm.prank(proposer);
+ timelock.renounceRole(proposerRole, proposer);
+
+ assertFalse(timelock.hasRole(proposerRole, proposer));
+
+ // Verify the user can no longer schedule
+ bytes memory data = abi.encodeWithSelector(MockTarget.setValue.selector, 42);
+ vm.prank(proposer);
+ vm.expectRevert(abi.encodeWithSelector(IAccessControl.AccessControlUnauthorizedAccount.selector, proposer, proposerRole));
+ _schedule(address(mockTarget), 0, data, bytes32(0), SALT, MIN_DELAY);
+ }
+
+ function testUserCannotRenounceOthersRole() public {
+ bytes32 proposerRole = timelock.PROPOSER_ROLE();
+
+ vm.prank(other);
+ vm.expectRevert(abi.encodeWithSelector(IAccessControl.AccessControlBadConfirmation.selector));
+ timelock.renounceRole(proposerRole, proposer);
+
+ // Proposer still has their role
+ assertTrue(timelock.hasRole(proposerRole, proposer));
+ }
+
+ function testAdminRoleTransfer() public {
+ address newAdmin = address(0x888);
+ bytes32 adminRole = timelock.DEFAULT_ADMIN_ROLE();
+ bytes32 proposerRole = timelock.PROPOSER_ROLE();
+
+ // Admin grants admin role to new address
+ vm.prank(admin);
+ timelock.grantRole(adminRole, newAdmin);
+
+ assertTrue(timelock.hasRole(adminRole, newAdmin));
+
+ // New admin can grant roles
+ address newProposer = address(0x999);
+ vm.prank(newAdmin);
+ timelock.grantRole(proposerRole, newProposer);
+
+ assertTrue(timelock.hasRole(proposerRole, newProposer));
+
+ // Old admin renounces
+ vm.prank(admin);
+ timelock.renounceRole(adminRole, admin);
+
+ assertFalse(timelock.hasRole(adminRole, admin));
+
+ // Old admin can no longer grant roles
+ vm.prank(admin);
+ vm.expectRevert(abi.encodeWithSelector(IAccessControl.AccessControlUnauthorizedAccount.selector, admin, adminRole));
+ timelock.grantRole(proposerRole, address(0x777));
+ }
+
+ // ============================================================================
+ // Scheduling Tests
+ // ============================================================================
+
+ function testScheduleSingleReverts() public {
+ bytes memory data = abi.encodeWithSelector(MockTarget.setValue.selector, 42);
+
+ vm.prank(proposer);
+ vm.expectRevert("Timelock/use-scheduleBatch");
+ timelock.schedule(address(mockTarget), 0, data, bytes32(0), SALT, MIN_DELAY);
+ }
+
+ function testScheduleSingleOperation() public {
+ bytes memory data = abi.encodeWithSelector(MockTarget.setValue.selector, 42);
+ bytes32 id = _hashOperation(address(mockTarget), 0, data, bytes32(0), SALT);
+
+ vm.prank(proposer);
+ vm.expectEmit(true, true, true, true);
+ emit CallScheduled(id, 0, address(mockTarget), 0, data, bytes32(0), MIN_DELAY);
+ _schedule(address(mockTarget), 0, data, bytes32(0), SALT, MIN_DELAY);
+
+ assertEq(uint256(timelock.getTimestamp(id)), block.timestamp + MIN_DELAY);
+ }
+
+ function testScheduleRevertsIfNotProposer() public {
+ bytes memory data = abi.encodeWithSelector(MockTarget.setValue.selector, 42);
+ bytes32 proposerRole = timelock.PROPOSER_ROLE();
+
+ vm.prank(other);
+ vm.expectRevert(abi.encodeWithSelector(IAccessControl.AccessControlUnauthorizedAccount.selector, other, proposerRole));
+ _schedule(address(mockTarget), 0, data, bytes32(0), SALT, MIN_DELAY);
+ }
+
+ function testScheduleRevertsIfDelayTooShort() public {
+ bytes memory data = abi.encodeWithSelector(MockTarget.setValue.selector, 42);
+
+ vm.prank(proposer);
+ vm.expectRevert(abi.encodeWithSelector(TimelockController.TimelockInsufficientDelay.selector, MIN_DELAY - 1, MIN_DELAY));
+ _schedule(address(mockTarget), 0, data, bytes32(0), SALT, MIN_DELAY - 1);
+ }
+
+ function testScheduleRevertsIfPaused() public {
+ vm.prank(pauser);
+ timelock.pause();
+
+ bytes memory data = abi.encodeWithSelector(MockTarget.setValue.selector, 42);
+
+ vm.prank(proposer);
+ vm.expectRevert(Pausable.EnforcedPause.selector);
+ _schedule(address(mockTarget), 0, data, bytes32(0), SALT, MIN_DELAY);
+ }
+
+ function testScheduleRevertsOnSelfCall() public {
+ bytes memory data = abi.encodeWithSelector(MockTarget.setValue.selector, 42);
+
+ vm.prank(proposer);
+ vm.expectRevert("Timelock/self-calls-disabled");
+ _schedule(address(timelock), 0, data, bytes32(0), SALT, MIN_DELAY);
+ }
+
+ function testScheduleBatchRevertsOnSelfCall() public {
+ address[] memory targets = new address[](2);
+ targets[0] = address(mockTarget);
+ targets[1] = address(timelock);
+
+ uint256[] memory values = new uint256[](2);
+ bytes[] memory payloads = new bytes[](2);
+ payloads[0] = abi.encodeWithSelector(MockTarget.setValue.selector, 10);
+ payloads[1] = abi.encodeWithSelector(MockTarget.setValue.selector, 20);
+
+ vm.prank(proposer);
+ vm.expectRevert("Timelock/self-calls-disabled");
+ timelock.scheduleBatch(targets, values, payloads, bytes32(0), SALT, MIN_DELAY);
+ }
+
+ function testCannotScheduleDuplicate() public {
+ bytes memory data = abi.encodeWithSelector(MockTarget.setValue.selector, 42);
+
+ vm.startPrank(proposer);
+ bytes32 id = _schedule(address(mockTarget), 0, data, bytes32(0), SALT, MIN_DELAY);
+
+ vm.expectRevert(abi.encodeWithSelector(TimelockController.TimelockUnexpectedOperationState.selector, id, bytes32(1 << uint8(TimelockController.OperationState.Unset))));
+ _schedule(address(mockTarget), 0, data, bytes32(0), SALT, MIN_DELAY);
+ vm.stopPrank();
+ }
+
+ function testDifferentSaltCreatesDifferentId() public {
+ bytes memory data = abi.encodeWithSelector(MockTarget.setValue.selector, 42);
+
+ vm.startPrank(proposer);
+ bytes32 id1 = _schedule(address(mockTarget), 0, data, bytes32(0), keccak256("s1"), MIN_DELAY);
+ bytes32 id2 = _schedule(address(mockTarget), 0, data, bytes32(0), keccak256("s2"), MIN_DELAY);
+ vm.stopPrank();
+
+ assertTrue(id1 != id2);
+ assertEq(timelock.getOperationsCount(), 2);
+ }
+
+ function testMultipleProposersCanSchedule() public {
+ bytes memory data1 = abi.encodeWithSelector(MockTarget.setValue.selector, 1);
+ bytes memory data2 = abi.encodeWithSelector(MockTarget.setValue.selector, 2);
+
+ vm.prank(proposer);
+ _schedule(address(mockTarget), 0, data1, bytes32(0), keccak256("s1"), MIN_DELAY);
+
+ vm.prank(proposer2);
+ _schedule(address(mockTarget), 0, data2, bytes32(0), keccak256("s2"), MIN_DELAY);
+
+ assertEq(timelock.getOperationsCount(), 2);
+ }
+
+ // ============================================================================
+ // Execution Tests
+ // ============================================================================
+
+ function testExecuteSingleReverts() public {
+ bytes memory data = abi.encodeWithSelector(MockTarget.setValue.selector, 42);
+
+ vm.prank(proposer);
+ _schedule(address(mockTarget), 0, data, bytes32(0), SALT, MIN_DELAY);
+
+ vm.warp(block.timestamp + MIN_DELAY);
+
+ vm.expectRevert("Timelock/use-executeBatch");
+ timelock.execute(address(mockTarget), 0, data, bytes32(0), SALT);
+ }
+
+ function testExecuteSingleOperation() public {
+ bytes memory data = abi.encodeWithSelector(MockTarget.setValue.selector, 42);
+ bytes32 id = _hashOperation(address(mockTarget), 0, data, bytes32(0), SALT);
+
+ vm.prank(proposer);
+ _schedule(address(mockTarget), 0, data, bytes32(0), SALT, MIN_DELAY);
+
+ vm.warp(block.timestamp + MIN_DELAY);
+
+ vm.expectEmit(true, true, true, true);
+ emit CallExecuted(id, 0, address(mockTarget), 0, data);
+ _execute(address(mockTarget), 0, data, bytes32(0), SALT);
+
+ assertTrue(mockTarget.called());
+ assertEq(mockTarget.value(), 42);
+ }
+
+ function testExecutePermissionless() public {
+ bytes memory data = abi.encodeWithSelector(MockTarget.setValue.selector, 42);
+
+ vm.prank(proposer);
+ _schedule(address(mockTarget), 0, data, bytes32(0), SALT, MIN_DELAY);
+
+ vm.warp(block.timestamp + MIN_DELAY);
+
+ vm.prank(other);
+ _execute(address(mockTarget), 0, data, bytes32(0), SALT);
+
+ assertTrue(mockTarget.called());
+ }
+
+ function testExecuteRevertsIfNotReady() public {
+ bytes memory data = abi.encodeWithSelector(MockTarget.setValue.selector, 42);
+
+ vm.prank(proposer);
+ bytes32 id = _schedule(address(mockTarget), 0, data, bytes32(0), SALT, MIN_DELAY);
+
+ vm.expectRevert(abi.encodeWithSelector(TimelockController.TimelockUnexpectedOperationState.selector, id, bytes32(1 << uint8(TimelockController.OperationState.Ready))));
+ _execute(address(mockTarget), 0, data, bytes32(0), SALT);
+ }
+
+ function testExecuteRevertsIfPaused() public {
+ bytes memory data = abi.encodeWithSelector(MockTarget.setValue.selector, 42);
+
+ vm.prank(proposer);
+ _schedule(address(mockTarget), 0, data, bytes32(0), SALT, MIN_DELAY);
+
+ vm.warp(block.timestamp + MIN_DELAY);
+
+ vm.prank(pauser);
+ timelock.pause();
+
+ vm.expectRevert(Pausable.EnforcedPause.selector);
+ _execute(address(mockTarget), 0, data, bytes32(0), SALT);
+ }
+
+ function testExecuteRevertsIfTargetReverts() public {
+ bytes memory data = abi.encodeWithSelector(MockTarget.revertCall.selector);
+
+ vm.prank(proposer);
+ _schedule(address(mockTarget), 0, data, bytes32(0), SALT, MIN_DELAY);
+
+ vm.warp(block.timestamp + MIN_DELAY);
+
+ vm.expectRevert("MockTarget: intentional revert");
+ _execute(address(mockTarget), 0, data, bytes32(0), SALT);
+ }
+
+ function testExecuteBatchMultipleOperations() public {
+ address[] memory targets = new address[](2);
+ targets[0] = address(mockTarget);
+ targets[1] = address(mockTarget);
+
+ uint256[] memory values = new uint256[](2);
+ bytes[] memory payloads = new bytes[](2);
+ payloads[0] = abi.encodeWithSelector(MockTarget.setValue.selector, 10);
+ payloads[1] = abi.encodeWithSelector(MockTarget.setValue.selector, 20);
+
+ vm.prank(proposer);
+ timelock.scheduleBatch(targets, values, payloads, bytes32(0), SALT, MIN_DELAY);
+
+ vm.warp(block.timestamp + MIN_DELAY);
+
+ timelock.executeBatch(targets, values, payloads, bytes32(0), SALT);
+
+ assertEq(mockTarget.value(), 20);
+ }
+
+ function testLargeBatch() public {
+ uint256 batchSize = 50;
+
+ address[] memory targets = new address[](batchSize);
+ uint256[] memory values = new uint256[](batchSize);
+ bytes[] memory payloads = new bytes[](batchSize);
+
+ for (uint256 i = 0; i < batchSize; i++) {
+ targets[i] = address(mockTarget);
+ payloads[i] = abi.encodeWithSelector(MockTarget.setValue.selector, i);
+ }
+
+ vm.prank(proposer);
+ timelock.scheduleBatch(targets, values, payloads, bytes32(0), SALT, MIN_DELAY);
+
+ vm.warp(block.timestamp + MIN_DELAY);
+
+ timelock.executeBatch(targets, values, payloads, bytes32(0), SALT);
+
+ assertEq(mockTarget.value(), batchSize - 1);
+ assertEq(mockTarget.callCount(), batchSize);
+ }
+
+ // ============================================================================
+ // Predecessor Chain Tests
+ // ============================================================================
+
+ function testExecuteWithPredecessor() public {
+ bytes memory data1 = abi.encodeWithSelector(MockTarget.setValue.selector, 1);
+
+ vm.prank(proposer);
+ bytes32 id1 = _schedule(address(mockTarget), 0, data1, bytes32(0), SALT, MIN_DELAY);
+
+ bytes memory data2 = abi.encodeWithSelector(MockTarget.setValue.selector, 2);
+
+ vm.prank(proposer);
+ _schedule(address(mockTarget), 0, data2, id1, keccak256("salt2"), MIN_DELAY);
+
+ vm.warp(block.timestamp + MIN_DELAY);
+
+ _execute(address(mockTarget), 0, data1, bytes32(0), SALT);
+ assertEq(mockTarget.value(), 1);
+
+ _execute(address(mockTarget), 0, data2, id1, keccak256("salt2"));
+ assertEq(mockTarget.value(), 2);
+ }
+
+ function testExecuteRevertsIfPredecessorNotDone() public {
+ bytes memory data1 = abi.encodeWithSelector(MockTarget.setValue.selector, 1);
+
+ vm.prank(proposer);
+ bytes32 id1 = _schedule(address(mockTarget), 0, data1, bytes32(0), SALT, MIN_DELAY);
+
+ bytes memory data2 = abi.encodeWithSelector(MockTarget.setValue.selector, 2);
+
+ vm.prank(proposer);
+ _schedule(address(mockTarget), 0, data2, id1, keccak256("salt2"), MIN_DELAY);
+
+ vm.warp(block.timestamp + MIN_DELAY);
+
+ vm.expectRevert(abi.encodeWithSelector(TimelockController.TimelockUnexecutedPredecessor.selector, id1));
+ _execute(address(mockTarget), 0, data2, id1, keccak256("salt2"));
+ }
+
+ function testCancelledPredecessorBlocksDependent() public {
+ bytes memory data1 = abi.encodeWithSelector(MockTarget.setValue.selector, 1);
+ bytes memory data2 = abi.encodeWithSelector(MockTarget.setValue.selector, 2);
+
+ vm.startPrank(proposer);
+ bytes32 id1 = _schedule(address(mockTarget), 0, data1, bytes32(0), keccak256("s1"), MIN_DELAY);
+ _schedule(address(mockTarget), 0, data2, id1, keccak256("s2"), MIN_DELAY);
+ vm.stopPrank();
+
+ vm.prank(canceller);
+ timelock.cancel(id1);
+
+ vm.warp(block.timestamp + MIN_DELAY);
+
+ vm.expectRevert(abi.encodeWithSelector(TimelockController.TimelockUnexecutedPredecessor.selector, id1));
+ _execute(address(mockTarget), 0, data2, id1, keccak256("s2"));
+ }
+
+ // ============================================================================
+ // Cancellation Tests
+ // ============================================================================
+
+ function testCancelProposerCanCancel() public {
+ bytes memory data = abi.encodeWithSelector(MockTarget.setValue.selector, 42);
+
+ vm.prank(proposer);
+ bytes32 id = _schedule(address(mockTarget), 0, data, bytes32(0), SALT, MIN_DELAY);
+
+ vm.prank(proposer);
+ vm.expectEmit(true, false, false, false);
+ emit Cancelled(id);
+ timelock.cancel(id);
+
+ assertEq(uint256(timelock.getTimestamp(id)), 0);
+ }
+
+ function testCancelCancellerCanCancel() public {
+ bytes memory data = abi.encodeWithSelector(MockTarget.setValue.selector, 42);
+
+ vm.prank(proposer);
+ bytes32 id = _schedule(address(mockTarget), 0, data, bytes32(0), SALT, MIN_DELAY);
+
+ vm.prank(canceller);
+ timelock.cancel(id);
+
+ assertEq(uint256(timelock.getTimestamp(id)), 0);
+ }
+
+ function testCancelRevertsIfNotAuthorized() public {
+ bytes memory data = abi.encodeWithSelector(MockTarget.setValue.selector, 42);
+
+ vm.prank(proposer);
+ bytes32 id = _schedule(address(mockTarget), 0, data, bytes32(0), SALT, MIN_DELAY);
+
+ bytes32 cancellerRole = timelock.CANCELLER_ROLE();
+ vm.prank(other);
+ vm.expectRevert(abi.encodeWithSelector(IAccessControl.AccessControlUnauthorizedAccount.selector, other, cancellerRole));
+ timelock.cancel(id);
+ }
+
+ function testCancelCannotCancelAfterExecution() public {
+ bytes memory data = abi.encodeWithSelector(MockTarget.setValue.selector, 42);
+
+ vm.prank(proposer);
+ bytes32 id = _schedule(address(mockTarget), 0, data, bytes32(0), SALT, MIN_DELAY);
+
+ vm.warp(block.timestamp + MIN_DELAY);
+ _execute(address(mockTarget), 0, data, bytes32(0), SALT);
+
+ vm.prank(proposer);
+ // Cancel expects operation to be Waiting OR Ready
+ bytes32 expectedStates = bytes32((1 << uint8(TimelockController.OperationState.Waiting)) | (1 << uint8(TimelockController.OperationState.Ready)));
+ vm.expectRevert(abi.encodeWithSelector(TimelockController.TimelockUnexpectedOperationState.selector, id, expectedStates));
+ timelock.cancel(id);
+ }
+
+ function testCancelRevertsWhilePaused() public {
+ bytes memory data = abi.encodeWithSelector(MockTarget.setValue.selector, 42);
+
+ vm.prank(proposer);
+ bytes32 id = _schedule(address(mockTarget), 0, data, bytes32(0), SALT, MIN_DELAY);
+
+ vm.prank(pauser);
+ timelock.pause();
+
+ vm.prank(canceller);
+ vm.expectRevert(Pausable.EnforcedPause.selector);
+ timelock.cancel(id);
+ }
+
+ function testCancelWorksAfterUnpause() public {
+ bytes memory data = abi.encodeWithSelector(MockTarget.setValue.selector, 42);
+
+ vm.prank(proposer);
+ bytes32 id = _schedule(address(mockTarget), 0, data, bytes32(0), SALT, MIN_DELAY);
+
+ vm.prank(pauser);
+ timelock.pause();
+
+ vm.prank(admin);
+ timelock.unpause();
+
+ vm.prank(canceller);
+ timelock.cancel(id);
+
+ assertEq(timelock.getOperationsCount(), 0);
+ }
+
+ function testProposerCanCancelOtherProposersOperation() public {
+ bytes memory data = abi.encodeWithSelector(MockTarget.setValue.selector, 42);
+
+ vm.prank(proposer);
+ bytes32 id = _schedule(address(mockTarget), 0, data, bytes32(0), SALT, MIN_DELAY);
+
+ vm.prank(proposer2);
+ timelock.cancel(id);
+
+ assertEq(timelock.getOperationsCount(), 0);
+ }
+
+ // ============================================================================
+ // Pausing Tests
+ // ============================================================================
+
+ function testPausePauserCanPause() public {
+ vm.prank(pauser);
+ vm.expectEmit(true, false, false, false);
+ emit Paused(pauser);
+ timelock.pause();
+
+ assertTrue(timelock.paused());
+ }
+
+ function testPauseRevertsIfNotPauser() public {
+ bytes32 pauserRole = timelock.PAUSER_ROLE();
+ vm.prank(other);
+ vm.expectRevert(abi.encodeWithSelector(IAccessControl.AccessControlUnauthorizedAccount.selector, other, pauserRole));
+ timelock.pause();
+ }
+
+ function testUnpauseAdminCanUnpause() public {
+ vm.prank(pauser);
+ timelock.pause();
+
+ vm.prank(admin);
+ vm.expectEmit(true, false, false, false);
+ emit Unpaused(admin);
+ timelock.unpause();
+
+ assertFalse(timelock.paused());
+ }
+
+ function testUnpauseRevertsIfNotAdmin() public {
+ vm.prank(pauser);
+ timelock.pause();
+
+ bytes32 adminRole = timelock.DEFAULT_ADMIN_ROLE();
+ vm.prank(other);
+ vm.expectRevert(abi.encodeWithSelector(IAccessControl.AccessControlUnauthorizedAccount.selector, other, adminRole));
+ timelock.unpause();
+ }
+
+ function testPauserCannotUnpause() public {
+ vm.prank(pauser);
+ timelock.pause();
+
+ bytes32 adminRole = timelock.DEFAULT_ADMIN_ROLE();
+ vm.prank(pauser);
+ vm.expectRevert(abi.encodeWithSelector(IAccessControl.AccessControlUnauthorizedAccount.selector, pauser, adminRole));
+ timelock.unpause();
+
+ vm.prank(admin);
+ timelock.unpause();
+ assertFalse(timelock.paused());
+ }
+
+ function testPauseUnpauseResumesOperations() public {
+ bytes memory data = abi.encodeWithSelector(MockTarget.setValue.selector, 42);
+
+ vm.prank(proposer);
+ _schedule(address(mockTarget), 0, data, bytes32(0), SALT, MIN_DELAY);
+
+ vm.prank(pauser);
+ timelock.pause();
+
+ vm.warp(block.timestamp + MIN_DELAY);
+
+ vm.expectRevert(Pausable.EnforcedPause.selector);
+ _execute(address(mockTarget), 0, data, bytes32(0), SALT);
+
+ vm.prank(admin);
+ timelock.unpause();
+
+ _execute(address(mockTarget), 0, data, bytes32(0), SALT);
+ assertTrue(mockTarget.called());
+ }
+
+ // ============================================================================
+ // Delay Management Tests
+ // ============================================================================
+
+ function testUpdateDelayImmediatelyAdminCanUpdate() public {
+ uint256 newDelay = 2 days;
+
+ vm.prank(admin);
+ vm.expectEmit(true, false, false, false);
+ emit MinDelayChange(MIN_DELAY, newDelay);
+ timelock.updateDelayImmediately(newDelay);
+
+ assertEq(timelock.getMinDelay(), newDelay);
+ }
+
+ function testUpdateDelayImmediatelyRevertsIfNotAdmin() public {
+ bytes32 adminRole = timelock.DEFAULT_ADMIN_ROLE();
+ vm.prank(other);
+ vm.expectRevert(abi.encodeWithSelector(IAccessControl.AccessControlUnauthorizedAccount.selector, other, adminRole));
+ timelock.updateDelayImmediately(2 days);
+ }
+
+ function testNewDelayAppliesImmediately() public {
+ vm.prank(admin);
+ timelock.updateDelayImmediately(2 days);
+
+ bytes memory data = abi.encodeWithSelector(MockTarget.setValue.selector, 42);
+
+ vm.prank(proposer);
+ vm.expectRevert(abi.encodeWithSelector(TimelockController.TimelockInsufficientDelay.selector, MIN_DELAY, 2 days));
+ _schedule(address(mockTarget), 0, data, bytes32(0), SALT, MIN_DELAY);
+
+ vm.prank(proposer);
+ _schedule(address(mockTarget), 0, data, bytes32(0), SALT, 2 days);
+ assertEq(timelock.getOperationsCount(), 1);
+ }
+
+ function testUpdateDelayImmediatelyProposerCannotChangeDelay() public {
+ bytes memory data = abi.encodeWithSelector(Timelock.updateDelayImmediately.selector, 2 days);
+
+ vm.prank(proposer);
+ vm.expectRevert("Timelock/self-calls-disabled");
+ _schedule(address(timelock), 0, data, bytes32(0), SALT, MIN_DELAY);
+ }
+
+ // ============================================================================
+ // ETH Handling Tests
+ // ============================================================================
+
+ function testExecuteWithETHFromTimelockBalance() public {
+ bytes memory data = abi.encodeWithSelector(MockTarget.receiveEther.selector);
+
+ vm.prank(proposer);
+ _schedule(address(mockTarget), 1 ether, data, bytes32(0), SALT, MIN_DELAY);
+
+ vm.warp(block.timestamp + MIN_DELAY);
+
+ uint256 balanceBefore = address(timelock).balance;
+
+ address[] memory targets = new address[](1);
+ targets[0] = address(mockTarget);
+ uint256[] memory values = new uint256[](1);
+ values[0] = 1 ether;
+ bytes[] memory payloads = new bytes[](1);
+ payloads[0] = data;
+ timelock.executeBatch(targets, values, payloads, bytes32(0), SALT);
+
+ assertEq(mockTarget.lastEthReceived(), 1 ether);
+ assertEq(address(timelock).balance, balanceBefore - 1 ether);
+ }
+
+ function testExecuteWithMsgValue() public {
+ bytes memory data = abi.encodeWithSelector(MockTarget.receiveEther.selector);
+
+ vm.prank(proposer);
+ _schedule(address(mockTarget), 1 ether, data, bytes32(0), SALT, MIN_DELAY);
+
+ vm.warp(block.timestamp + MIN_DELAY);
+
+ vm.prank(address(timelock));
+ (bool success,) = other.call{value: address(timelock).balance}("");
+ require(success);
+ assertEq(address(timelock).balance, 0);
+
+ vm.deal(other, 1 ether);
+ vm.prank(other);
+ address[] memory targets = new address[](1);
+ targets[0] = address(mockTarget);
+ uint256[] memory values = new uint256[](1);
+ values[0] = 1 ether;
+ bytes[] memory payloads = new bytes[](1);
+ payloads[0] = data;
+ timelock.executeBatch{value: 1 ether}(targets, values, payloads, bytes32(0), SALT);
+
+ assertEq(mockTarget.lastEthReceived(), 1 ether);
+ }
+
+ function testExecuteMultipleTargetsWithETH() public {
+ address[] memory targets = new address[](2);
+ targets[0] = address(mockTarget);
+ targets[1] = address(mockTarget2);
+
+ uint256[] memory values = new uint256[](2);
+ values[0] = 1 ether;
+ values[1] = 2 ether;
+
+ bytes[] memory payloads = new bytes[](2);
+ payloads[0] = abi.encodeWithSelector(MockTarget.receiveEther.selector);
+ payloads[1] = abi.encodeWithSelector(MockTarget.receiveEther.selector);
+
+ vm.prank(proposer);
+ timelock.scheduleBatch(targets, values, payloads, bytes32(0), SALT, MIN_DELAY);
+
+ vm.warp(block.timestamp + MIN_DELAY);
+
+ timelock.executeBatch(targets, values, payloads, bytes32(0), SALT);
+
+ assertEq(mockTarget.lastEthReceived(), 1 ether);
+ assertEq(mockTarget2.lastEthReceived(), 2 ether);
+ }
+
+ function testGetOperationNonExistent() public view {
+ Timelock.Operation memory op = timelock.getOperation(keccak256("fake"));
+ assertEq(op.targets.length, 0);
+ assertEq(op.values.length, 0);
+ assertEq(op.payloads.length, 0);
+ }
+
+ function testLinkedListGetters() public {
+ bytes memory data = abi.encodeWithSelector(MockTarget.setValue.selector, 42);
+
+ // Empty list
+ assertEq(timelock.getOperationsCount(), 0);
+ assertEq(timelock.getFirstOperationId(), bytes32(0));
+ assertEq(timelock.getLastOperationId(), bytes32(0));
+ assertFalse(timelock.getOperationExists(keccak256("fake")));
+
+ // Schedule 3 operations
+ vm.startPrank(proposer);
+ bytes32 id0 = _schedule(address(mockTarget), 0, data, bytes32(0), keccak256("s0"), MIN_DELAY);
+ bytes32 id1 = _schedule(address(mockTarget), 0, data, bytes32(0), keccak256("s1"), MIN_DELAY);
+ bytes32 id2 = _schedule(address(mockTarget), 0, data, bytes32(0), keccak256("s2"), MIN_DELAY);
+ vm.stopPrank();
+
+ // Verify count
+ assertEq(timelock.getOperationsCount(), 3);
+
+ // Verify existence
+ assertTrue(timelock.getOperationExists(id0));
+ assertTrue(timelock.getOperationExists(id1));
+ assertTrue(timelock.getOperationExists(id2));
+ assertFalse(timelock.getOperationExists(keccak256("fake")));
+
+ // Verify first and last
+ assertEq(timelock.getFirstOperationId(), id0);
+ assertEq(timelock.getLastOperationId(), id2);
+
+ // Verify prev/next chain: id0 <-> id1 <-> id2
+ assertEq(timelock.getPrevOperationId(id0), bytes32(0));
+ assertEq(timelock.getNextOperationId(id0), id1);
+ assertEq(timelock.getPrevOperationId(id1), id0);
+ assertEq(timelock.getNextOperationId(id1), id2);
+ assertEq(timelock.getPrevOperationId(id2), id1);
+ assertEq(timelock.getNextOperationId(id2), bytes32(0));
+
+ // Remove middle element (id1) via cancel
+ vm.prank(proposer);
+ timelock.cancel(id1);
+
+ // Verify count after removal
+ assertEq(timelock.getOperationsCount(), 2);
+
+ // Verify existence after removal
+ assertTrue(timelock.getOperationExists(id0));
+ assertFalse(timelock.getOperationExists(id1));
+ assertTrue(timelock.getOperationExists(id2));
+
+ // Verify first and last unchanged
+ assertEq(timelock.getFirstOperationId(), id0);
+ assertEq(timelock.getLastOperationId(), id2);
+
+ // Verify chain after removal: id0 <-> id2
+ assertEq(timelock.getPrevOperationId(id0), bytes32(0));
+ assertEq(timelock.getNextOperationId(id0), id2);
+ assertEq(timelock.getPrevOperationId(id2), id0);
+ assertEq(timelock.getNextOperationId(id2), bytes32(0));
+ }
+
+ function testGetOperationAfterExecution() public {
+ bytes memory data = abi.encodeWithSelector(MockTarget.setValue.selector, 42);
+
+ vm.prank(proposer);
+ bytes32 id = _schedule(address(mockTarget), 0, data, bytes32(0), SALT, MIN_DELAY);
+
+ Timelock.Operation memory opBefore = timelock.getOperation(id);
+ assertEq(opBefore.targets.length, 1);
+
+ vm.warp(block.timestamp + MIN_DELAY);
+ _execute(address(mockTarget), 0, data, bytes32(0), SALT);
+
+ Timelock.Operation memory opAfter = timelock.getOperation(id);
+ assertEq(opAfter.targets.length, 0);
+ }
+
+ function testGetOperationAfterCancel() public {
+ bytes memory data = abi.encodeWithSelector(MockTarget.setValue.selector, 42);
+
+ vm.prank(proposer);
+ bytes32 id = _schedule(address(mockTarget), 0, data, bytes32(0), SALT, MIN_DELAY);
+
+ vm.prank(canceller);
+ timelock.cancel(id);
+
+ Timelock.Operation memory op = timelock.getOperation(id);
+ assertEq(op.targets.length, 0);
+ }
+
+ function testOperationCountTracking() public {
+ assertEq(timelock.getOperationsCount(), 0);
+
+ bytes memory data = abi.encodeWithSelector(MockTarget.setValue.selector, 42);
+
+ vm.startPrank(proposer);
+ bytes32 id1 = _schedule(address(mockTarget), 0, data, bytes32(0), keccak256("s1"), MIN_DELAY);
+ assertEq(timelock.getOperationsCount(), 1);
+
+ _schedule(address(mockTarget), 0, data, bytes32(0), keccak256("s2"), MIN_DELAY);
+ assertEq(timelock.getOperationsCount(), 2);
+
+ _schedule(address(mockTarget), 0, data, bytes32(0), keccak256("s3"), MIN_DELAY);
+ assertEq(timelock.getOperationsCount(), 3);
+ vm.stopPrank();
+
+ vm.prank(canceller);
+ timelock.cancel(id1);
+ assertEq(timelock.getOperationsCount(), 2);
+
+ vm.warp(block.timestamp + MIN_DELAY);
+ _execute(address(mockTarget), 0, data, bytes32(0), keccak256("s2"));
+ assertEq(timelock.getOperationsCount(), 1);
+ }
+
+ function testOperationDataIntegrity() public {
+ address[] memory targets = new address[](2);
+ targets[0] = address(mockTarget);
+ targets[1] = address(mockTarget2);
+
+ uint256[] memory values = new uint256[](2);
+ values[0] = 1 ether;
+ values[1] = 2 ether;
+
+ bytes[] memory payloads = new bytes[](2);
+ payloads[0] = abi.encodeWithSelector(MockTarget.setValue.selector, 100);
+ payloads[1] = abi.encodeWithSelector(MockTarget.setValue.selector, 200);
+
+ bytes32 predecessor = keccak256("pred");
+ bytes32 salt = keccak256("salt");
+
+ vm.prank(proposer);
+ timelock.scheduleBatch(targets, values, payloads, predecessor, salt, MIN_DELAY);
+
+ bytes32 id = timelock.hashOperationBatch(targets, values, payloads, predecessor, salt);
+ Timelock.Operation memory op = timelock.getOperation(id);
+
+ assertEq(op.targets.length, 2);
+ assertEq(op.targets[0], address(mockTarget));
+ assertEq(op.targets[1], address(mockTarget2));
+ assertEq(op.values[0], 1 ether);
+ assertEq(op.values[1], 2 ether);
+ assertEq(op.payloads[0], payloads[0]);
+ assertEq(op.payloads[1], payloads[1]);
+ assertEq(op.predecessor, predecessor);
+ assertEq(op.salt, salt);
+ }
+
+ function testLinkedListRequiresOnCorruptedState() public {
+ bytes memory data = abi.encodeWithSelector(MockTarget.setValue.selector, 42);
+
+ // Test add-failed: corrupt exists[id] to true before scheduling
+ bytes32 id1 = _hashOperation(address(mockTarget), 0, data, bytes32(0), keccak256("s1"));
+ stdstore.target(address(timelock)).sig("getOperationExists(bytes32)").with_key(id1).checked_write(true);
+ vm.prank(proposer);
+ vm.expectRevert("Timelock/add-failed");
+ _schedule(address(mockTarget), 0, data, bytes32(0), keccak256("s1"), MIN_DELAY);
+
+ // Test remove-failed on cancel: corrupt exists[id] to false after scheduling
+ vm.prank(proposer);
+ bytes32 id2 = _schedule(address(mockTarget), 0, data, bytes32(0), keccak256("s2"), MIN_DELAY);
+ stdstore.target(address(timelock)).sig("getOperationExists(bytes32)").with_key(id2).checked_write(false);
+ vm.prank(proposer);
+ vm.expectRevert("Timelock/remove-failed");
+ timelock.cancel(id2);
+
+ // Test remove-failed on execute: corrupt exists[id] to false after scheduling
+ vm.prank(proposer);
+ bytes32 id3 = _schedule(address(mockTarget), 0, data, bytes32(0), keccak256("s3"), MIN_DELAY);
+ vm.warp(block.timestamp + MIN_DELAY);
+ stdstore.target(address(timelock)).sig("getOperationExists(bytes32)").with_key(id3).checked_write(false);
+ vm.expectRevert("Timelock/remove-failed");
+ _execute(address(mockTarget), 0, data, bytes32(0), keccak256("s3"));
+ }
+
+ function testIndividualGettersMatchCombinedGetter() public {
+ // Create a batch operation with multiple calls
+ address[] memory targets = new address[](3);
+ targets[0] = address(mockTarget);
+ targets[1] = address(mockTarget2);
+ targets[2] = address(mockTarget);
+
+ uint256[] memory values = new uint256[](3);
+ values[0] = 1 ether;
+ values[1] = 2 ether;
+ values[2] = 0;
+
+ bytes[] memory payloads = new bytes[](3);
+ payloads[0] = abi.encodeWithSelector(MockTarget.receiveEther.selector);
+ payloads[1] = abi.encodeWithSelector(MockTarget.setValue.selector, 100);
+ payloads[2] = abi.encodeWithSelector(MockTarget.setValue.selector, 200);
+
+ bytes32 predecessor = keccak256("test-predecessor");
+ bytes32 salt = keccak256("test-salt");
+
+ vm.prank(proposer);
+ timelock.scheduleBatch(targets, values, payloads, predecessor, salt, MIN_DELAY);
+
+ bytes32 id = timelock.hashOperationBatch(targets, values, payloads, predecessor, salt);
+
+ // Get the full operation using combined getter
+ Timelock.Operation memory op = timelock.getOperation(id);
+
+ // Verify individual getters match the origina data
+ assertEq(timelock.getOperationLength(id), 3, "Length mismatch");
+ assertEq(timelock.getOperationPredecessor(id), predecessor, "Predecessor mismatch");
+ assertEq(timelock.getOperationSalt(id), salt, "Salt mismatch");
+
+ // Verify individual getters match the combined getter
+ assertEq(timelock.getOperationLength(id), op.targets.length, "Length mismatch");
+ assertEq(timelock.getOperationPredecessor(id), op.predecessor, "Predecessor mismatch");
+ assertEq(timelock.getOperationSalt(id), op.salt, "Salt mismatch");
+
+ // Verify each index matches
+ for (uint256 i = 0; i < op.targets.length; i++) {
+ assertEq(timelock.getOperationTarget(id, i), targets[i], "Target mismatch at index");
+ assertEq(timelock.getOperationValue(id, i), values[i], "Value mismatch at index");
+ assertEq(timelock.getOperationPayload(id, i), payloads[i], "Payload mismatch at index");
+
+ assertEq(timelock.getOperationTarget(id, i), op.targets[i], "Target mismatch at index");
+ assertEq(timelock.getOperationValue(id, i), op.values[i], "Value mismatch at index");
+ assertEq(timelock.getOperationPayload(id, i), op.payloads[i], "Payload mismatch at index");
+ }
+ }
+}
diff --git a/test/timelock/TimelockHelper.t.sol b/test/timelock/TimelockHelper.t.sol
new file mode 100644
index 0000000..618826f
--- /dev/null
+++ b/test/timelock/TimelockHelper.t.sol
@@ -0,0 +1,345 @@
+// SPDX-FileCopyrightText: © 2026 Dai Foundation
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+
+pragma solidity ^0.8.24;
+
+import { Test } from "forge-std/Test.sol";
+import { Timelock } from "src/timelock/Timelock.sol";
+import { TimelockHelper } from "src/timelock/helpers/TimelockHelper.sol";
+
+contract TimelockHelperTest is Test {
+ Timelock public timelock;
+ TimelockHelper public timelockHelper;
+
+ uint256 public constant MIN_DELAY = 1 days;
+ bytes32 public constant SALT = keccak256("test-salt");
+
+ function setUp() public {
+ timelock = new Timelock(MIN_DELAY, address(this));
+ timelockHelper = new TimelockHelper(address(timelock));
+
+ timelock.grantRole(timelock.PROPOSER_ROLE(), address(this));
+ timelock.grantRole(timelock.CANCELLER_ROLE(), address(this));
+ }
+
+ function _schedule(
+ address target,
+ uint256 value,
+ bytes memory data,
+ bytes32 predecessor,
+ bytes32 salt,
+ uint256 delay
+ ) internal returns (bytes32) {
+ address[] memory targets = new address[](1);
+ targets[0] = target;
+ uint256[] memory values = new uint256[](1);
+ values[0] = value;
+ bytes[] memory payloads = new bytes[](1);
+ payloads[0] = data;
+ timelock.scheduleBatch(targets, values, payloads, predecessor, salt, delay);
+ return timelock.hashOperationBatch(targets, values, payloads, predecessor, salt);
+ }
+
+ function _execute(
+ address target,
+ uint256 value,
+ bytes memory data,
+ bytes32 predecessor,
+ bytes32 salt
+ ) internal {
+ address[] memory targets = new address[](1);
+ targets[0] = target;
+ uint256[] memory values = new uint256[](1);
+ values[0] = value;
+ bytes[] memory payloads = new bytes[](1);
+ payloads[0] = data;
+ timelock.executeBatch{value: value}(targets, values, payloads, predecessor, salt);
+ }
+
+ function testGetNextExecutableOperationEmpty() public view {
+ (bool found, bytes32 id) = timelockHelper.getNextExecutableOperationId(bytes32(0), type(uint256).max);
+ assertEq(id, bytes32(0));
+ assertFalse(found);
+ }
+
+ function testGetNextExecutableOperationNotReady() public {
+ bytes memory data = abi.encodeWithSignature("random(uint256)", 42);
+
+ _schedule(address(123), 0, data, bytes32(0), SALT, MIN_DELAY);
+
+ (bool found, bytes32 id) = timelockHelper.getNextExecutableOperationId(bytes32(0), type(uint256).max);
+ assertEq(id, bytes32(0));
+ assertFalse(found);
+ }
+
+ function testGetNextExecutableOperationReady() public {
+ bytes memory data = abi.encodeWithSignature("random(uint256)", 42);
+
+ bytes32 expectedId = _schedule(address(123), 0, data, bytes32(0), SALT, MIN_DELAY);
+
+ vm.warp(block.timestamp + MIN_DELAY);
+
+ (bool found, bytes32 id) = timelockHelper.getNextExecutableOperationId(bytes32(0), type(uint256).max);
+ assertEq(id, expectedId);
+ assertTrue(found);
+ }
+
+ function testGetNextExecutableOperationMultipleReturnsFirst() public {
+ bytes memory data1 = abi.encodeWithSignature("random(uint256)", 1);
+ bytes memory data2 = abi.encodeWithSignature("random(uint256)", 2);
+ bytes memory data3 = abi.encodeWithSignature("random(uint256)", 3);
+
+ bytes32 id1 = _schedule(address(123), 0, data1, bytes32(0), keccak256("s1"), MIN_DELAY);
+
+ vm.warp(block.timestamp + 1 hours);
+ _schedule(address(123), 0, data2, bytes32(0), keccak256("s2"), MIN_DELAY);
+
+ vm.warp(block.timestamp + 1 hours);
+ _schedule(address(123), 0, data3, bytes32(0), keccak256("s3"), MIN_DELAY);
+ vm.stopPrank();
+
+ vm.warp(block.timestamp + MIN_DELAY);
+
+ (bool found, bytes32 id) = timelockHelper.getNextExecutableOperationId(bytes32(0), type(uint256).max);
+ assertEq(id, id1);
+ assertTrue(found);
+ }
+
+ function testGetNextExecutableOperationChangesAfterExecution() public {
+ bytes memory data1 = abi.encodeWithSignature("random(uint256)", 1);
+ bytes memory data2 = abi.encodeWithSignature("random(uint256)", 2);
+
+ bytes32 id1 = _schedule(address(123), 0, data1, bytes32(0), keccak256("s1"), MIN_DELAY);
+ bytes32 id2 = _schedule(address(123), 0, data2, id1, keccak256("s2"), MIN_DELAY);
+ vm.stopPrank();
+
+ vm.warp(block.timestamp + MIN_DELAY);
+
+ (bool found, bytes32 id) = timelockHelper.getNextExecutableOperationId(bytes32(0), type(uint256).max);
+ assertEq(id, id1);
+ assertTrue(found);
+
+ _execute(address(123), 0, data1, bytes32(0), keccak256("s1"));
+
+ (found, id) = timelockHelper.getNextExecutableOperationId(bytes32(0), type(uint256).max);
+ assertEq(id, id2);
+ assertTrue(found);
+ }
+
+ function testGetNextExecutableOperationWithMaxIterations() public {
+ // Schedule 5 operations, only the 4th one is ready
+ bytes memory data = abi.encodeWithSignature("random(uint256)", 42);
+
+ // Schedule 3 operations with long delay (not ready)
+ _schedule(address(123), 0, data, bytes32(0), keccak256("s1"), 10 days);
+ _schedule(address(123), 0, data, bytes32(0), keccak256("s2"), 10 days);
+ _schedule(address(123), 0, data, bytes32(0), keccak256("s3"), 10 days);
+ // Schedule 1 operation with short delay (will be ready)
+ bytes32 expectedReadyId = _schedule(address(123), 0, data, bytes32(0), keccak256("s4"), MIN_DELAY);
+ // Schedule 1 more with long delay
+ _schedule(address(123), 0, data, bytes32(0), keccak256("s5"), 10 days);
+ vm.stopPrank();
+
+ vm.warp(block.timestamp + MIN_DELAY);
+
+ // With maxIterations=3, should not find the ready operation (it's at position 4)
+ // Returns the next id to continue from (s4) with found=false
+ (bool found, bytes32 id) = timelockHelper.getNextExecutableOperationId(bytes32(0), 3);
+ assertEq(id, expectedReadyId, "Should return next id to check with limit 3");
+ assertFalse(found, "Should not be found with limit 3");
+
+ // With maxIterations=4, should find it
+ (found, id) = timelockHelper.getNextExecutableOperationId(bytes32(0), 4);
+ assertEq(id, expectedReadyId, "Should find with limit 4");
+ assertTrue(found, "Should be found with limit 4");
+
+ // With maxIterations=100, should also find it
+ (found, id) = timelockHelper.getNextExecutableOperationId(bytes32(0), 100);
+ assertEq(id, expectedReadyId, "Should find with limit 100");
+ assertTrue(found, "Should be found with limit 100");
+ }
+
+ function testGetNextExecutableOperationZeroMaxIterationsReverts() public {
+ vm.expectRevert("TimelockHelper/zero-maxIterations");
+ timelockHelper.getNextExecutableOperationId(bytes32(0), 0);
+ }
+
+ function testGetNextExecutableOperationStartWithNonExistentIdReverts() public {
+ bytes memory data = abi.encodeWithSignature("random(uint256)", 42);
+ _schedule(address(123), 0, data, bytes32(0), SALT, MIN_DELAY);
+
+ // Starting with a non-existent ID reverts
+ vm.expectRevert("TimelockHelper/invalid-startId");
+ timelockHelper.getNextExecutableOperationId(keccak256("nonexistent"), type(uint256).max);
+ }
+
+ function testGetNextExecutableOperationLargeMaxIterations() public {
+ bytes memory data = abi.encodeWithSignature("random(uint256)", 42);
+ bytes32 expectedId = _schedule(address(123), 0, data, bytes32(0), SALT, MIN_DELAY);
+
+ vm.warp(block.timestamp + MIN_DELAY);
+
+ // Very large maxIterations should not overflow
+ (bool found, bytes32 id) = timelockHelper.getNextExecutableOperationId(bytes32(0), type(uint256).max);
+ assertEq(id, expectedId);
+ assertTrue(found);
+ }
+
+ function testGetNextExecutableOperationPagination() public {
+ bytes memory data = abi.encodeWithSignature("random(uint256)", 42);
+
+ _schedule(address(123), 0, data, bytes32(0), keccak256("s0"), 10 days); // not ready
+ bytes32 id1 = _schedule(address(123), 0, data, bytes32(0), keccak256("s1"), MIN_DELAY); // ready
+ _schedule(address(123), 0, data, bytes32(0), keccak256("s2"), 10 days); // not ready
+ bytes32 id3 = _schedule(address(123), 0, data, bytes32(0), keccak256("s3"), MIN_DELAY); // ready
+ _schedule(address(123), 0, data, bytes32(0), keccak256("s4"), 10 days); // not ready
+ vm.stopPrank();
+
+ vm.warp(block.timestamp + MIN_DELAY);
+
+ // First call finds id1
+ (bool found, bytes32 id) = timelockHelper.getNextExecutableOperationId(bytes32(0), type(uint256).max);
+ assertEq(id, id1);
+ assertTrue(found);
+
+ // Continue from after id1 (use getNextOperationId to get next starting point), finds id3
+ bytes32 nextStart = timelock.getNextOperationId(id1);
+ (found, id) = timelockHelper.getNextExecutableOperationId(nextStart, type(uint256).max);
+ assertEq(id, id3);
+ assertTrue(found);
+
+ // Continue from after id3, nothing ready left (list exhausted)
+ nextStart = timelock.getNextOperationId(id3);
+ (found, id) = timelockHelper.getNextExecutableOperationId(nextStart, type(uint256).max);
+ assertEq(id, bytes32(0));
+ assertFalse(found);
+ }
+
+ function testGetNextExecutableOperationSkipsPendingPredecessor() public {
+ bytes memory data1 = abi.encodeWithSignature("random(uint256)", 1);
+ bytes memory data2 = abi.encodeWithSignature("random(uint256)", 2);
+ bytes memory data3 = abi.encodeWithSignature("random(uint256)", 3);
+
+ // Schedule id1 with long delay (won't be ready)
+ bytes32 id1 = _schedule(address(123), 0, data1, bytes32(0), keccak256("s1"), 10 days);
+ // Schedule id2 with id1 as predecessor (ready but predecessor not done)
+ _schedule(address(123), 0, data2, id1, keccak256("s2"), MIN_DELAY);
+ // Schedule id3 with no predecessor (ready)
+ bytes32 id3 = _schedule(address(123), 0, data3, bytes32(0), keccak256("s3"), MIN_DELAY);
+ vm.stopPrank();
+
+ vm.warp(block.timestamp + MIN_DELAY);
+
+ // Should skip id1 (not ready), skip id2 (predecessor not done), return id3
+ (bool found, bytes32 id) = timelockHelper.getNextExecutableOperationId(bytes32(0), type(uint256).max);
+ assertEq(id, id3);
+ assertTrue(found);
+ }
+
+ function testGetNextExecutableOperationWithDonePredecessor() public {
+ bytes memory data1 = abi.encodeWithSignature("random(uint256)", 1);
+ bytes memory data2 = abi.encodeWithSignature("random(uint256)", 2);
+
+ // Schedule operation A (no predecessor)
+ bytes32 id1 = _schedule(address(123), 0, data1, bytes32(0), keccak256("s1"), MIN_DELAY);
+
+ // Schedule operation B with A as predecessor
+ bytes32 id2 = _schedule(address(123), 0, data2, id1, keccak256("s2"), MIN_DELAY);
+
+ vm.warp(block.timestamp + MIN_DELAY);
+
+ // Before executing predecessor: should return id1 (id2's predecessor not done)
+ (bool found, bytes32 id) = timelockHelper.getNextExecutableOperationId(bytes32(0), type(uint256).max);
+ assertEq(id, id1);
+ assertTrue(found);
+
+ // Execute id1 (predecessor is now done)
+ _execute(address(123), 0, data1, bytes32(0), keccak256("s1"));
+
+ // Now id2's predecessor is done, should return id2
+ (found, id) = timelockHelper.getNextExecutableOperationId(bytes32(0), type(uint256).max);
+ assertEq(id, id2);
+ assertTrue(found);
+ }
+
+ function testGetNextExecutableOperationReturnValues() public {
+ bytes memory data = abi.encodeWithSignature("random(uint256)", 42);
+
+ // Case 1: Empty list - id should be bytes32(0), found should be false
+ (bool found, bytes32 id) = timelockHelper.getNextExecutableOperationId(bytes32(0), type(uint256).max);
+ assertEq(id, bytes32(0), "Empty list: id should be 0");
+ assertFalse(found, "Empty list: found should be false");
+
+ // Schedule 4 operations with long delays (none ready)
+ bytes32 id0 = _schedule(address(123), 0, data, bytes32(0), keccak256("s0"), 10 days);
+ bytes32 id1 = _schedule(address(123), 0, data, bytes32(0), keccak256("s1"), 10 days);
+ bytes32 id2 = _schedule(address(123), 0, data, bytes32(0), keccak256("s2"), 10 days);
+ bytes32 id3 = _schedule(address(123), 0, data, bytes32(0), keccak256("s3"), 10 days);
+ vm.stopPrank();
+
+ // Case 2: No operations ready, maxIterations reached - id should be next to check, found false
+ (found, id) = timelockHelper.getNextExecutableOperationId(bytes32(0), 2);
+ assertEq(id, id2, "Not ready, limit 2: id should be id2 (next to check)");
+ assertFalse(found, "Not ready, limit 2: found should be false");
+
+ (found, id) = timelockHelper.getNextExecutableOperationId(bytes32(0), 3);
+ assertEq(id, id3, "Not ready, limit 3: id should be id3 (next to check)");
+ assertFalse(found, "Not ready, limit 3: found should be false");
+
+ // Case 3: No operations ready, list exhausted - id should be bytes32(0), found false
+ (found, id) = timelockHelper.getNextExecutableOperationId(bytes32(0), type(uint256).max);
+ assertEq(id, bytes32(0), "Not ready, exhausted: id should be 0");
+ assertFalse(found, "Not ready, exhausted: found should be false");
+
+ // Make operations ready
+ vm.warp(block.timestamp + 10 days);
+
+ // Case 4: Ready operation found - id should be the ready operation, found true
+ (found, id) = timelockHelper.getNextExecutableOperationId(bytes32(0), type(uint256).max);
+ assertEq(id, id0, "Ready found: id should be id0");
+ assertTrue(found, "Ready found: found should be true");
+
+ // Case 5: Ready operation found starting from id1 (startId is inclusive)
+ (found, id) = timelockHelper.getNextExecutableOperationId(id1, type(uint256).max);
+ assertEq(id, id1, "Ready from id1: id should be id1");
+ assertTrue(found, "Ready from id1: found should be true");
+
+ // Case 6: Pagination - when not found, returned id is directly the next startId
+ // Cancel id0 and id1 so they won't be found, schedule new not-ready ops
+ timelock.cancel(id0);
+ timelock.cancel(id1);
+ // Schedule 2 new operations with very long delay (not ready)
+ bytes32 id4 = _schedule(address(123), 0, data, bytes32(0), keccak256("new0"), 100 days);
+ bytes32 id5 = _schedule(address(123), 0, data, bytes32(0), keccak256("new1"), 100 days);
+ vm.stopPrank();
+
+ // Now list is: id2 (ready), id3 (ready), id4 (not ready), id5 (not ready)
+ // Search with limit 1 starting from beginning - finds id2 immediately
+ (found, id) = timelockHelper.getNextExecutableOperationId(bytes32(0), 1);
+ assertEq(id, id2, "Pagination case 6a: id should be id2");
+ assertTrue(found, "Pagination case 6a: found should be true");
+
+ // Search with limit 1 starting from id4 - checks id4 (not ready), returns id5 (next to check)
+ (found, id) = timelockHelper.getNextExecutableOperationId(id4, 1);
+ assertEq(id, timelock.getNextOperationId(id4), "Pagination case 6b: id should be next of id4");
+ assertFalse(found, "Pagination case 6b: found should be false");
+
+ // Continue from id5 with limit 1 - checks id5 (not ready), id5 is last so returns 0 (exhausted)
+ (found, id) = timelockHelper.getNextExecutableOperationId(id5, 1);
+ assertEq(id, timelock.getNextOperationId(id5), "Pagination case 6c: id should be next of id5 (0)");
+ assertFalse(found, "Pagination case 6c: found should be false");
+ }
+}