diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..2d454d0 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,78 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +jobs: + contracts: + name: Contracts + runs-on: ubuntu-latest + defaults: + run: + working-directory: contracts + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + - uses: foundry-rs/foundry-toolchain@v1 + with: + version: v1.8.1 + - name: Build (deny warnings) + run: forge build --deny warnings + - name: Lint + run: forge lint + - name: Format check + run: forge fmt --check + - name: Test + run: forge test + + deno: + name: Deno + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + - uses: denoland/setup-deno@v2 + with: + deno-version: v2.x + - uses: foundry-rs/foundry-toolchain@v1 + with: + version: v1.8.1 + - name: Type check + run: deno check + - name: Format check + run: deno fmt --check + - name: Verify drizzle migrations are up to date + run: | + deno task drizzle-generate + if [ -n "$(git status --porcelain -- drizzle)" ]; then + echo "::error::drizzle/ is out of date with src/db/schema.ts \ + - run 'deno task drizzle-generate' and commit the result." + git diff -- drizzle + exit 1 + fi + # Compares only the compiled bytecode (see `trimMetadata` in generate-contracts.ts) rather + # than diffing the generated files outright, since Solidity's metadata trailer embeds a + # hash covering absolute source paths and differs between machines/Foundry versions even + # when the contract itself hasn't changed. + - name: Verify generated contract bindings are up to date + run: deno task contracts-generate-check + + integration: + name: Integration tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: denoland/setup-deno@v2 + with: + deno-version: v2.x + - name: Start Postgres and Anvil + run: docker compose up -d --wait + - name: Run integration tests + run: deno task test-integration diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..c65a596 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "contracts/lib/forge-std"] + path = contracts/lib/forge-std + url = https://github.com/foundry-rs/forge-std diff --git a/Dockerfile b/Dockerfile index 421482f..157f1fd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,18 +1,7 @@ -# Use an official Deno image. FROM docker.io/denoland/deno:2.7.14 - -# Set the working directory in the container WORKDIR /app -COPY . . - -# Cache dependencies. Deno will use deno.json automatically. -# Copy deno.json first so this layer is cached if only source files change. -RUN deno cache main.ts --allow-import - -# Expose the port the app runs on +COPY deno.json deno.lock ./ +COPY drizzle ./drizzle +COPY src ./src EXPOSE 8000 - -# Command to run the application -# Deno uses deno.json automatically for import maps. -# Permissions are still needed. CMD ["deno", "task", "start"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..eb1b16d --- /dev/null +++ b/LICENSE @@ -0,0 +1,621 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 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 General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is 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. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + 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. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + 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 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. Use with the GNU Affero General Public License. + + 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 Affero 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 special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU 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 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 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 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 diff --git a/config.ts b/config.ts deleted file mode 100644 index 4758717..0000000 --- a/config.ts +++ /dev/null @@ -1,116 +0,0 @@ -import { z } from "zod"; -import { - createWalletClient, - getContract, - GetContractReturnType, - Hex, - http, - parseAbi, - WalletClient, -} from "viem"; -import { privateKeyToAccount } from "viem/accounts"; -import * as viemChains from "viem/chains"; - -// Port number configuration - -export function getPort(): number { - return port ??= z.coerce.number().default(8000).parse(Deno.env.get("PORT")); -} - -let port: number | undefined; - -// App configuration in raw form as provided by the user - -type Config = z.infer; - -function getConfig(): Config { - return config ??= configSchema.parse(JSON.parse(Deno.env.get("CONFIG") ?? "{}")); -} - -let config: Config | undefined; - -const configSchema = z.object({ - rpcs: z.array(z.object({ - chainId: z.number(), - url: z.url(), - })).default([]), - wallets: z.array(z.object({ - chainIds: z.array(z.number()), - privateKey: z.string(), - })).default([]), -}); - -// All supported chains - -type Chains = Record; - -function getChains(): Chains { - // Resolve duplicate chains by keeping the version from the lexically first key e.g. suffix-free. - return chains ??= Object.keys(viemChains) - .sort() - .map((key) => viemChains[key as keyof typeof viemChains]) - .reduce((chains, chain) => ({ [chain.id]: chain, ...chains }), {}); -} - -let chains: Chains | undefined; - -// RPC URL overrides configuration - -type RpcUrls = Record; - -function getRpcUrls(): RpcUrls { - return rpcUrls ??= getRpcUrlsValue(); -} - -let rpcUrls: RpcUrls | undefined; - -function getRpcUrlsValue(): RpcUrls { - // Resolve duplicate chains by keeping the version under the lexically first key, e.g. suffix-free. - const chains = getChains(); - const rpcUrls: RpcUrls = {}; - for (const { chainId, url } of getConfig().rpcs) { - if (!chains[chainId]) throw new Error("Unknown RPC chain ID " + chainId); - if (rpcUrls[chainId]) throw new Error("Duplicate RPCs for chain ID " + chainId); - rpcUrls[chainId] = url; - } - return rpcUrls; -} - -// Mutlicall3 wallets configuration - -const multicall3Abi = parseAbi([ - "struct Call3 { address target; bool allowFailure; bytes callData; }", - "struct Result { bool success; bytes returnData; }", - "function aggregate3(Call3[] calls) payable returns (Result[] returnData)", -]); - -export type Multicall3 = GetContractReturnType; -export type Multicall3s = Record; - -export function getMulticall3s(): Multicall3s { - return multicall3s ??= getMulticall3sValue(); -} - -let multicall3s: Multicall3s | undefined; - -function getMulticall3sValue(): Multicall3s { - const chains = getChains(); - const rpcUrls = getRpcUrls(); - const multicall3s: Multicall3s = {}; - for (const wallet of getConfig().wallets) { - const account = privateKeyToAccount(wallet.privateKey as Hex); - for (const chainId of wallet.chainIds) { - const chain = chains[chainId]; - if (!chain) throw new Error("Unknown wallet chain ID " + chainId); - - const address = chain.contracts?.multicall3?.address; - if (!address) throw new Error("No multicall3 for chain ID " + chainId); - - const client = createWalletClient({ account, chain, transport: http(rpcUrls[chainId]) }); - - if (multicall3s[chainId]) throw new Error("Duplicate wallets for chain ID " + chainId); - multicall3s[chainId] = getContract({ address, client, abi: multicall3Abi }); - } - } - return multicall3s; -} diff --git a/contracts/.gitignore b/contracts/.gitignore new file mode 100644 index 0000000..c8e4586 --- /dev/null +++ b/contracts/.gitignore @@ -0,0 +1,20 @@ +# Compiler files +cache/ +out/ + +# Commonly ignored directories +.logs/ +.ignore/ + +# Ignores development broadcast logs +!/broadcast +/broadcast/*/31337/ +/broadcast/**/dry-run/ + +# Docs +docs/ + +# Dotenv file +.env +.env.* +!.env.example diff --git a/contracts/foundry.lock b/contracts/foundry.lock new file mode 100644 index 0000000..7521bfd --- /dev/null +++ b/contracts/foundry.lock @@ -0,0 +1,8 @@ +{ + "lib/forge-std": { + "tag": { + "name": "v1.16.1", + "rev": "620536fa5277db4e3fd46772d5cbc1ea0696fb43" + } + } +} \ No newline at end of file diff --git a/contracts/foundry.toml b/contracts/foundry.toml new file mode 100644 index 0000000..34f501b --- /dev/null +++ b/contracts/foundry.toml @@ -0,0 +1,8 @@ +[profile.default] +solc_version = '0.8.36' +optimizer_runs = 1_000_000 +evm_version = 'constantinople' +[fmt] +line_length = 100 +[lint] +ignore = ["test/*"] diff --git a/contracts/lib/forge-std b/contracts/lib/forge-std new file mode 160000 index 0000000..620536f --- /dev/null +++ b/contracts/lib/forge-std @@ -0,0 +1 @@ +Subproject commit 620536fa5277db4e3fd46772d5cbc1ea0696fb43 diff --git a/contracts/src/Executor.sol b/contracts/src/Executor.sol new file mode 100644 index 0000000..39a15c3 --- /dev/null +++ b/contracts/src/Executor.sol @@ -0,0 +1,92 @@ +// SPDX-License-Identifier: GPL-3.0-only +pragma solidity ^0.8.35; + +struct Burst { + bool needsPrev; + uint256 gas; + Call[] calls; +} + +struct Call { + address target; + bytes data; + uint256 gas; +} + +// forge-lint: disable-next-line(unsafe-typecast) +address constant DRAIN_GAS_WALLET = address(bytes20("Executor - drain gas")); + +contract Executor { + event Receipt(int256[] gasReport); + + function exec(Burst[] calldata bursts) public returns (int256[] memory gasReport) { + gasReport = new int256[](bursts.length + 1); + bool success = true; + // forge-lint: disable-next-item(boolean-cst) + for (uint256 idx = 0; true; idx++) { + // forge-lint: disable-next-item(unsafe-typecast) + gasReport[idx] = success ? int256(gasleft()) : -int256(gasleft()); + if (idx == bursts.length) break; + Burst calldata burst = bursts[idx]; + if (!success && burst.needsPrev) continue; + // forge-lint: disable-next-item(return-bomb, calls-loop, low-level-calls) + (success,) = + address(this).call{gas: burst.gas}(abi.encodeCall(this.execSingle, (burst.calls))); + // forge-lint: disable-next-line(tx-origin) + if (tx.origin == DRAIN_GAS_WALLET) { + // forge-lint: disable-next-item( + // require-revert-in-loop, literal-instead-of-constant, custom-errors) + // Assert that there was enough gas to cover the burst gas limit in full + require(gasleft() * 63 >= burst.gas); + // Do not skip the next burst in the sequence + success = true; + } + } + // forge-lint: disable-next-item(reentrancy-events) + emit Receipt(gasReport); + } + + /// @param burstsGas The gas needed to execute `bursts`. + /// It must be at least entire gas needed to call `exec`, including any leftover gas, + /// but it may exclude the TX overhead, e.g. the calldata cost. + function execNext(Burst[] calldata bursts, uint256 burstsGas, Call[] calldata nextBurstCalls) + external + returns (uint256 nextBurstGas) + { + // Assert that exec has as much gas as it requires. + uint256 gasAfter = gasleft() - burstsGas; + int256[] memory results = exec(bursts); + // Burn any possible leftover gas to avoid it overshadowing the next call's gas requirement. + while (gasleft() > gasAfter) continue; + for (uint256 i = 0; i < results.length; i++) { + // forge-lint: disable-next-item(require-revert-in-loop, custom-errors) + require(results[i] > 0); + } + // forge-lint: disable-next-item(literal-instead-of-constant) + // Measure gas passed into the burst. + nextBurstGas = gasleft() * 63 / 64; + this.execSingle(nextBurstCalls); + } + + function execSingle(Call[] calldata calls) external { + // forge-lint: disable-next-line(tx-origin) + if (tx.origin == DRAIN_GAS_WALLET) { + // Burn all available gas and revert + while (true) continue; + } + for (uint256 i = 0; i < calls.length; i++) { + Call calldata nextCall = calls[i]; + uint256 gasLimit = nextCall.gas; + if (gasLimit == 0) gasLimit = gasleft(); + address target = nextCall.target; + bytes memory data = nextCall.data; + bool success; + // forge-lint: disable-next-item(inline-assembly) + assembly ("memory-safe") { + success := call(gasLimit, target, 0, add(data, 32), mload(data), 0, 0) + } + // forge-lint: disable-next-item(require-revert-in-loop, custom-errors) + require(success); + } + } +} diff --git a/contracts/test/CallsLog.sol b/contracts/test/CallsLog.sol new file mode 100644 index 0000000..984faba --- /dev/null +++ b/contracts/test/CallsLog.sol @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: GPL-3.0-only +pragma solidity ^0.8.35; + +contract CallsLog { + string[] private logs; + mapping(string => bool) private reverts; + mapping(string => uint256) private gasPenalties; + + function addLog(string calldata log) external { + require(!reverts[log], "CallsLog: configured to revert"); + + uint256 gasPenalty = gasPenalties[log]; + uint256 gas = gasleft(); + while (gas - gasleft() < gasPenalty) continue; + + logs.push(log); + } + + function getLogs() external view returns (string[] memory) { + return logs; + } + + function setReverts(string calldata log) external { + reverts[log] = true; + } + + function setGasPenalty(string calldata log, uint256 gas) external { + gasPenalties[log] = gas; + } +} diff --git a/contracts/test/Executor.t.sol b/contracts/test/Executor.t.sol new file mode 100644 index 0000000..e423ad4 --- /dev/null +++ b/contracts/test/Executor.t.sol @@ -0,0 +1,371 @@ +// SPDX-License-Identifier: GPL-3.0-only +pragma solidity ^0.8.35; + +import {TestBase} from "forge-std/Base.sol"; +import {StdAssertions} from "forge-std/StdAssertions.sol"; +import {stdMath} from "forge-std/StdMath.sol"; +import {Vm} from "forge-std/Vm.sol"; +import {Burst, Call, DRAIN_GAS_WALLET, Executor} from "../src/Executor.sol"; +import {CallsLog} from "./CallsLog.sol"; + +// Cheat code address, see `forge-std/Base.sol`'s `CommonBase` - duplicated here so the libraries +// below (which can't inherit `TestBase`) can also use Foundry's own `vm.assertX` cheatcodes +// instead of a plain `require`, matching the rest of this file. +Vm constant vm = Vm(0x7109709ECfa91a80626fF3989D68f67F5b1DD12D); + +// Tracks progress consuming a `CallsLog.getLogs()` result. +struct LogsExpect { + string[] logs; + uint256 index; +} + +using Logs for LogsExpect global; + +// Consumes a `CallsLog.getLogs()` result entry by entry. Meant to be used via +// `using Logs for string[];`, e.g.: +// callsLog.getLogs().expect().log("a").log("b").end(); +library Logs { + function expect(string[] memory logs) internal pure returns (LogsExpect memory) { + return LogsExpect({logs: logs, index: 0}); + } + + function log(LogsExpect memory state, string memory expected) + internal + pure + returns (LogsExpect memory) + { + vm.assertLt(state.index, state.logs.length, "Logs: no more entries"); + vm.assertEq(state.logs[state.index], expected, "Logs: unexpected entry"); + state.index++; + return state; + } + + // Asserts that every entry has been consumed by `log`. + function end(LogsExpect memory state) internal pure { + vm.assertEq(state.index, state.logs.length, "Logs: not fully consumed"); + } +} + +// Tracks progress consuming an `Executor.exec` gas report - `report` is kept whole (unlike a +// shift-and-shrink approach) specifically so `success`/`failure` can compare each entry against +// the one before it. +struct GasReportExpect { + int256[] report; + uint256 index; +} + +using GasReport for GasReportExpect global; + +// Consumes an `Executor.exec` gas report entry by entry. Meant to be used via +// `using GasReport for int256[];`, e.g.: +// gasReport.expect().success().success().failure().end(); +library GasReport { + // The 1st entry is always a success (the checkpoint before the 1st burst runs, when nothing's + // failed yet), so it's checked immediately, rather than needing its own `success()` call. + function expect(int256[] memory report) internal pure returns (GasReportExpect memory) { + vm.assertGt(report.length, 0, "GasReport: empty"); + vm.assertGt(report[0], int256(0), "GasReport: expected a success entry"); + return GasReportExpect({report: report, index: 1}); + } + + function success(GasReportExpect memory state) internal pure returns (GasReportExpect memory) { + vm.assertLt(state.index, state.report.length, "GasReport: no more entries"); + vm.assertGt(state.report[state.index], int256(0), "GasReport: expected a success entry"); + advance(state); + return state; + } + + function failure(GasReportExpect memory state) internal pure returns (GasReportExpect memory) { + vm.assertLt(state.index, state.report.length, "GasReport: no more entries"); + vm.assertLt(state.report[state.index], int256(0), "GasReport: expected a failure entry"); + advance(state); + return state; + } + + // Asserts that every entry has been consumed by `success`/`failure`. + function end(GasReportExpect memory state) internal pure { + vm.assertEq(state.index, state.report.length, "GasReport: not fully consumed"); + } + + // Each entry is `gasleft()` at a later checkpoint than the last, so - regardless of sign - it + // must be lower. + function advance(GasReportExpect memory state) private pure { + vm.assertGt( + stdMath.abs(state.report[state.index - 1]), + stdMath.abs(state.report[state.index]), + "GasReport: gas left didn't strictly decrease" + ); + state.index++; + } +} + +// A batch under construction for `Executor.exec`, carrying along its target `CallsLog` so it +// doesn't need to be repeated on every `pushCallLog` call. +struct BatchBuilder { + Burst[] bursts; + CallsLog callsLog; +} + +using BatchBuilderImpl for BatchBuilder global; + +// Incrementally builds a `BatchBuilder` - `Burst[] memory` has no `push`, so each call here +// allocates a new, 1-larger array and copies the old one into it. E.g.: +// BatchBuilder memory builder = BatchBuilderImpl.create(callsLog); +// builder = builder.pushBurst(false).pushCallLog("a").pushBurst(true).pushCallLog("b"); +library BatchBuilderImpl { + // `new` is a reserved keyword in Solidity, so this can't be named that. + function create(CallsLog callsLog) internal pure returns (BatchBuilder memory) { + return BatchBuilder({bursts: new Burst[](0), callsLog: callsLog}); + } + + function pushBurst(BatchBuilder memory builder, bool needsPrev) + internal + pure + returns (BatchBuilder memory) + { + return pushBurst(builder, needsPrev, 500_000); + } + + function pushBurst(BatchBuilder memory builder, bool needsPrev, uint256 gas) + internal + pure + returns (BatchBuilder memory) + { + Burst[] memory newBursts = new Burst[](builder.bursts.length + 1); + for (uint256 i = 0; i < builder.bursts.length; i++) { + newBursts[i] = builder.bursts[i]; + } + newBursts[builder.bursts.length] = + Burst({needsPrev: needsPrev, gas: gas, calls: new Call[](0)}); + builder.bursts = newBursts; + return builder; + } + + // Appends a `CallsLog.addLog(log)` call to the last burst. + function pushCallLog(BatchBuilder memory builder, string memory log) + internal + pure + returns (BatchBuilder memory) + { + return pushCallLog(builder, log, 0); + } + + function pushCallLog(BatchBuilder memory builder, string memory log, uint256 gas) + internal + pure + returns (BatchBuilder memory) + { + Burst memory lastBurst = builder.bursts[builder.bursts.length - 1]; + Call[] memory newCalls = new Call[](lastBurst.calls.length + 1); + for (uint256 i = 0; i < lastBurst.calls.length; i++) { + newCalls[i] = lastBurst.calls[i]; + } + newCalls[lastBurst.calls.length] = Call({ + target: address(builder.callsLog), + data: abi.encodeCall(builder.callsLog.addLog, (log)), + gas: gas + }); + lastBurst.calls = newCalls; + builder.bursts[builder.bursts.length - 1] = lastBurst; + return builder; + } +} + +// `forge-std/Test.sol` pulls in `StdCheats`, which uses the `chainid` opcode - unavailable under +// this project's `evm_version = 'constantinople'` (see foundry.toml) - so only the pieces actually +// needed here (cheatcodes via `vm`, plus assertion helpers) are used instead. +contract ExecutorTest is TestBase, StdAssertions { + using Logs for string[]; + using GasReport for int256[]; + + Executor private executor; + CallsLog private callsLog; + + function setUp() public { + executor = new Executor(); + callsLog = new CallsLog(); + } + + // Calls `exec`, asserting that the `Receipt` event it emits matches the gas report it returns. + function execChecked(Burst[] memory bursts) private returns (int256[] memory gasReport) { + vm.recordLogs(); + gasReport = executor.exec(bursts); + + Vm.Log[] memory entries = vm.getRecordedLogs(); + assertGe(entries.length, 1); + // `exec` emits it as the very last thing it does, so it's always the last entry. + Vm.Log memory receipt = entries[entries.length - 1]; + assertEq(receipt.emitter, address(executor)); + assertEq(receipt.topics[0], Executor.Receipt.selector); + assertEq(abi.decode(receipt.data, (int256[])), gasReport); + } + + function testExecRunsAllBurstsChecksReport() public { + BatchBuilder memory builder = BatchBuilderImpl.create(callsLog); + builder = builder.pushBurst(false).pushCallLog("a"); + builder = builder.pushBurst(true).pushCallLog("b1").pushCallLog("b2"); + builder = builder.pushBurst(true).pushCallLog("c1").pushCallLog("c2").pushCallLog("c3"); + + int256[] memory gasReport = execChecked(builder.bursts); + + // 1 report entry before the 1st burst, plus 1 after each burst - all successes, each with + // strictly less gas left than the one before. + gasReport.expect().success().success().success().end(); + + callsLog.getLogs().expect().log("a").log("b1").log("b2").log("c1").log("c2").log("c3").end(); + } + + function testExecSkipsNeedsPrevBurstsAfterRevertThenContinues() public { + callsLog.setReverts("bad"); + + BatchBuilder memory builder = BatchBuilderImpl.create(callsLog); + builder = builder.pushBurst(false).pushCallLog("a"); + // Reverts - its 2nd call, `bad`, is configured to revert on `CallsLog` - proving the whole + // burst is atomic, since its 1st call's own effect never lands either. + builder = builder.pushBurst(true).pushCallLog("not-reverted").pushCallLog("bad"); + // Skipped entirely - `needsPrev` and the previous burst failed - its call never runs. + builder = builder.pushBurst(true).pushCallLog("skipped"); + // Runs anyway - `needsPrev` is false, so it's attempted despite the earlier failure. + builder = builder.pushBurst(false).pushCallLog("c"); + // Runs normally - the previous burst succeeded, so this needs-prev burst isn't skipped. + builder = builder.pushBurst(true).pushCallLog("d"); + + int256[] memory gasReport = execChecked(builder.bursts); + + // Each entry reflects the outcome of whichever burst last actually ran before it - carried + // forward unchanged across a skip, since skipping never updates the success flag. Gas is + // only ever spent, so - regardless of sign - each entry has strictly less left than the one + // before it, whether the burst in between succeeded, reverted, or was skipped. + gasReport.expect().success().failure().failure().success().success().end(); + + // Neither of the reverted burst's calls land (not even its 1st, `not-reverted`) and + // "skipped" never runs at all. + callsLog.getLogs().expect().log("a").log("c").log("d").end(); + } + + function testExecDoesNotRunBurstsWhenSenderIsDrainGas() public { + // Configured to revert if it ever actually ran, proving the drain-gas short-circuit in + // `execSingle` kicks in before a burst's calls are even attempted, not just before enough + // of them succeed to matter. + callsLog.setReverts("bad"); + + BatchBuilder memory builder = BatchBuilderImpl.create(callsLog); + builder = builder.pushBurst(false).pushCallLog("a"); + builder = builder.pushBurst(true).pushCallLog("b"); + builder = builder.pushBurst(true).pushCallLog("bad"); + + // `execSingle` burns all its gas and reverts immediately when `tx.origin` is this address + // (see `Executor.sol`) - used by the relay to measure a batch's worst-case gas cost without + // its calls' side effects actually landing. + vm.prank(address(this), DRAIN_GAS_WALLET); + int256[] memory gasReport = execChecked(builder.bursts); + + // Every burst is forced back to "success" regardless, so a real batch keeps being measured + // in full, rather than stopping early as if the 1st burst had actually failed. + gasReport.expect().success().success().success().end(); + callsLog.getLogs().expect().end(); + } + + function testExecEnforcesACallsGasLimit() public { + // Far too little for `CallsLog.addLog` to even begin running - it runs out of gas and + // reverts, proving the call's own gas limit is actually forwarded to it, rather than being + // silently ignored or upgraded to however much gas happens to be available. + BatchBuilder memory builder = BatchBuilderImpl.create(callsLog); + builder = builder.pushBurst(false).pushCallLog("starved", 100); + + int256[] memory gasReport = execChecked(builder.bursts); + + gasReport.expect().failure().end(); + callsLog.getLogs().expect().end(); + } + + function testExecNextRunsPassedBurstsAndTheNextBurst() public { + BatchBuilder memory builder = BatchBuilderImpl.create(callsLog); + builder = builder.pushBurst(false).pushCallLog("a"); + builder = builder.pushBurst(true).pushCallLog("b"); + + Call[] memory nextBurstCalls = + BatchBuilderImpl.create(callsLog).pushBurst(false).pushCallLog("c").bursts[0].calls; + + // `burstsGas` only pads leftover gas before measuring the next burst's own needs, so it can + // just be 0 - that only skips the padding, it doesn't otherwise change what actually runs. + uint256 nextBurstGas = executor.execNext(builder.bursts, 0, nextBurstCalls); + + assertGt(nextBurstGas, 0); + callsLog.getLogs().expect().log("a").log("b").log("c").end(); + } + + function testExecNextRevertsWhenBurstsHasAFailingCall() public { + callsLog.setReverts("bad"); + + BatchBuilder memory builder = BatchBuilderImpl.create(callsLog); + builder = builder.pushBurst(false).pushCallLog("bad"); + + Call[] memory nextBurstCalls = + BatchBuilderImpl.create(callsLog).pushBurst(false).pushCallLog("c").bursts[0].calls; + + // `execNext` requires every burst in `bursts` to have succeeded - unlike `exec` on its own, + // it has no room for a partial failure, since there's no sequence state to fall back to + // pending here. + vm.expectRevert(); + executor.execNext(builder.bursts, 0, nextBurstCalls); + + // The whole call reverts atomically - `nextBurstCalls` never even gets a chance to run. + callsLog.getLogs().expect().end(); + } + + function testExecNextRevertsWhenNextBurstCallsHasAFailingCall() public { + callsLog.setReverts("bad"); + + BatchBuilder memory builder = BatchBuilderImpl.create(callsLog); + builder = builder.pushBurst(false).pushCallLog("a"); + + Call[] memory nextBurstCalls = + BatchBuilderImpl.create(callsLog).pushBurst(false).pushCallLog("bad").bursts[0].calls; + + // `execSingle` is called directly (not via the low-level call `exec` uses internally), so a + // failing call in it reverts `execNext` as a whole, rather than being reported as a failure. + vm.expectRevert(); + executor.execNext(builder.bursts, 0, nextBurstCalls); + + // The whole call reverts atomically - not even `bursts`' own successful effect lands. + callsLog.getLogs().expect().end(); + } + + function testExecNextRevertsWhenABurstsCallRunsOutOfGas() public { + // Far too little for `CallsLog.addLog` to even begin running - it runs out of gas and + // reverts, causing `execNext`'s post-`exec` success check to fail the whole call, same as + // any other failing call in `bursts`. + BatchBuilder memory builder = BatchBuilderImpl.create(callsLog); + builder = builder.pushBurst(false).pushCallLog("starved", 100); + + Call[] memory nextBurstCalls = + BatchBuilderImpl.create(callsLog).pushBurst(false).pushCallLog("c").bursts[0].calls; + + vm.expectRevert(); + executor.execNext(builder.bursts, 0, nextBurstCalls); + + // The whole call reverts atomically - `nextBurstCalls` never even gets a chance to run. + callsLog.getLogs().expect().end(); + } + + function testExecNextRevertsWhenANextBurstCallRunsOutOfGas() public { + BatchBuilder memory builder = BatchBuilderImpl.create(callsLog); + builder = builder.pushBurst(false).pushCallLog("a"); + + // Far too little for `CallsLog.addLog` to even begin running - it runs out of gas and + // reverts, which reverts `execSingle` and, with it, the whole `execNext` call. + Call[] memory nextBurstCalls = + BatchBuilderImpl.create(callsLog) + .pushBurst(false) + .pushCallLog("starved", 100) + .bursts[0] + .calls; + + vm.expectRevert(); + executor.execNext(builder.bursts, 0, nextBurstCalls); + + // The whole call reverts atomically - not even `bursts`' own successful effect lands. + callsLog.getLogs().expect().end(); + } +} diff --git a/deno.json b/deno.json index 13a9337..686a990 100644 --- a/deno.json +++ b/deno.json @@ -1,14 +1,40 @@ { + "license": "GPL-3.0-only", + "exclude": ["contracts"], "tasks": { - "start": "deno run --allow-net --allow-env --env-file main.ts" + "start": "deno run --allow-read=drizzle --allow-net --allow-sys=uid,gid,userInfo --allow-env --env-file src/main.ts", + "start-dev": "deno task --env-file start", + "contracts-generate": "deno run --allow-read=contracts,src --allow-write=src,tests --allow-run=forge,deno src/generate-contracts.ts", + "contracts-generate-check": "deno run --allow-read=contracts,src,tests --allow-run=forge src/generate-contracts.ts --check", + "drizzle-kit": "deno run --allow-write=drizzle --allow-net --allow-sys=homedir --allow-env --env-file --allow-read npm:drizzle-kit", + "drizzle-generate": "deno task drizzle-kit generate", + "test-integration": "deno test --allow-run=deno --allow-net --allow-env tests/integration" }, "imports": { + "drizzle-kit": "npm:drizzle-kit@^0.31.10", + "drizzle-orm": "npm:drizzle-orm@^0.45.2", "oak": "jsr:@oak/oak@^17.2.0", - "assert": "jsr:@std/assert@1", + "@std/assert": "jsr:@std/assert@^1.0.19", + "async": "jsr:@std/async@^1.5.0", + "postgres": "npm:postgres@^3.4.9", "viem": "jsr:@wevm/viem@^2.48.1", "zod": "jsr:@zod/zod@^4.3.6" }, "fmt": { - "lineWidth": 100 + "lineWidth": 100, + "include": [ + "src", + "tests", + "deno.json", + "drizzle.config.ts" + ] + }, + "lint": { + "include": [ + "src", + "tests", + "deno.json", + "drizzle.config.ts" + ] } } diff --git a/deno.lock b/deno.lock index 8d67cc1..a191f58 100644 --- a/deno.lock +++ b/deno.lock @@ -2,17 +2,21 @@ "version": "5", "specifiers": { "jsr:@oak/commons@1": "1.0.1", - "jsr:@oak/oak@*": "17.2.0", "jsr:@oak/oak@^17.2.0": "17.2.0", "jsr:@std/assert@1": "1.0.19", + "jsr:@std/assert@^1.0.19": "1.0.19", + "jsr:@std/async@1.5.0": "1.5.0", + "jsr:@std/async@^1.5.0": "1.5.0", "jsr:@std/bytes@1": "1.0.6", "jsr:@std/crypto@1": "1.0.5", + "jsr:@std/data-structures@^1.1.1": "1.1.1", "jsr:@std/encoding@1": "1.0.10", "jsr:@std/encoding@^1.0.10": "1.0.10", "jsr:@std/http@1": "1.0.25", - "jsr:@std/internal@^1.0.12": "1.0.12", + "jsr:@std/internal@^1.0.12": "1.0.14", "jsr:@std/media-types@1": "1.1.0", "jsr:@std/path@1": "1.1.4", + "jsr:@wevm/viem@*": "2.48.1", "jsr:@wevm/viem@^2.48.1": "2.48.1", "jsr:@zod/zod@^4.3.6": "4.3.6", "npm:@noble/curves@1.9.1": "1.9.1", @@ -20,15 +24,19 @@ "npm:@scure/bip32@1.7.0": "1.7.0", "npm:@scure/bip39@1.6.0": "1.6.0", "npm:abitype@1.2.3": "1.2.3", + "npm:drizzle-kit@*": "0.31.10", + "npm:drizzle-kit@~0.31.10": "0.31.10", + "npm:drizzle-orm@~0.45.2": "0.45.2_postgres@3.4.9", "npm:isows@1.0.7": "1.0.7_ws@8.20.0", "npm:ox@0.14.17": "0.14.17", - "npm:path-to-regexp@^6.3.0": "6.3.0" + "npm:path-to-regexp@^6.3.0": "6.3.0", + "npm:postgres@^3.4.9": "3.4.9" }, "jsr": { "@oak/commons@1.0.1": { "integrity": "889ff210f0b4292591721be07244ecb1b5c118742f5273c70cf30d7cd4184d0c", "dependencies": [ - "jsr:@std/assert", + "jsr:@std/assert@1", "jsr:@std/bytes", "jsr:@std/crypto", "jsr:@std/encoding@1", @@ -40,7 +48,7 @@ "integrity": "938537a92fc7922a46a9984696c65fb189c9baad164416ac3e336768a9ff0cd1", "dependencies": [ "jsr:@oak/commons", - "jsr:@std/assert", + "jsr:@std/assert@1", "jsr:@std/bytes", "jsr:@std/http", "jsr:@std/media-types", @@ -54,12 +62,21 @@ "jsr:@std/internal" ] }, + "@std/async@1.5.0": { + "integrity": "4c1f22d287a17ad7d8643b1952bc5a4d21e5d8b68dc8c20d8268eecc69096e82", + "dependencies": [ + "jsr:@std/data-structures" + ] + }, "@std/bytes@1.0.6": { "integrity": "f6ac6adbd8ccd99314045f5703e23af0a68d7f7e58364b47d2c7f408aeb5820a" }, "@std/crypto@1.0.5": { "integrity": "0dcfbb319fe0bba1bd3af904ceb4f948cde1b92979ec1614528380ed308a3b40" }, + "@std/data-structures@1.1.1": { + "integrity": "7e1fcf19771fb9c6dbcd7f65039d6d42ef8dc023cfbc0c4cdc74b24e09229380" + }, "@std/encoding@1.0.10": { "integrity": "8783c6384a2d13abd5e9e87a7ae0520a30e9f56aeeaa3bdf910a3eaaf5c811a1" }, @@ -72,6 +89,9 @@ "@std/internal@1.0.12": { "integrity": "972a634fd5bc34b242024402972cd5143eac68d8dffaca5eaa4dba30ce17b027" }, + "@std/internal@1.0.14": { + "integrity": "291516b3d4c35024d6ffbc0a9df5bf4c64116e05b50012cf846710152d2ffdf7" + }, "@std/media-types@1.1.0": { "integrity": "c9d093f0c05c3512932b330e3cc1fe1d627b301db33a4c2c2185c02471d6eaa4" }, @@ -101,6 +121,395 @@ "@adraffy/ens-normalize@1.11.1": { "integrity": "sha512-nhCBV3quEgesuf7c7KYfperqSS14T8bYuvJ8PcLJp6znkZpFc0AuW4qBtr8eKVyPPe/8RSr7sglCWPU5eaxwKQ==" }, + "@drizzle-team/brocli@0.10.2": { + "integrity": "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w==" + }, + "@esbuild-kit/core-utils@3.3.2": { + "integrity": "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==", + "dependencies": [ + "esbuild@0.18.20", + "source-map-support" + ], + "deprecated": true + }, + "@esbuild-kit/esm-loader@2.6.5": { + "integrity": "sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==", + "dependencies": [ + "@esbuild-kit/core-utils", + "get-tsconfig" + ], + "deprecated": true + }, + "@esbuild/aix-ppc64@0.25.12": { + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "os": ["aix"], + "cpu": ["ppc64"] + }, + "@esbuild/aix-ppc64@0.27.7": { + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "os": ["aix"], + "cpu": ["ppc64"] + }, + "@esbuild/android-arm64@0.18.20": { + "integrity": "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ==", + "os": ["android"], + "cpu": ["arm64"] + }, + "@esbuild/android-arm64@0.25.12": { + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "os": ["android"], + "cpu": ["arm64"] + }, + "@esbuild/android-arm64@0.27.7": { + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "os": ["android"], + "cpu": ["arm64"] + }, + "@esbuild/android-arm@0.18.20": { + "integrity": "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw==", + "os": ["android"], + "cpu": ["arm"] + }, + "@esbuild/android-arm@0.25.12": { + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "os": ["android"], + "cpu": ["arm"] + }, + "@esbuild/android-arm@0.27.7": { + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "os": ["android"], + "cpu": ["arm"] + }, + "@esbuild/android-x64@0.18.20": { + "integrity": "sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg==", + "os": ["android"], + "cpu": ["x64"] + }, + "@esbuild/android-x64@0.25.12": { + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "os": ["android"], + "cpu": ["x64"] + }, + "@esbuild/android-x64@0.27.7": { + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "os": ["android"], + "cpu": ["x64"] + }, + "@esbuild/darwin-arm64@0.18.20": { + "integrity": "sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA==", + "os": ["darwin"], + "cpu": ["arm64"] + }, + "@esbuild/darwin-arm64@0.25.12": { + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "os": ["darwin"], + "cpu": ["arm64"] + }, + "@esbuild/darwin-arm64@0.27.7": { + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "os": ["darwin"], + "cpu": ["arm64"] + }, + "@esbuild/darwin-x64@0.18.20": { + "integrity": "sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ==", + "os": ["darwin"], + "cpu": ["x64"] + }, + "@esbuild/darwin-x64@0.25.12": { + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "os": ["darwin"], + "cpu": ["x64"] + }, + "@esbuild/darwin-x64@0.27.7": { + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "os": ["darwin"], + "cpu": ["x64"] + }, + "@esbuild/freebsd-arm64@0.18.20": { + "integrity": "sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw==", + "os": ["freebsd"], + "cpu": ["arm64"] + }, + "@esbuild/freebsd-arm64@0.25.12": { + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "os": ["freebsd"], + "cpu": ["arm64"] + }, + "@esbuild/freebsd-arm64@0.27.7": { + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "os": ["freebsd"], + "cpu": ["arm64"] + }, + "@esbuild/freebsd-x64@0.18.20": { + "integrity": "sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ==", + "os": ["freebsd"], + "cpu": ["x64"] + }, + "@esbuild/freebsd-x64@0.25.12": { + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "os": ["freebsd"], + "cpu": ["x64"] + }, + "@esbuild/freebsd-x64@0.27.7": { + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "os": ["freebsd"], + "cpu": ["x64"] + }, + "@esbuild/linux-arm64@0.18.20": { + "integrity": "sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA==", + "os": ["linux"], + "cpu": ["arm64"] + }, + "@esbuild/linux-arm64@0.25.12": { + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "os": ["linux"], + "cpu": ["arm64"] + }, + "@esbuild/linux-arm64@0.27.7": { + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "os": ["linux"], + "cpu": ["arm64"] + }, + "@esbuild/linux-arm@0.18.20": { + "integrity": "sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg==", + "os": ["linux"], + "cpu": ["arm"] + }, + "@esbuild/linux-arm@0.25.12": { + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "os": ["linux"], + "cpu": ["arm"] + }, + "@esbuild/linux-arm@0.27.7": { + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "os": ["linux"], + "cpu": ["arm"] + }, + "@esbuild/linux-ia32@0.18.20": { + "integrity": "sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA==", + "os": ["linux"], + "cpu": ["ia32"] + }, + "@esbuild/linux-ia32@0.25.12": { + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "os": ["linux"], + "cpu": ["ia32"] + }, + "@esbuild/linux-ia32@0.27.7": { + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "os": ["linux"], + "cpu": ["ia32"] + }, + "@esbuild/linux-loong64@0.18.20": { + "integrity": "sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg==", + "os": ["linux"], + "cpu": ["loong64"] + }, + "@esbuild/linux-loong64@0.25.12": { + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "os": ["linux"], + "cpu": ["loong64"] + }, + "@esbuild/linux-loong64@0.27.7": { + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "os": ["linux"], + "cpu": ["loong64"] + }, + "@esbuild/linux-mips64el@0.18.20": { + "integrity": "sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ==", + "os": ["linux"], + "cpu": ["mips64el"] + }, + "@esbuild/linux-mips64el@0.25.12": { + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "os": ["linux"], + "cpu": ["mips64el"] + }, + "@esbuild/linux-mips64el@0.27.7": { + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "os": ["linux"], + "cpu": ["mips64el"] + }, + "@esbuild/linux-ppc64@0.18.20": { + "integrity": "sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA==", + "os": ["linux"], + "cpu": ["ppc64"] + }, + "@esbuild/linux-ppc64@0.25.12": { + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "os": ["linux"], + "cpu": ["ppc64"] + }, + "@esbuild/linux-ppc64@0.27.7": { + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "os": ["linux"], + "cpu": ["ppc64"] + }, + "@esbuild/linux-riscv64@0.18.20": { + "integrity": "sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A==", + "os": ["linux"], + "cpu": ["riscv64"] + }, + "@esbuild/linux-riscv64@0.25.12": { + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "os": ["linux"], + "cpu": ["riscv64"] + }, + "@esbuild/linux-riscv64@0.27.7": { + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "os": ["linux"], + "cpu": ["riscv64"] + }, + "@esbuild/linux-s390x@0.18.20": { + "integrity": "sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ==", + "os": ["linux"], + "cpu": ["s390x"] + }, + "@esbuild/linux-s390x@0.25.12": { + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "os": ["linux"], + "cpu": ["s390x"] + }, + "@esbuild/linux-s390x@0.27.7": { + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "os": ["linux"], + "cpu": ["s390x"] + }, + "@esbuild/linux-x64@0.18.20": { + "integrity": "sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w==", + "os": ["linux"], + "cpu": ["x64"] + }, + "@esbuild/linux-x64@0.25.12": { + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "os": ["linux"], + "cpu": ["x64"] + }, + "@esbuild/linux-x64@0.27.7": { + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "os": ["linux"], + "cpu": ["x64"] + }, + "@esbuild/netbsd-arm64@0.25.12": { + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "os": ["netbsd"], + "cpu": ["arm64"] + }, + "@esbuild/netbsd-arm64@0.27.7": { + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "os": ["netbsd"], + "cpu": ["arm64"] + }, + "@esbuild/netbsd-x64@0.18.20": { + "integrity": "sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A==", + "os": ["netbsd"], + "cpu": ["x64"] + }, + "@esbuild/netbsd-x64@0.25.12": { + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "os": ["netbsd"], + "cpu": ["x64"] + }, + "@esbuild/netbsd-x64@0.27.7": { + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "os": ["netbsd"], + "cpu": ["x64"] + }, + "@esbuild/openbsd-arm64@0.25.12": { + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "os": ["openbsd"], + "cpu": ["arm64"] + }, + "@esbuild/openbsd-arm64@0.27.7": { + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "os": ["openbsd"], + "cpu": ["arm64"] + }, + "@esbuild/openbsd-x64@0.18.20": { + "integrity": "sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg==", + "os": ["openbsd"], + "cpu": ["x64"] + }, + "@esbuild/openbsd-x64@0.25.12": { + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "os": ["openbsd"], + "cpu": ["x64"] + }, + "@esbuild/openbsd-x64@0.27.7": { + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "os": ["openbsd"], + "cpu": ["x64"] + }, + "@esbuild/openharmony-arm64@0.25.12": { + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "os": ["openharmony"], + "cpu": ["arm64"] + }, + "@esbuild/openharmony-arm64@0.27.7": { + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "os": ["openharmony"], + "cpu": ["arm64"] + }, + "@esbuild/sunos-x64@0.18.20": { + "integrity": "sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ==", + "os": ["sunos"], + "cpu": ["x64"] + }, + "@esbuild/sunos-x64@0.25.12": { + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "os": ["sunos"], + "cpu": ["x64"] + }, + "@esbuild/sunos-x64@0.27.7": { + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "os": ["sunos"], + "cpu": ["x64"] + }, + "@esbuild/win32-arm64@0.18.20": { + "integrity": "sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg==", + "os": ["win32"], + "cpu": ["arm64"] + }, + "@esbuild/win32-arm64@0.25.12": { + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "os": ["win32"], + "cpu": ["arm64"] + }, + "@esbuild/win32-arm64@0.27.7": { + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "os": ["win32"], + "cpu": ["arm64"] + }, + "@esbuild/win32-ia32@0.18.20": { + "integrity": "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g==", + "os": ["win32"], + "cpu": ["ia32"] + }, + "@esbuild/win32-ia32@0.25.12": { + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "os": ["win32"], + "cpu": ["ia32"] + }, + "@esbuild/win32-ia32@0.27.7": { + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "os": ["win32"], + "cpu": ["ia32"] + }, + "@esbuild/win32-x64@0.18.20": { + "integrity": "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ==", + "os": ["win32"], + "cpu": ["x64"] + }, + "@esbuild/win32-x64@0.25.12": { + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "os": ["win32"], + "cpu": ["x64"] + }, + "@esbuild/win32-x64@0.27.7": { + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "os": ["win32"], + "cpu": ["x64"] + }, "@noble/ciphers@1.3.0": { "integrity": "sha512-2I0gnIVPtfnMw9ee9h1dJG7tp81+8Ob3OJb3Mv37rx5L40/b0i7djjCVvGOVqc9AEIQyvyu1i6ypKdFw8R8gQw==" }, @@ -134,9 +543,137 @@ "abitype@1.2.3": { "integrity": "sha512-Ofer5QUnuUdTFsBRwARMoWKOH1ND5ehwYhJ3OJ/BQO+StkwQjHw0XyVh4vDttzHB7QOFhPHa/o413PJ82gU/Tg==" }, + "buffer-from@1.1.2": { + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==" + }, + "drizzle-kit@0.31.10": { + "integrity": "sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw==", + "dependencies": [ + "@drizzle-team/brocli", + "@esbuild-kit/esm-loader", + "esbuild@0.25.12", + "tsx" + ], + "bin": true + }, + "drizzle-orm@0.45.2_postgres@3.4.9": { + "integrity": "sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q==", + "dependencies": [ + "postgres" + ], + "optionalPeers": [ + "postgres" + ] + }, + "esbuild@0.18.20": { + "integrity": "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA==", + "optionalDependencies": [ + "@esbuild/android-arm@0.18.20", + "@esbuild/android-arm64@0.18.20", + "@esbuild/android-x64@0.18.20", + "@esbuild/darwin-arm64@0.18.20", + "@esbuild/darwin-x64@0.18.20", + "@esbuild/freebsd-arm64@0.18.20", + "@esbuild/freebsd-x64@0.18.20", + "@esbuild/linux-arm@0.18.20", + "@esbuild/linux-arm64@0.18.20", + "@esbuild/linux-ia32@0.18.20", + "@esbuild/linux-loong64@0.18.20", + "@esbuild/linux-mips64el@0.18.20", + "@esbuild/linux-ppc64@0.18.20", + "@esbuild/linux-riscv64@0.18.20", + "@esbuild/linux-s390x@0.18.20", + "@esbuild/linux-x64@0.18.20", + "@esbuild/netbsd-x64@0.18.20", + "@esbuild/openbsd-x64@0.18.20", + "@esbuild/sunos-x64@0.18.20", + "@esbuild/win32-arm64@0.18.20", + "@esbuild/win32-ia32@0.18.20", + "@esbuild/win32-x64@0.18.20" + ], + "scripts": true, + "bin": true + }, + "esbuild@0.25.12": { + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "optionalDependencies": [ + "@esbuild/aix-ppc64@0.25.12", + "@esbuild/android-arm@0.25.12", + "@esbuild/android-arm64@0.25.12", + "@esbuild/android-x64@0.25.12", + "@esbuild/darwin-arm64@0.25.12", + "@esbuild/darwin-x64@0.25.12", + "@esbuild/freebsd-arm64@0.25.12", + "@esbuild/freebsd-x64@0.25.12", + "@esbuild/linux-arm@0.25.12", + "@esbuild/linux-arm64@0.25.12", + "@esbuild/linux-ia32@0.25.12", + "@esbuild/linux-loong64@0.25.12", + "@esbuild/linux-mips64el@0.25.12", + "@esbuild/linux-ppc64@0.25.12", + "@esbuild/linux-riscv64@0.25.12", + "@esbuild/linux-s390x@0.25.12", + "@esbuild/linux-x64@0.25.12", + "@esbuild/netbsd-arm64@0.25.12", + "@esbuild/netbsd-x64@0.25.12", + "@esbuild/openbsd-arm64@0.25.12", + "@esbuild/openbsd-x64@0.25.12", + "@esbuild/openharmony-arm64@0.25.12", + "@esbuild/sunos-x64@0.25.12", + "@esbuild/win32-arm64@0.25.12", + "@esbuild/win32-ia32@0.25.12", + "@esbuild/win32-x64@0.25.12" + ], + "scripts": true, + "bin": true + }, + "esbuild@0.27.7": { + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "optionalDependencies": [ + "@esbuild/aix-ppc64@0.27.7", + "@esbuild/android-arm@0.27.7", + "@esbuild/android-arm64@0.27.7", + "@esbuild/android-x64@0.27.7", + "@esbuild/darwin-arm64@0.27.7", + "@esbuild/darwin-x64@0.27.7", + "@esbuild/freebsd-arm64@0.27.7", + "@esbuild/freebsd-x64@0.27.7", + "@esbuild/linux-arm@0.27.7", + "@esbuild/linux-arm64@0.27.7", + "@esbuild/linux-ia32@0.27.7", + "@esbuild/linux-loong64@0.27.7", + "@esbuild/linux-mips64el@0.27.7", + "@esbuild/linux-ppc64@0.27.7", + "@esbuild/linux-riscv64@0.27.7", + "@esbuild/linux-s390x@0.27.7", + "@esbuild/linux-x64@0.27.7", + "@esbuild/netbsd-arm64@0.27.7", + "@esbuild/netbsd-x64@0.27.7", + "@esbuild/openbsd-arm64@0.27.7", + "@esbuild/openbsd-x64@0.27.7", + "@esbuild/openharmony-arm64@0.27.7", + "@esbuild/sunos-x64@0.27.7", + "@esbuild/win32-arm64@0.27.7", + "@esbuild/win32-ia32@0.27.7", + "@esbuild/win32-x64@0.27.7" + ], + "scripts": true, + "bin": true + }, "eventemitter3@5.0.1": { "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==" }, + "fsevents@2.3.3": { + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "os": ["darwin"], + "scripts": true + }, + "get-tsconfig@4.14.0": { + "integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==", + "dependencies": [ + "resolve-pkg-maps" + ] + }, "isows@1.0.7_ws@8.20.0": { "integrity": "sha512-I1fSfDCZL5P0v33sVqeTDSpcstAg/N+wF5HS033mogOVIp4B+oHC7oOCsA3axAbBSGTJ8QubbNmnIRN/h8U7hg==", "dependencies": [ @@ -159,6 +696,33 @@ "path-to-regexp@6.3.0": { "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==" }, + "postgres@3.4.9": { + "integrity": "sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw==" + }, + "resolve-pkg-maps@1.0.0": { + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==" + }, + "source-map-support@0.5.21": { + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dependencies": [ + "buffer-from", + "source-map" + ] + }, + "source-map@0.6.1": { + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==" + }, + "tsx@4.21.0": { + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dependencies": [ + "esbuild@0.27.7", + "get-tsconfig" + ], + "optionalDependencies": [ + "fsevents" + ], + "bin": true + }, "ws@8.20.0": { "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==" } @@ -166,9 +730,13 @@ "workspace": { "dependencies": [ "jsr:@oak/oak@^17.2.0", - "jsr:@std/assert@1", + "jsr:@std/assert@^1.0.19", + "jsr:@std/async@^1.5.0", "jsr:@wevm/viem@^2.48.1", - "jsr:@zod/zod@^4.3.6" + "jsr:@zod/zod@^4.3.6", + "npm:drizzle-kit@~0.31.10", + "npm:drizzle-orm@~0.45.2", + "npm:postgres@^3.4.9" ] } } diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..84ae7a8 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,28 @@ +services: + db: + image: docker.io/postgres:18.3 + restart: always + environment: + POSTGRES_USER: user + POSTGRES_PASSWORD: password + POSTGRES_DB: tx_relay + ports: ["5432:5432"] + volumes: ["postgres_data:/var/lib/postgresql"] + healthcheck: + test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"] + interval: 5s + timeout: 5s + retries: 5 + anvil: + image: ghcr.io/foundry-rs/foundry:v1.8.1 + restart: always + entrypoint: ["anvil"] + command: ["--host", "0.0.0.0"] + ports: ["8545:8545"] + healthcheck: + test: ["CMD-SHELL", "cast chain-id"] + interval: 5s + timeout: 5s + retries: 5 +volumes: + postgres_data: diff --git a/drizzle.config.ts b/drizzle.config.ts new file mode 100644 index 0000000..d067751 --- /dev/null +++ b/drizzle.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "drizzle-kit"; +import { getDbUrl } from "./src/config.ts"; + +export default defineConfig({ + out: "./drizzle", + schema: "./src/db/schema.ts", + dialect: "postgresql", + casing: "snake_case", + dbCredentials: { url: getDbUrl() }, +}); diff --git a/drizzle/0000_equal_blacklash.sql b/drizzle/0000_equal_blacklash.sql new file mode 100644 index 0000000..e854fac --- /dev/null +++ b/drizzle/0000_equal_blacklash.sql @@ -0,0 +1,77 @@ +CREATE TYPE "public"."burst_state" AS ENUM('pending', 'success', 'failure');--> statement-breakpoint +CREATE TYPE "public"."event_kind" AS ENUM('created', 'rejected', 'submitted', 'executed', 'skipped');--> statement-breakpoint +CREATE TYPE "public"."tx_state" AS ENUM('pending', 'success', 'reverted', 'skipped');--> statement-breakpoint +CREATE TABLE "bursts" ( + "id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "bursts_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1), + "sequence_id" uuid NOT NULL, + "idx_in_sequence" integer NOT NULL, + "gas_buffer_percent" integer, + "state" "burst_state" DEFAULT 'pending' NOT NULL +); +--> statement-breakpoint +CREATE TABLE "calls" ( + "id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "calls_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1), + "burst_id" integer NOT NULL, + "target" "bytea" NOT NULL, + "calldata" "bytea" NOT NULL, + "gas" numeric(78, 0) +); +--> statement-breakpoint +CREATE TABLE "sequence_events" ( + "id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "sequence_events_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1), + "sequence_id" uuid NOT NULL, + "timestamp" timestamp with time zone DEFAULT now() NOT NULL, + "kind" "event_kind" NOT NULL, + "details" jsonb NOT NULL +); +--> statement-breakpoint +CREATE TABLE "sequences" ( + "id" uuid PRIMARY KEY DEFAULT uuidv7() NOT NULL, + "chain_id" integer NOT NULL +); +--> statement-breakpoint +CREATE TABLE "tx_payload_bursts" ( + "id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "tx_payload_bursts_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1), + "tx_payload_id" integer NOT NULL, + "burst_id" integer NOT NULL, + "inclusion_gas" numeric(78, 0) NOT NULL +); +--> statement-breakpoint +CREATE TABLE "tx_payloads" ( + "id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "tx_payloads_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1), + "tx_sender_id" integer NOT NULL, + "target" "bytea" NOT NULL, + "calldata" "bytea" DEFAULT '0x' NOT NULL, + "gas" numeric(78, 0) +); +--> statement-breakpoint +CREATE TABLE "tx_senders" ( + "id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "tx_senders_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1), + "address" "bytea" NOT NULL, + "nonce" integer NOT NULL, + "chain_id" integer NOT NULL, + CONSTRAINT "tx_senders_address_nonce_chainId_unique" UNIQUE("address","nonce","chain_id") +); +--> statement-breakpoint +CREATE TABLE "txs" ( + "id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "txs_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1), + "tx_hash" "bytea" NOT NULL, + "tx_payload_id" integer NOT NULL, + "state" "tx_state" DEFAULT 'pending' NOT NULL, + CONSTRAINT "txs_txHash_unique" UNIQUE("tx_hash") +); +--> statement-breakpoint +ALTER TABLE "bursts" ADD CONSTRAINT "bursts_sequence_id_sequences_id_fk" FOREIGN KEY ("sequence_id") REFERENCES "public"."sequences"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "calls" ADD CONSTRAINT "calls_burst_id_bursts_id_fk" FOREIGN KEY ("burst_id") REFERENCES "public"."bursts"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "sequence_events" ADD CONSTRAINT "sequence_events_sequence_id_sequences_id_fk" FOREIGN KEY ("sequence_id") REFERENCES "public"."sequences"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "tx_payload_bursts" ADD CONSTRAINT "tx_payload_bursts_tx_payload_id_tx_payloads_id_fk" FOREIGN KEY ("tx_payload_id") REFERENCES "public"."tx_payloads"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "tx_payload_bursts" ADD CONSTRAINT "tx_payload_bursts_burst_id_bursts_id_fk" FOREIGN KEY ("burst_id") REFERENCES "public"."bursts"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "tx_payloads" ADD CONSTRAINT "tx_payloads_tx_sender_id_tx_senders_id_fk" FOREIGN KEY ("tx_sender_id") REFERENCES "public"."tx_senders"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "txs" ADD CONSTRAINT "txs_tx_payload_id_tx_payloads_id_fk" FOREIGN KEY ("tx_payload_id") REFERENCES "public"."tx_payloads"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "bursts_sequence_id_idx" ON "bursts" USING btree ("sequence_id");--> statement-breakpoint +CREATE INDEX "bursts_state_idx" ON "bursts" USING btree ("state") WHERE "bursts"."state" = 'pending';--> statement-breakpoint +CREATE INDEX "calls_burst_ids_idx" ON "calls" USING btree ("burst_id");--> statement-breakpoint +CREATE INDEX "sequence_events_sequence_id_idx" ON "sequence_events" USING btree ("sequence_id");--> statement-breakpoint +CREATE INDEX "tx_payload_bursts_tx_payload_id_idx" ON "tx_payload_bursts" USING btree ("tx_payload_id");--> statement-breakpoint +CREATE INDEX "tx_payloads_tx_sender_id_idx" ON "tx_payloads" USING btree ("tx_sender_id");--> statement-breakpoint +CREATE INDEX "txs_state_idx" ON "txs" USING btree ("state") WHERE "txs"."state" = 'pending'; \ No newline at end of file diff --git a/drizzle/meta/0000_snapshot.json b/drizzle/meta/0000_snapshot.json new file mode 100644 index 0000000..3ece323 --- /dev/null +++ b/drizzle/meta/0000_snapshot.json @@ -0,0 +1,671 @@ +{ + "id": "4a79efd8-fabb-4706-8e52-880b853cc992", + "prevId": "00000000-0000-0000-0000-000000000000", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.bursts": { + "name": "bursts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "bursts_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "sequence_id": { + "name": "sequence_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "idx_in_sequence": { + "name": "idx_in_sequence", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "gas_buffer_percent": { + "name": "gas_buffer_percent", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "burst_state", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + } + }, + "indexes": { + "bursts_sequence_id_idx": { + "name": "bursts_sequence_id_idx", + "columns": [ + { + "expression": "sequence_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "bursts_state_idx": { + "name": "bursts_state_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"bursts\".\"state\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "bursts_sequence_id_sequences_id_fk": { + "name": "bursts_sequence_id_sequences_id_fk", + "tableFrom": "bursts", + "tableTo": "sequences", + "columnsFrom": [ + "sequence_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.calls": { + "name": "calls", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "calls_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "burst_id": { + "name": "burst_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "target": { + "name": "target", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "calldata": { + "name": "calldata", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "gas": { + "name": "gas", + "type": "numeric(78, 0)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "calls_burst_ids_idx": { + "name": "calls_burst_ids_idx", + "columns": [ + { + "expression": "burst_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "calls_burst_id_bursts_id_fk": { + "name": "calls_burst_id_bursts_id_fk", + "tableFrom": "calls", + "tableTo": "bursts", + "columnsFrom": [ + "burst_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sequence_events": { + "name": "sequence_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "sequence_events_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "sequence_id": { + "name": "sequence_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "timestamp": { + "name": "timestamp", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "kind": { + "name": "kind", + "type": "event_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "details": { + "name": "details", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "sequence_events_sequence_id_idx": { + "name": "sequence_events_sequence_id_idx", + "columns": [ + { + "expression": "sequence_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sequence_events_sequence_id_sequences_id_fk": { + "name": "sequence_events_sequence_id_sequences_id_fk", + "tableFrom": "sequence_events", + "tableTo": "sequences", + "columnsFrom": [ + "sequence_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sequences": { + "name": "sequences", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "uuidv7()" + }, + "chain_id": { + "name": "chain_id", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tx_payload_bursts": { + "name": "tx_payload_bursts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "tx_payload_bursts_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "tx_payload_id": { + "name": "tx_payload_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "burst_id": { + "name": "burst_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "inclusion_gas": { + "name": "inclusion_gas", + "type": "numeric(78, 0)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "tx_payload_bursts_tx_payload_id_idx": { + "name": "tx_payload_bursts_tx_payload_id_idx", + "columns": [ + { + "expression": "tx_payload_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tx_payload_bursts_tx_payload_id_tx_payloads_id_fk": { + "name": "tx_payload_bursts_tx_payload_id_tx_payloads_id_fk", + "tableFrom": "tx_payload_bursts", + "tableTo": "tx_payloads", + "columnsFrom": [ + "tx_payload_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "tx_payload_bursts_burst_id_bursts_id_fk": { + "name": "tx_payload_bursts_burst_id_bursts_id_fk", + "tableFrom": "tx_payload_bursts", + "tableTo": "bursts", + "columnsFrom": [ + "burst_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tx_payloads": { + "name": "tx_payloads", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "tx_payloads_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "tx_sender_id": { + "name": "tx_sender_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "target": { + "name": "target", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "calldata": { + "name": "calldata", + "type": "bytea", + "primaryKey": false, + "notNull": true, + "default": "'0x'" + }, + "gas": { + "name": "gas", + "type": "numeric(78, 0)", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "tx_payloads_tx_sender_id_idx": { + "name": "tx_payloads_tx_sender_id_idx", + "columns": [ + { + "expression": "tx_sender_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tx_payloads_tx_sender_id_tx_senders_id_fk": { + "name": "tx_payloads_tx_sender_id_tx_senders_id_fk", + "tableFrom": "tx_payloads", + "tableTo": "tx_senders", + "columnsFrom": [ + "tx_sender_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tx_senders": { + "name": "tx_senders", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "tx_senders_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "address": { + "name": "address", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "nonce": { + "name": "nonce", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "chain_id": { + "name": "chain_id", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "tx_senders_address_nonce_chainId_unique": { + "name": "tx_senders_address_nonce_chainId_unique", + "nullsNotDistinct": false, + "columns": [ + "address", + "nonce", + "chain_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.txs": { + "name": "txs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "txs_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "tx_hash": { + "name": "tx_hash", + "type": "bytea", + "primaryKey": false, + "notNull": true + }, + "tx_payload_id": { + "name": "tx_payload_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "tx_state", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + } + }, + "indexes": { + "txs_state_idx": { + "name": "txs_state_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"txs\".\"state\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "txs_tx_payload_id_tx_payloads_id_fk": { + "name": "txs_tx_payload_id_tx_payloads_id_fk", + "tableFrom": "txs", + "tableTo": "tx_payloads", + "columnsFrom": [ + "tx_payload_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "txs_txHash_unique": { + "name": "txs_txHash_unique", + "nullsNotDistinct": false, + "columns": [ + "tx_hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.burst_state": { + "name": "burst_state", + "schema": "public", + "values": [ + "pending", + "success", + "failure" + ] + }, + "public.event_kind": { + "name": "event_kind", + "schema": "public", + "values": [ + "created", + "rejected", + "submitted", + "executed", + "skipped" + ] + }, + "public.tx_state": { + "name": "tx_state", + "schema": "public", + "values": [ + "pending", + "success", + "reverted", + "skipped" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json new file mode 100644 index 0000000..5a4e042 --- /dev/null +++ b/drizzle/meta/_journal.json @@ -0,0 +1,13 @@ +{ + "version": "7", + "dialect": "postgresql", + "entries": [ + { + "idx": 0, + "version": "7", + "when": 1788212710152, + "tag": "0000_equal_blacklash", + "breakpoints": true + } + ] +} diff --git a/main.ts b/main.ts deleted file mode 100644 index ea7fdaa..0000000 --- a/main.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { Application, Router } from "oak"; -import { z } from "zod"; -import { Hex, isHex } from "viem"; - -import { getMulticall3s, getPort } from "./config.ts"; - -const multicall3s = getMulticall3s(); - -const sendSchema = z.object({ - calls: z.array(z.object({ - target: z.string().refine(isHex), - calldata: z.string().refine(isHex), - })).nonempty(), -}); - -const router = new Router(); -router - .post("/:chainId/send", async (context) => { - const multicall3 = multicall3s[Number(context.params.chainId)]; - if (!multicall3) { - context.response.status = 404; - context.response.body = "Unsupported chain ID"; - return; - } - - let sendArg: z.infer; - try { - sendArg = sendSchema.parse(await context.request.body.json()); - } catch (error) { - context.response.status = 400; - context.response.body = String(error); - return; - } - - const calls = sendArg.calls.map(({ target, calldata }) => ({ - target: target as Hex, - callData: calldata as Hex, - allowFailure: false, - })); - - let txHash: Hex; - try { - txHash = await multicall3.write.aggregate3([calls]); - } catch (error) { - context.response.status = 500; - context.response.body = String(error); - return; - } - - context.response.body = { txHash }; - }); - -await new Application() - .use(router.routes()) - .use(router.allowedMethods()) - .listen({ port: getPort(), hostname: "[::]" }); diff --git a/src/app.ts b/src/app.ts new file mode 100644 index 0000000..9792b9b --- /dev/null +++ b/src/app.ts @@ -0,0 +1,250 @@ +import { Application, Context, Router } from "oak"; +import { z } from "zod"; +import { Address, Hex, isAddress, isHex } from "viem"; +import { eq, inArray, sql } from "drizzle-orm"; +import { type PostgresJsDatabase } from "drizzle-orm/postgres-js"; +import { + burstsTable, + callsTable, + dbValueToSequenceEvent, + sequenceEventSchema, + sequenceEventsTable, + sequenceEventToDbValue, + sequencesTable, +} from "./db/schema.ts"; +import { WorkerHealth } from "./worker.ts"; + +export const healthSchema = z.object({ + runningSince: z.coerce.date(), + workers: z.array(z.object({ + name: z.string(), + runningSince: z.coerce.date().nullable(), + })), +}); +export type Health = z.infer; + +export const sendSequencesArgSchema = z.object({ + sequences: z.array(z.object({ + chainId: z.number().int().positive(), + bursts: z.array(z.object({ + gasBufferPercent: z.number().int().nonnegative().optional(), + calls: z.array(z.object({ + target: z.custom
().refine(isAddress, "Not an address"), + calldata: z.custom().refine((s) => isHex(s) && s.length % 2 == 0, "Not a hex value"), + gas: z.number().int().positive().optional(), + })).nonempty(), + })).nonempty(), + })).nonempty(), +}); +export type SendSequencesArg = z.infer; + +export const sendSequencesSchema = z.object({ + sequences: z.array(z.object({ + id: z.uuid(), + })).nonempty(), +}); +export type SendSequences = z.infer; + +export const sequencesStatesArgSchema = z.object({ + sequences: z.array(z.object({ + id: z.uuid(), + })).nonempty().max(1_000), +}); +export type SequencesStatesArg = z.infer; + +export const sequencesStatesSchema = z.object({ + sequences: z.array(z.object({ + id: z.uuid(), + pending: z.number().int().nonnegative(), + successes: z.number().int().nonnegative(), + failures: z.number().int().nonnegative(), + events: z.array(z.intersection(sequenceEventSchema, z.object({ timestamp: z.coerce.date() }))), + })).nonempty(), +}); +export type SequencesStates = z.infer; + +export const sequencesConfigsArgSchema = z.object({ + sequences: z.array(z.object({ + id: z.uuid(), + })).nonempty().max(1_000), +}); +export type SequencesConfigsArg = z.infer; + +export const sequencesConfigsSchema = sendSequencesArgSchema; +export type SequencesConfigs = z.infer; + +async function parseJsonArg( + context: Context, + schema: S, +): Promise> { + try { + return schema.parse(await context.request.body.json()); + } catch (error) { + const message = error instanceof z.ZodError + ? error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("\n") + : String(error); + context.throw(400, message); + } +} + +export function createApp( + db: PostgresJsDatabase, + chainIds: Set, + workersHealth: WorkerHealth[], +): Application { + const runningSince = new Date(); + const router = new Router(); + router + .get("/health", (context) => { + context.response.body = { + runningSince, + workers: workersHealth.map(({ name, runningSince }) => ({ name, runningSince })), + } satisfies Health; + }) + .post("/send-sequences", async (context) => { + const arg = await parseJsonArg(context, sendSequencesArgSchema); + + let callsCount = 0; + for (const { chainId, bursts } of arg.sequences) { + if (!chainIds.has(chainId)) context.throw(400, "Unsupported chain ID " + chainId); + for (const { calls } of bursts) { + callsCount += calls.length; + } + } + // Each Postgres request can accept up to 65_533 arguments. + // All calls are inserted in a single request, with each call requiring 4 arguments, + // so the limit of 16_000 calls caps the largest possible request at 64_000 arguments. + // All other inserts in this route use fewer arguments per row + // and their row counts never exceed the number of calls. + if (callsCount > 16_000) { + context.throw(400, "More than 16000 calls in a single request, got " + callsCount); + } + + await db.transaction(async (dbTx) => { + const sequenceIds = await dbTx.insert(sequencesTable) + .values(arg.sequences.map(({ chainId }) => ({ chainId }))) + .returning({ sequenceId: sequencesTable.id }); + + const burstValues = arg.sequences.flatMap(({ bursts }, sequenceIdx) => { + const { sequenceId } = sequenceIds[sequenceIdx]!; + return bursts.map(({ gasBufferPercent }, idxInSequence) => ({ + sequenceId, + idxInSequence, + gasBufferPercent, + })); + }); + const burstIds = await dbTx.insert(burstsTable) + .values(burstValues) + .returning({ burstId: burstsTable.id }); + + const callValues = arg.sequences.flatMap(({ bursts }) => bursts) + .flatMap(({ calls }, burstIdx) => { + const { burstId } = burstIds[burstIdx]; + return calls.map(({ target, calldata, gas }) => ( + { burstId, target, calldata, gas: gas === undefined ? null : BigInt(gas) } + )); + }); + await dbTx.insert(callsTable).values(callValues); + + const eventValues = arg.sequences.map(({ bursts }, sequenceIdx) => + sequenceEventToDbValue(sequenceIds[sequenceIdx]!.sequenceId, { + kind: "created", + details: { burstsCount: bursts.length }, + }) + ); + await dbTx.insert(sequenceEventsTable).values(eventValues); + + context.response.body = { + sequences: sequenceIds.map(({ sequenceId }) => ({ id: sequenceId })), + } satisfies SendSequences; + }); + }) + .post("/sequences-states", async (context) => { + const arg = await parseJsonArg(context, sequencesStatesArgSchema); + const sequenceIds = arg.sequences.map(({ id }) => id); + + await db.transaction(async (dbTx) => { + const statesRows = await dbTx.select({ + sequenceId: burstsTable.sequenceId, + pending: sql`count(*) filter (where ${burstsTable.state} = 'pending')::int`, + successes: sql`count(*) filter (where ${burstsTable.state} = 'success')::int`, + failures: sql`count(*) filter (where ${burstsTable.state} = 'failure')::int`, + }) + .from(burstsTable) + .where(inArray(burstsTable.sequenceId, sequenceIds)) + .groupBy(burstsTable.sequenceId); + const statesById = Object.fromEntries(statesRows.map((row) => [row.sequenceId, row])); + + const unknownIds = sequenceIds.filter((id) => !statesById[id]); + if (unknownIds.length) { + context.throw(404, "Unknown sequence IDs: " + unknownIds.join(", ")); + } + + const eventRows = await dbTx.select({ + sequenceId: sequenceEventsTable.sequenceId, + timestamp: sequenceEventsTable.timestamp, + kind: sequenceEventsTable.kind, + details: sequenceEventsTable.details, + }).from(sequenceEventsTable) + .where(inArray(sequenceEventsTable.sequenceId, sequenceIds)) + .orderBy(sequenceEventsTable.id); + const eventsById = Object.groupBy(eventRows, (row) => row.sequenceId); + + const sequences = sequenceIds.map((id) => { + const { pending, successes, failures } = statesById[id]; + const events = (eventsById[id] ?? []) + .map(({ timestamp, kind, details }) => ({ + timestamp, + ...dbValueToSequenceEvent(kind, details), + })); + return { id, pending, successes, failures, events }; + }); + context.response.body = { sequences } satisfies SequencesStates; + }); + }) + .post("/sequences-configs", async (context) => { + const arg = await parseJsonArg(context, sequencesConfigsArgSchema); + const sequenceIds = arg.sequences.map(({ id }) => id); + + const callRows = await db.select({ + sequenceId: sequencesTable.id, + chainId: sequencesTable.chainId, + burstId: burstsTable.id, + gasBufferPercent: burstsTable.gasBufferPercent, + target: callsTable.target, + calldata: callsTable.calldata, + gas: callsTable.gas, + }) + .from(callsTable) + .innerJoin(burstsTable, eq(burstsTable.id, callsTable.burstId)) + .innerJoin(sequencesTable, eq(sequencesTable.id, burstsTable.sequenceId)) + .where(inArray(sequencesTable.id, sequenceIds)) + .orderBy(burstsTable.id, callsTable.id); + + const callsBySequenceId = Object.groupBy(callRows, (row) => row.sequenceId); + const unknownIds = sequenceIds.filter((id) => !callsBySequenceId[id]); + if (unknownIds.length) { + context.throw(404, "Unknown sequence IDs: " + unknownIds.join(", ")); + } + + const sequences = sequenceIds.map((sequenceId) => { + const calls = callsBySequenceId[sequenceId]!; + const bursts: SequencesConfigs["sequences"][number]["bursts"] = []; + let lastBurstId: number | undefined; + for (const { burstId, gasBufferPercent, target, calldata, gas } of calls) { + if (burstId !== lastBurstId) { + bursts.push({ gasBufferPercent: gasBufferPercent ?? undefined, calls: [] }); + } + const call = { target, calldata, gas: gas === null ? undefined : Number(gas) }; + bursts.at(-1)!.calls.push(call); + lastBurstId = burstId; + } + return { chainId: calls[0].chainId, bursts }; + }); + context.response.body = { sequences } satisfies SequencesConfigs; + }); + + return new Application() + .use(router.routes()) + .use(router.allowedMethods()); +} diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..f24d077 --- /dev/null +++ b/src/config.ts @@ -0,0 +1,113 @@ +import { z } from "zod"; +import { + Chain, + createWalletClient, + fallback, + Hex, + http, + isHex, + PrivateKeyAccount, + publicActions, + PublicClient, + Transport, + WalletClient, +} from "viem"; +import { privateKeyToAccount } from "viem/accounts"; +import * as viemChains from "viem/chains"; + +const configSchema = z.object({ + wallets: z.array(z.object({ + privateKey: z.custom((s) => isHex(s) && s.length === 66, "Not a 32-byte hex value"), + chains: z.array(z.object({ + chainId: z.number().int().positive(), + rpcUrls: z.array(z.url()).default([]), + confirmations: z.number().int().positive().optional(), + minGasIncreasePercent: z.number().int().nonnegative().optional(), + inclusionWaitBlocks: z.number().int().positive().optional(), + })).nonempty(), + })).nonempty(), +}); + +const testConfigSchema = z.object({ + workerRestartDelayMs: z.number().nonnegative().optional(), + sendNextBatchMinRetryDelayMs: z.number().nonnegative().optional(), + delayUntilBlockNumberPollingIntervalMs: z.number().nonnegative().optional(), + waitForBalanceRetryDelayMs: z.number().nonnegative().optional(), + burnNonceDelayInitialMs: z.number().nonnegative().optional(), + burnNonceDelayMultiplier: z.number().nonnegative().optional(), + burnNonceDelayMaxMs: z.number().nonnegative().optional(), +}); + +export type Config = { + dbUrl: string; + port: number; + chainConfigs: ChainConfig[]; +}; + +export type ChainConfig = { + client: Client; + confirmations: number; + minGasIncreasePercent: number; + inclusionWaitBlocks: number; + workerRestartDelayMs: number; + sendNextBatchMinRetryDelayMs: number; + delayUntilBlockNumberPollingIntervalMs: number; + waitForBalanceRetryDelayMs: number; + burnNonceDelayInitialMs: number; + burnNonceDelayMultiplier: number; + burnNonceDelayMaxMs: number; +}; + +export type Client = + & WalletClient + & PublicClient; + +export function getConfig(): Config { + const dbUrl = getDbUrl(); + const port = z.coerce.number().int().positive().default(8000).parse(Deno.env.get("PORT")); + const config = configSchema.parse(JSON.parse(Deno.env.get("CONFIG") ?? "{}")); + const testConfig = testConfigSchema.parse(JSON.parse(Deno.env.get("TEST_CONFIG") ?? "{}")); + + const chains = Object.entries(viemChains) + // Sort by chain names used in Viem as keys, lexically descending + .sort(([chainA], [chainB]) => -chainA.localeCompare(chainB)) + // Resolve duplicate chain IDs by keeping the one from the lexically first key e.g. suffix-free. + .reduce((map, [, chain]) => map.set(chain.id, chain), new Map()); + + const chainIds = new Set(); + const chainConfigs: ChainConfig[] = []; + for (const wallet of config.wallets) { + const account = privateKeyToAccount(wallet.privateKey); + for (const { chainId, rpcUrls, ...config } of wallet.chains) { + if (chainIds.has(chainId)) throw new Error("Duplicate wallets for chain ID " + chainId); + chainIds.add(chainId); + + const chain = chains.get(chainId); + if (!chain) throw new Error("Unknown wallet chain ID " + chainId); + const transport = fallback(rpcUrls.length ? rpcUrls.map((url) => http(url)) : [http()]); + const client = createWalletClient({ account, chain, transport, cacheTime: 0 }) + .extend(publicActions); + + chainConfigs.push({ + client, + confirmations: config.confirmations ?? 1, + minGasIncreasePercent: config.minGasIncreasePercent ?? 10, + inclusionWaitBlocks: config.inclusionWaitBlocks ?? 5, + workerRestartDelayMs: testConfig.workerRestartDelayMs ?? 30_000, + sendNextBatchMinRetryDelayMs: testConfig.sendNextBatchMinRetryDelayMs ?? 1_000, + delayUntilBlockNumberPollingIntervalMs: testConfig.delayUntilBlockNumberPollingIntervalMs ?? + 2_000, + waitForBalanceRetryDelayMs: testConfig.waitForBalanceRetryDelayMs ?? 10_000, + burnNonceDelayInitialMs: testConfig.burnNonceDelayInitialMs ?? 1_000, + burnNonceDelayMultiplier: testConfig.burnNonceDelayMultiplier ?? 10, + burnNonceDelayMaxMs: testConfig.burnNonceDelayMaxMs ?? 60_000, + }); + } + } + return { dbUrl, port, chainConfigs }; +} + +export function getDbUrl(): string { + return z.url().default("postgres://user:password@localhost:5432/tx_relay") + .parse(Deno.env.get("DB_URL")); +} diff --git a/src/db/schema.ts b/src/db/schema.ts new file mode 100644 index 0000000..89b5d84 --- /dev/null +++ b/src/db/schema.ts @@ -0,0 +1,167 @@ +import { sql } from "drizzle-orm"; +import { + customType, + index, + integer, + jsonb, + numeric, + pgEnum, + pgTable, + timestamp, + unique, + uuid, +} from "drizzle-orm/pg-core"; +import { type Address, bytesToHex, getAddress, type Hex, hexToBytes } from "viem"; +import { z } from "zod"; + +const bytea = customType<{ data: Hex; driverData: Uint8Array }>({ + dataType: () => "bytea", + toDriver: hexToBytes, + fromDriver: bytesToHex, +}); + +const address = customType<{ data: Address; driverData: Uint8Array }>({ + dataType: () => "bytea", + toDriver: (address) => hexToBytes(getAddress(address)), + fromDriver: (bytes) => getAddress(bytesToHex(bytes)), +}); + +const uint256 = () => numeric({ precision: 78, scale: 0, mode: "bigint" }); + +// Inserted when a sequence is queued for execution. +export const sequencesTable = pgTable("sequences", { + id: uuid().primaryKey().default(sql`uuidv7()`), + chainId: integer().notNull(), +}); + +export const burstStateEnum = pgEnum("burst_state", ["pending", "success", "failure"]); + +// Inserted when a sequence is queued for execution. 1 row per burst in a sequence. +export const burstsTable = pgTable("bursts", { + id: integer().primaryKey().generatedAlwaysAsIdentity(), + sequenceId: uuid().notNull().references(() => sequencesTable.id), + idxInSequence: integer().notNull(), + gasBufferPercent: integer(), + state: burstStateEnum().notNull().default("pending"), +}, (table) => [ + index("bursts_sequence_id_idx").on(table.sequenceId), + index("bursts_state_idx").on(table.state).where(sql`${table.state} = 'pending'`), +]); + +// Inserted when a sequence is queued for execution. 1 row per call in a burst. +export const callsTable = pgTable("calls", { + id: integer().primaryKey().generatedAlwaysAsIdentity(), + burstId: integer().notNull().references(() => burstsTable.id), + target: address().notNull(), + calldata: bytea().notNull(), + gas: uint256(), +}, (table) => [index("calls_burst_ids_idx").on(table.burstId)]); + +export const sequenceEventKindEnum = pgEnum("event_kind", [ + "created", + "rejected", + "submitted", + "executed", + "skipped", +]); + +// Inserted when a sequence event occurs. +export const sequenceEventsTable = pgTable("sequence_events", { + id: integer().primaryKey().generatedAlwaysAsIdentity(), + sequenceId: uuid().notNull().references(() => sequencesTable.id), + timestamp: timestamp({ withTimezone: true }).notNull().defaultNow(), + kind: sequenceEventKindEnum().notNull(), + details: jsonb().notNull(), +}, (table) => [index("sequence_events_sequence_id_idx").on(table.sequenceId)]); + +const sequenceEventCreatedSchema = z.object({ + kind: z.literal("created"), + details: z.object({ burstsCount: z.number() }), +}); + +const sequenceEventRejectedSchema = z.object({ + kind: z.literal("rejected"), + details: z.object({ fromIdxInSequence: z.number() }), +}); + +const sequenceEventSubmittedSchema = z.object({ + kind: z.literal("submitted"), + details: z.object({ fromIdxInSequence: z.number(), burstsCount: z.number() }), +}); + +const sequenceEventExecutedSchema = z.object({ + kind: z.literal("executed"), + details: z.object({ fromIdxInSequence: z.number(), successes: z.number(), failed: z.boolean() }), +}); + +const sequenceEventSkippedSchema = z.object({ + kind: z.literal("skipped"), + details: z.object({}), +}); + +export const sequenceEventSchema = z.discriminatedUnion("kind", [ + sequenceEventCreatedSchema, + sequenceEventRejectedSchema, + sequenceEventSubmittedSchema, + sequenceEventExecutedSchema, + sequenceEventSkippedSchema, +]); + +export type SequenceEvent = z.infer; +export type SequenceEventKindEnum = (typeof sequenceEventKindEnum.enumValues)[number]; + +export function sequenceEventToDbValue( + sequenceId: string, + { kind, details }: SequenceEvent, +): { sequenceId: string; kind: SequenceEventKindEnum; details: unknown } { + return { sequenceId, kind, details }; +} + +export function dbValueToSequenceEvent( + kind: SequenceEventKindEnum, + details: unknown, +): SequenceEvent { + return sequenceEventSchema.parse({ kind, details }); +} + +// Inserted when a new transaction sender is prepared to send its first transaction. +// The sender is considered new when it uses a previously unused nonce on the given chain. +export const txSendersTable = pgTable( + "tx_senders", + { + id: integer().primaryKey().generatedAlwaysAsIdentity(), + address: address().notNull(), + nonce: integer().notNull(), + chainId: integer().notNull(), + }, + (table) => [unique().on(table.address, table.nonce, table.chainId)], +); + +export const txStateEnum = pgEnum("tx_state", ["pending", "success", "reverted", "skipped"]); + +// Inserted when a signed transaction is published. +export const txsTable = pgTable("txs", { + id: integer().primaryKey().generatedAlwaysAsIdentity(), + txHash: bytea().notNull().unique(), + txPayloadId: integer().notNull().references(() => txPayloadsTable.id), + state: txStateEnum().notNull().default("pending"), +}, (table) => [ + index("txs_state_idx").on(table.state).where(sql`${table.state} = 'pending'`), +]); + +// Inserted when a new payload for transactions is created. +export const txPayloadsTable = pgTable("tx_payloads", { + id: integer().primaryKey().generatedAlwaysAsIdentity(), + txSenderId: integer().notNull().references(() => txSendersTable.id), + target: address().notNull(), + calldata: bytea().notNull().default("0x"), + gas: uint256(), +}, (table) => [index("tx_payloads_tx_sender_id_idx").on(table.txSenderId)]); + +// Inserted when a batch is created, 1 row per burst in a batch. +export const txPayloadBurstsTable = pgTable("tx_payload_bursts", { + id: integer().primaryKey().generatedAlwaysAsIdentity(), + txPayloadId: integer().notNull().references(() => txPayloadsTable.id), + burstId: integer().notNull().references(() => burstsTable.id), + inclusionGas: uint256().notNull(), +}, (table) => [index("tx_payload_bursts_tx_payload_id_idx").on(table.txPayloadId)]); diff --git a/src/executor.generated.ts b/src/executor.generated.ts new file mode 100644 index 0000000..cc22cc9 --- /dev/null +++ b/src/executor.generated.ts @@ -0,0 +1,438 @@ +// DO NOT EDIT, code generated with task `generate-contracts` +import type { Abi, Hex } from "viem"; + +const artifact = { + "abi": [ + { + "type": "function", + "name": "exec", + "inputs": [ + { + "name": "bursts", + "type": "tuple[]", + "internalType": "struct Burst[]", + "components": [ + { + "name": "needsPrev", + "type": "bool", + "internalType": "bool", + }, + { + "name": "gas", + "type": "uint256", + "internalType": "uint256", + }, + { + "name": "calls", + "type": "tuple[]", + "internalType": "struct Call[]", + "components": [ + { + "name": "target", + "type": "address", + "internalType": "address", + }, + { + "name": "data", + "type": "bytes", + "internalType": "bytes", + }, + { + "name": "gas", + "type": "uint256", + "internalType": "uint256", + }, + ], + }, + ], + }, + ], + "outputs": [ + { + "name": "gasReport", + "type": "int256[]", + "internalType": "int256[]", + }, + ], + "stateMutability": "nonpayable", + }, + { + "type": "function", + "name": "execNext", + "inputs": [ + { + "name": "bursts", + "type": "tuple[]", + "internalType": "struct Burst[]", + "components": [ + { + "name": "needsPrev", + "type": "bool", + "internalType": "bool", + }, + { + "name": "gas", + "type": "uint256", + "internalType": "uint256", + }, + { + "name": "calls", + "type": "tuple[]", + "internalType": "struct Call[]", + "components": [ + { + "name": "target", + "type": "address", + "internalType": "address", + }, + { + "name": "data", + "type": "bytes", + "internalType": "bytes", + }, + { + "name": "gas", + "type": "uint256", + "internalType": "uint256", + }, + ], + }, + ], + }, + { + "name": "burstsGas", + "type": "uint256", + "internalType": "uint256", + }, + { + "name": "nextBurstCalls", + "type": "tuple[]", + "internalType": "struct Call[]", + "components": [ + { + "name": "target", + "type": "address", + "internalType": "address", + }, + { + "name": "data", + "type": "bytes", + "internalType": "bytes", + }, + { + "name": "gas", + "type": "uint256", + "internalType": "uint256", + }, + ], + }, + ], + "outputs": [ + { + "name": "nextBurstGas", + "type": "uint256", + "internalType": "uint256", + }, + ], + "stateMutability": "nonpayable", + }, + { + "type": "function", + "name": "execSingle", + "inputs": [ + { + "name": "calls", + "type": "tuple[]", + "internalType": "struct Call[]", + "components": [ + { + "name": "target", + "type": "address", + "internalType": "address", + }, + { + "name": "data", + "type": "bytes", + "internalType": "bytes", + }, + { + "name": "gas", + "type": "uint256", + "internalType": "uint256", + }, + ], + }, + ], + "outputs": [], + "stateMutability": "nonpayable", + }, + { + "type": "event", + "name": "Receipt", + "inputs": [ + { + "name": "gasReport", + "type": "int256[]", + "indexed": false, + "internalType": "int256[]", + }, + ], + "anonymous": false, + }, + ], + "bytecode": { + "object": + "0x6080604052348015600f57600080fd5b50610b108061001f6000396000f3fe608060405234801561001057600080fd5b50600436106100415760003560e01c80635835cb8014610046578063b8ce26f91461005b578063f6cc90ce14610081575b600080fd5b61005961005436600461052e565b6100a1565b005b61006e610069366004610570565b61019a565b6040519081526020015b60405180910390f35b61009461008f36600461052e565b610292565b60405161007891906105ef565b7fffffffffffffffffffffffffba879a9c8a8b908ddfd2df9b8d9e9691df989e8d32016100ce575b6100c9565b60005b8181101561019557368383838181106100ec576100ec610632565b90506020028101906100fe9190610661565b905060408101356000819003610112575a90505b600061012160208401846106c8565b9050600061013260208501856106ea565b8080601f0160208091040260200160405190810160405280939291908181526020018383808284376000920182905250845194955093849350839250905060208501828789f190508061018457600080fd5b5050600190930192506100d1915050565b505050565b600080845a6101a9919061077e565b905060006101b78888610292565b90505b815a116101ba5760005b81518110156101fb5760008282815181106101e1576101e1610632565b6020026020010151136101f357600080fd5b6001016101c4565b5060405a61020a90603f610797565b61021491906107ae565b6040517f5835cb800000000000000000000000000000000000000000000000000000000081529093503090635835cb809061025590889088906004016107e9565b600060405180830381600087803b15801561026f57600080fd5b505af1158015610283573d6000803e3d6000fd5b50505050505095945050505050565b606061029f82600161096f565b67ffffffffffffffff8111156102b7576102b7610982565b6040519080825280602002602001820160405280156102e0578160200160208202803683370190505b509050600160005b816102fc575a6102f7906109b1565b6102fe565b5a5b83828151811061031057610310610632565b60209081029190910101528084146104a3573685858381811061033557610335610632565b90506020028101906103479190610661565b90508215801561035f575061035f60208201826109e9565b1561036a5750610491565b30602082013581635835cb806103836040860186610a0b565b6040516024016103949291906107e9565b604080517fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe08184030181529181526020820180517bffffffffffffffffffffffffffffffffffffffffffffffffffffffff1660e09490941b9390931790925290516103ff9250610a73565b60006040518083038160008787f1925050503d806000811461043d576040519150601f19603f3d011682016040523d82523d6000602084013e610442565b606091505b50909350507fffffffffffffffffffffffffba879a9c8a8b908ddfd2df9b8d9e9691df989e8d320161048f5780602001355a61047f90603f610797565b101561048a57600080fd5b600192505b505b8061049b81610aa2565b9150506102e8565b507f2f11af55833ef785aecc862ba1c283298eea75cf957861fcf5c922ece0b27315826040516104d391906105ef565b60405180910390a15092915050565b60008083601f8401126104f457600080fd5b50813567ffffffffffffffff81111561050c57600080fd5b6020830191508360208260051b850101111561052757600080fd5b9250929050565b6000806020838503121561054157600080fd5b823567ffffffffffffffff81111561055857600080fd5b610564858286016104e2565b90969095509350505050565b60008060008060006060868803121561058857600080fd5b853567ffffffffffffffff81111561059f57600080fd5b6105ab888289016104e2565b90965094505060208601359250604086013567ffffffffffffffff8111156105d257600080fd5b6105de888289016104e2565b969995985093965092949392505050565b602080825282518282018190526000918401906040840190835b81811015610627578351835260209384019390920191600101610609565b509095945050505050565b7f4e487b7100000000000000000000000000000000000000000000000000000000600052603260045260246000fd5b600082357fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffa183360301811261069557600080fd5b9190910192915050565b803573ffffffffffffffffffffffffffffffffffffffff811681146106c357600080fd5b919050565b6000602082840312156106da57600080fd5b6106e38261069f565b9392505050565b60008083357fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe184360301811261071f57600080fd5b83018035915067ffffffffffffffff82111561073a57600080fd5b60200191503681900382131561052757600080fd5b7f4e487b7100000000000000000000000000000000000000000000000000000000600052601160045260246000fd5b818103818111156107915761079161074f565b92915050565b80820281158282048414176107915761079161074f565b6000826107e4577f4e487b7100000000000000000000000000000000000000000000000000000000600052601260045260246000fd5b500490565b6020808252810182905260006040600584901b8301810190830185837fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffa136839003015b87821015610962577fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc0878603018452823581811261086957600080fd5b890173ffffffffffffffffffffffffffffffffffffffff6108898261069f565b16865260208101357fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe18236030181126108c157600080fd5b810160208101903567ffffffffffffffff8111156108de57600080fd5b8036038213156108ed57600080fd5b60606020890152806060890152808260808a013760006080828a010152600091506040830135915081604089015260807fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe0601f830116890101975050505060208301925060208401935060018201915061082c565b5092979650505050505050565b808201808211156107915761079161074f565b7f4e487b7100000000000000000000000000000000000000000000000000000000600052604160045260246000fd5b60007f800000000000000000000000000000000000000000000000000000000000000082036109e2576109e261074f565b5060000390565b6000602082840312156109fb57600080fd5b813580151581146106e357600080fd5b60008083357fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe1843603018112610a4057600080fd5b83018035915067ffffffffffffffff821115610a5b57600080fd5b6020019150600581901b360382131561052757600080fd5b6000825160005b81811015610a945760208186018101518583015201610a7a565b506000920191825250919050565b60007fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff8203610ad357610ad361074f565b506001019056fea2646970667358221220f27c75610b49ce2ea98b03019db3cc3648f6bfc34bc7596f293aa1baf16f8d7364736f6c63430008240033", + "sourceMap": "210:3497:0:-:0;;;;;;;;;;;;;;;;;;;", + "linkReferences": {}, + }, + "deployedBytecode": { + "object": + "0x608060405234801561001057600080fd5b50600436106100415760003560e01c80635835cb8014610046578063b8ce26f91461005b578063f6cc90ce14610081575b600080fd5b61005961005436600461052e565b6100a1565b005b61006e610069366004610570565b61019a565b6040519081526020015b60405180910390f35b61009461008f36600461052e565b610292565b60405161007891906105ef565b7fffffffffffffffffffffffffba879a9c8a8b908ddfd2df9b8d9e9691df989e8d32016100ce575b6100c9565b60005b8181101561019557368383838181106100ec576100ec610632565b90506020028101906100fe9190610661565b905060408101356000819003610112575a90505b600061012160208401846106c8565b9050600061013260208501856106ea565b8080601f0160208091040260200160405190810160405280939291908181526020018383808284376000920182905250845194955093849350839250905060208501828789f190508061018457600080fd5b5050600190930192506100d1915050565b505050565b600080845a6101a9919061077e565b905060006101b78888610292565b90505b815a116101ba5760005b81518110156101fb5760008282815181106101e1576101e1610632565b6020026020010151136101f357600080fd5b6001016101c4565b5060405a61020a90603f610797565b61021491906107ae565b6040517f5835cb800000000000000000000000000000000000000000000000000000000081529093503090635835cb809061025590889088906004016107e9565b600060405180830381600087803b15801561026f57600080fd5b505af1158015610283573d6000803e3d6000fd5b50505050505095945050505050565b606061029f82600161096f565b67ffffffffffffffff8111156102b7576102b7610982565b6040519080825280602002602001820160405280156102e0578160200160208202803683370190505b509050600160005b816102fc575a6102f7906109b1565b6102fe565b5a5b83828151811061031057610310610632565b60209081029190910101528084146104a3573685858381811061033557610335610632565b90506020028101906103479190610661565b90508215801561035f575061035f60208201826109e9565b1561036a5750610491565b30602082013581635835cb806103836040860186610a0b565b6040516024016103949291906107e9565b604080517fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe08184030181529181526020820180517bffffffffffffffffffffffffffffffffffffffffffffffffffffffff1660e09490941b9390931790925290516103ff9250610a73565b60006040518083038160008787f1925050503d806000811461043d576040519150601f19603f3d011682016040523d82523d6000602084013e610442565b606091505b50909350507fffffffffffffffffffffffffba879a9c8a8b908ddfd2df9b8d9e9691df989e8d320161048f5780602001355a61047f90603f610797565b101561048a57600080fd5b600192505b505b8061049b81610aa2565b9150506102e8565b507f2f11af55833ef785aecc862ba1c283298eea75cf957861fcf5c922ece0b27315826040516104d391906105ef565b60405180910390a15092915050565b60008083601f8401126104f457600080fd5b50813567ffffffffffffffff81111561050c57600080fd5b6020830191508360208260051b850101111561052757600080fd5b9250929050565b6000806020838503121561054157600080fd5b823567ffffffffffffffff81111561055857600080fd5b610564858286016104e2565b90969095509350505050565b60008060008060006060868803121561058857600080fd5b853567ffffffffffffffff81111561059f57600080fd5b6105ab888289016104e2565b90965094505060208601359250604086013567ffffffffffffffff8111156105d257600080fd5b6105de888289016104e2565b969995985093965092949392505050565b602080825282518282018190526000918401906040840190835b81811015610627578351835260209384019390920191600101610609565b509095945050505050565b7f4e487b7100000000000000000000000000000000000000000000000000000000600052603260045260246000fd5b600082357fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffa183360301811261069557600080fd5b9190910192915050565b803573ffffffffffffffffffffffffffffffffffffffff811681146106c357600080fd5b919050565b6000602082840312156106da57600080fd5b6106e38261069f565b9392505050565b60008083357fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe184360301811261071f57600080fd5b83018035915067ffffffffffffffff82111561073a57600080fd5b60200191503681900382131561052757600080fd5b7f4e487b7100000000000000000000000000000000000000000000000000000000600052601160045260246000fd5b818103818111156107915761079161074f565b92915050565b80820281158282048414176107915761079161074f565b6000826107e4577f4e487b7100000000000000000000000000000000000000000000000000000000600052601260045260246000fd5b500490565b6020808252810182905260006040600584901b8301810190830185837fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffa136839003015b87821015610962577fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc0878603018452823581811261086957600080fd5b890173ffffffffffffffffffffffffffffffffffffffff6108898261069f565b16865260208101357fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe18236030181126108c157600080fd5b810160208101903567ffffffffffffffff8111156108de57600080fd5b8036038213156108ed57600080fd5b60606020890152806060890152808260808a013760006080828a010152600091506040830135915081604089015260807fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe0601f830116890101975050505060208301925060208401935060018201915061082c565b5092979650505050505050565b808201808211156107915761079161074f565b7f4e487b7100000000000000000000000000000000000000000000000000000000600052604160045260246000fd5b60007f800000000000000000000000000000000000000000000000000000000000000082036109e2576109e261074f565b5060000390565b6000602082840312156109fb57600080fd5b813580151581146106e357600080fd5b60008083357fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe1843603018112610a4057600080fd5b83018035915067ffffffffffffffff821115610a5b57600080fd5b6020019150600581901b360382131561052757600080fd5b6000825160005b81811015610a945760208186018101518583015201610a7a565b506000920191825250919050565b60007fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff8203610ad357610ad361074f565b506001019056fea2646970667358221220f27c75610b49ce2ea98b03019db3cc3648f6bfc34bc7596f293aa1baf16f8d7364736f6c63430008240033", + "sourceMap": + "210:3497:0:-:0;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;2771:934;;;;;;:::i;:::-;;:::i;:::-;;1911:854;;;;;;:::i;:::-;;:::i;:::-;;;1980:25:1;;;1968:2;1953:18;1911:854:0;;;;;;;;274:1412;;;;;;:::i;:::-;;:::i;:::-;;;;;;;:::i;2771:934::-;2906:53;:9;:53;2902:154;;3024:21;3037:8;3024:21;;3070:9;3065:634;3085:16;;;3065:634;;;3122:22;3147:5;;3153:1;3147:8;;;;;;;:::i;:::-;;;;;;;;;;;;:::i;:::-;3122:33;-1:-1:-1;3188:12:0;;;;3169:16;3218:13;;;3214:39;;3244:9;3233:20;;3214:39;3267:14;3284:15;;;;:8;:15;:::i;:::-;3267:32;-1:-1:-1;3313:17:0;3333:13;;;;:8;:13;:::i;:::-;3313:33;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;-1:-1:-1;3543:11:0;;3313:33;;-1:-1:-1;3313:33:0;;;-1:-1:-1;3313:33:0;;-1:-1:-1;3543:11:0;-1:-1:-1;3538:2:0;3528:13;;3313:33;3517:6;3507:8;3502:59;3491:70;;3680:7;3672:16;;;;;;-1:-1:-1;;3103:3:0;;;;;-1:-1:-1;3065:634:0;;-1:-1:-1;;3065:634:0;;;2771:934;;:::o;1911:854::-;2039:20;2135:16;2166:9;2154;:21;;;;:::i;:::-;2135:40;;2185:23;2211:12;2216:6;;2211:4;:12::i;:::-;2185:38;;2334:37;2353:8;2341:9;:20;2363:8;2334:37;2386:9;2381:177;2405:7;:14;2401:1;:18;2381:177;;;2545:1;2532:7;2540:1;2532:10;;;;;;;;:::i;:::-;;;;;;;:14;2524:23;;;;;;2421:3;;2381:177;;;;2715:2;2698:9;:14;;2710:2;2698:14;:::i;:::-;:19;;;;:::i;:::-;2727:31;;;;;2683:34;;-1:-1:-1;2727:4:0;;:15;;:31;;2743:14;;;;2727:31;;;:::i;:::-;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;2065:700;;1911:854;;;;;;;:::o;274:1412::-;329:25;391:17;:6;407:1;391:17;:::i;:::-;378:31;;;;;;;;:::i;:::-;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;-1:-1:-1;378:31:0;-1:-1:-1;366:43:0;-1:-1:-1;434:4:0;419:12;502:1085;630:7;:48;;668:9;660:18;;;:::i;:::-;630:48;;;647:9;630:48;613:9;623:3;613:14;;;;;;;;:::i;:::-;;;;;;;;;;:65;692:31;;;718:5;692:31;737:20;760:6;;767:3;760:11;;;;;;;:::i;:::-;;;;;;;;;;;;:::i;:::-;737:34;;790:7;789:8;:27;;;;-1:-1:-1;801:15:0;;;;:5;:15;:::i;:::-;785:41;;;818:8;;;785:41;964:4;980:9;;;;964:4;1006:15;1024:11;;;;980:5;1024:11;:::i;:::-;991:46;;;;;;;;;:::i;:::-;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;956:82;;;;-1:-1:-1;956:82:0;:::i;:::-;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;-1:-1:-1;927:111:0;;-1:-1:-1;;1129:53:0;:9;:53;1125:452;;1458:5;:9;;;1440;:14;;1452:2;1440:14;:::i;:::-;:27;;1432:36;;;;;;1558:4;1548:14;;1125:452;537:1050;502:1085;530:5;;;;:::i;:::-;;;;502:1085;;;;1661:18;1669:9;1661:18;;;;;;:::i;:::-;;;;;;;;356:1330;274:1412;;;;:::o;14:380:1:-;90:8;100:6;154:3;147:4;139:6;135:17;131:27;121:55;;172:1;169;162:12;121:55;-1:-1:-1;195:20:1;;238:18;227:30;;224:50;;;270:1;267;260:12;224:50;307:4;299:6;295:17;283:29;;367:3;360:4;350:6;347:1;343:14;335:6;331:27;327:38;324:47;321:67;;;384:1;381;374:12;321:67;14:380;;;;;:::o;399:472::-;507:6;515;568:2;556:9;547:7;543:23;539:32;536:52;;;584:1;581;574:12;536:52;624:9;611:23;657:18;649:6;646:30;643:50;;;689:1;686;679:12;643:50;728:83;803:7;794:6;783:9;779:22;728:83;:::i;:::-;830:8;;702:109;;-1:-1:-1;399:472:1;-1:-1:-1;;;;399:472:1:o;876:953::-;1052:6;1060;1068;1076;1084;1137:2;1125:9;1116:7;1112:23;1108:32;1105:52;;;1153:1;1150;1143:12;1105:52;1193:9;1180:23;1226:18;1218:6;1215:30;1212:50;;;1258:1;1255;1248:12;1212:50;1297:83;1372:7;1363:6;1352:9;1348:22;1297:83;:::i;:::-;1399:8;;-1:-1:-1;1271:109:1;-1:-1:-1;;1503:2:1;1488:18;;1475:32;;-1:-1:-1;1584:2:1;1569:18;;1556:32;1613:18;1600:32;;1597:52;;;1645:1;1642;1635:12;1597:52;1684:85;1761:7;1750:8;1739:9;1735:24;1684:85;:::i;:::-;876:953;;;;-1:-1:-1;876:953:1;;-1:-1:-1;1788:8:1;;1658:111;876:953;-1:-1:-1;;;876:953:1:o;2494:609::-;2682:2;2694:21;;;2764:13;;2667:18;;;2786:22;;;2634:4;;2865:15;;;2839:2;2824:18;;;2634:4;2908:169;2922:6;2919:1;2916:13;2908:169;;;2983:13;;2971:26;;3026:2;3052:15;;;;3017:12;;;;2944:1;2937:9;2908:169;;;-1:-1:-1;3094:3:1;;2494:609;-1:-1:-1;;;;;2494:609:1:o;3108:184::-;3160:77;3157:1;3150:88;3257:4;3254:1;3247:15;3281:4;3278:1;3271:15;3297:378;3385:4;3443:11;3430:25;3533:66;3522:8;3506:14;3502:29;3498:102;3478:18;3474:127;3464:155;;3615:1;3612;3605:12;3464:155;3636:33;;;;;3297:378;-1:-1:-1;;3297:378:1:o;3680:196::-;3748:20;;3808:42;3797:54;;3787:65;;3777:93;;3866:1;3863;3856:12;3777:93;3680:196;;;:::o;3881:186::-;3940:6;3993:2;3981:9;3972:7;3968:23;3964:32;3961:52;;;4009:1;4006;3999:12;3961:52;4032:29;4051:9;4032:29;:::i;:::-;4022:39;3881:186;-1:-1:-1;;;3881:186:1:o;4072:580::-;4149:4;4155:6;4215:11;4202:25;4305:66;4294:8;4278:14;4274:29;4270:102;4250:18;4246:127;4236:155;;4387:1;4384;4377:12;4236:155;4414:33;;4466:20;;;-1:-1:-1;4509:18:1;4498:30;;4495:50;;;4541:1;4538;4531:12;4495:50;4574:4;4562:17;;-1:-1:-1;4605:14:1;4601:27;;;4591:38;;4588:58;;;4642:1;4639;4632:12;4657:184;4709:77;4706:1;4699:88;4806:4;4803:1;4796:15;4830:4;4827:1;4820:15;4846:128;4913:9;;;4934:11;;;4931:37;;;4948:18;;:::i;:::-;4846:128;;;;:::o;4979:168::-;5052:9;;;5083;;5100:15;;;5094:22;;5080:37;5070:71;;5121:18;;:::i;5152:274::-;5192:1;5218;5208:189;;5253:77;5250:1;5243:88;5354:4;5351:1;5344:15;5382:4;5379:1;5372:15;5208:189;-1:-1:-1;5411:9:1;;5152:274::o;5431:2157::-;5673:2;5685:21;;;5658:18;;5741:22;;;-1:-1:-1;5794:2:1;5843:1;5839:14;;;5824:30;;5820:39;;;5779:18;;5882:6;-1:-1:-1;5959:66:1;5934:14;5930:27;;;5926:100;6035:1524;6049:6;6046:1;6043:13;6035:1524;;;6138:66;6126:9;6118:6;6114:22;6110:95;6105:3;6098:108;6258:6;6245:20;6312:2;6292:18;6288:27;6278:55;;6329:1;6326;6319:12;6278:55;6359:31;;6449:42;6422:25;6359:31;6422:25;:::i;:::-;6418:74;6410:6;6403:90;6558:2;6551:5;6547:14;6534:28;6643:66;6635:5;6619:14;6615:26;6611:99;6589:20;6585:126;6575:154;;6725:1;6722;6715:12;6575:154;6757:32;;6878:2;6865:16;;;6816:21;6908:18;6897:30;;6894:50;;;6940:1;6937;6930:12;6894:50;6993:6;6977:14;6973:27;6964:7;6960:41;6957:61;;;7014:1;7011;7004:12;6957:61;7055:4;7050:2;7042:6;7038:15;7031:29;7099:6;7092:4;7084:6;7080:17;7073:33;7159:6;7150:7;7144:3;7136:6;7132:16;7119:47;7217:1;7211:3;7202:6;7194;7190:19;7186:29;7179:40;7247:1;7232:16;;7296:2;7289:5;7285:14;7272:28;7261:39;;7337:7;7332:2;7324:6;7320:15;7313:32;7475:3;7405:66;7400:2;7392:6;7388:15;7384:88;7376:6;7372:101;7368:111;7358:121;;;;;7514:2;7506:6;7502:15;7492:25;;7546:2;7541:3;7537:12;7530:19;;6071:1;6068;6064:9;6059:14;;6035:1524;;;-1:-1:-1;7576:6:1;;5431:2157;-1:-1:-1;;;;;;;5431:2157:1:o;7593:125::-;7658:9;;;7679:10;;;7676:36;;;7692:18;;:::i;7723:184::-;7775:77;7772:1;7765:88;7872:4;7869:1;7862:15;7896:4;7893:1;7886:15;7912:191;7947:3;7978:66;7971:5;7968:77;7965:103;;8048:18;;:::i;:::-;-1:-1:-1;8088:1:1;8084:13;;7912:191::o;8492:273::-;8548:6;8601:2;8589:9;8580:7;8576:23;8572:32;8569:52;;;8617:1;8614;8607:12;8569:52;8656:9;8643:23;8709:5;8702:13;8695:21;8688:5;8685:32;8675:60;;8731:1;8728;8721:12;8770:626;8885:4;8891:6;8951:11;8938:25;9041:66;9030:8;9014:14;9010:29;9006:102;8986:18;8982:127;8972:155;;9123:1;9120;9113:12;8972:155;9150:33;;9202:20;;;-1:-1:-1;9245:18:1;9234:30;;9231:50;;;9277:1;9274;9267:12;9231:50;9310:4;9298:17;;-1:-1:-1;9361:1:1;9357:14;;;9341;9337:35;9327:46;;9324:66;;;9386:1;9383;9376:12;9401:412;9530:3;9568:6;9562:13;9593:1;9603:129;9617:6;9614:1;9611:13;9603:129;;;9715:4;9699:14;;;9695:25;;9689:32;9676:11;;;9669:53;9632:12;9603:129;;;-1:-1:-1;9787:1:1;9751:16;;9776:13;;;-1:-1:-1;9751:16:1;9401:412;-1:-1:-1;9401:412:1:o;9818:195::-;9857:3;9888:66;9881:5;9878:77;9875:103;;9958:18;;:::i;:::-;-1:-1:-1;10005:1:1;9994:13;;9818:195::o", + "linkReferences": {}, + }, + "methodIdentifiers": { + "exec((bool,uint256,(address,bytes,uint256)[])[])": "f6cc90ce", + "execNext((bool,uint256,(address,bytes,uint256)[])[],uint256,(address,bytes,uint256)[])": + "b8ce26f9", + "execSingle((address,bytes,uint256)[])": "5835cb80", + }, + "rawMetadata": + '{"compiler":{"version":"0.8.36+commit.8a079791"},"language":"Solidity","output":{"abi":[{"anonymous":false,"inputs":[{"indexed":false,"internalType":"int256[]","name":"gasReport","type":"int256[]"}],"name":"Receipt","type":"event"},{"inputs":[{"components":[{"internalType":"bool","name":"needsPrev","type":"bool"},{"internalType":"uint256","name":"gas","type":"uint256"},{"components":[{"internalType":"address","name":"target","type":"address"},{"internalType":"bytes","name":"data","type":"bytes"},{"internalType":"uint256","name":"gas","type":"uint256"}],"internalType":"struct Call[]","name":"calls","type":"tuple[]"}],"internalType":"struct Burst[]","name":"bursts","type":"tuple[]"}],"name":"exec","outputs":[{"internalType":"int256[]","name":"gasReport","type":"int256[]"}],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"components":[{"internalType":"bool","name":"needsPrev","type":"bool"},{"internalType":"uint256","name":"gas","type":"uint256"},{"components":[{"internalType":"address","name":"target","type":"address"},{"internalType":"bytes","name":"data","type":"bytes"},{"internalType":"uint256","name":"gas","type":"uint256"}],"internalType":"struct Call[]","name":"calls","type":"tuple[]"}],"internalType":"struct Burst[]","name":"bursts","type":"tuple[]"},{"internalType":"uint256","name":"burstsGas","type":"uint256"},{"components":[{"internalType":"address","name":"target","type":"address"},{"internalType":"bytes","name":"data","type":"bytes"},{"internalType":"uint256","name":"gas","type":"uint256"}],"internalType":"struct Call[]","name":"nextBurstCalls","type":"tuple[]"}],"name":"execNext","outputs":[{"internalType":"uint256","name":"nextBurstGas","type":"uint256"}],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"components":[{"internalType":"address","name":"target","type":"address"},{"internalType":"bytes","name":"data","type":"bytes"},{"internalType":"uint256","name":"gas","type":"uint256"}],"internalType":"struct Call[]","name":"calls","type":"tuple[]"}],"name":"execSingle","outputs":[],"stateMutability":"nonpayable","type":"function"}],"devdoc":{"kind":"dev","methods":{"execNext((bool,uint256,(address,bytes,uint256)[])[],uint256,(address,bytes,uint256)[])":{"params":{"burstsGas":"The gas needed to execute `bursts`. It must be at least entire gas needed to call `exec`, including any leftover gas, but it may exclude the TX overhead, e.g. the calldata cost."}}},"version":1},"userdoc":{"kind":"user","methods":{},"version":1}},"settings":{"compilationTarget":{"src/Executor.sol":"Executor"},"evmVersion":"constantinople","libraries":{},"metadata":{"bytecodeHash":"ipfs"},"optimizer":{"enabled":true,"runs":1000000},"remappings":[":forge-std/=lib/forge-std/src/"]},"sources":{"src/Executor.sol":{"keccak256":"0x3db0e9e3f30c1beb5636ddcb0ac0d3007070b5f0228880fe4b115ffac03e7580","license":"GPL-3.0-only","urls":["bzz-raw://994faa3052a00e13cf04c89f0e5c08bf395e8604f8b6b0caff81998af6ea5b8e","dweb:/ipfs/QmbuQBAteLpRDktXcL1XrxCYkmQnbxnBirfUA7CRnyY1rY"]}},"version":1}', + "metadata": { + "compiler": { + "version": "0.8.36+commit.8a079791", + }, + "language": "Solidity", + "output": { + "abi": [ + { + "inputs": [ + { + "internalType": "int256[]", + "name": "gasReport", + "type": "int256[]", + "indexed": false, + }, + ], + "type": "event", + "name": "Receipt", + "anonymous": false, + }, + { + "inputs": [ + { + "internalType": "struct Burst[]", + "name": "bursts", + "type": "tuple[]", + "components": [ + { + "internalType": "bool", + "name": "needsPrev", + "type": "bool", + }, + { + "internalType": "uint256", + "name": "gas", + "type": "uint256", + }, + { + "internalType": "struct Call[]", + "name": "calls", + "type": "tuple[]", + "components": [ + { + "internalType": "address", + "name": "target", + "type": "address", + }, + { + "internalType": "bytes", + "name": "data", + "type": "bytes", + }, + { + "internalType": "uint256", + "name": "gas", + "type": "uint256", + }, + ], + }, + ], + }, + ], + "stateMutability": "nonpayable", + "type": "function", + "name": "exec", + "outputs": [ + { + "internalType": "int256[]", + "name": "gasReport", + "type": "int256[]", + }, + ], + }, + { + "inputs": [ + { + "internalType": "struct Burst[]", + "name": "bursts", + "type": "tuple[]", + "components": [ + { + "internalType": "bool", + "name": "needsPrev", + "type": "bool", + }, + { + "internalType": "uint256", + "name": "gas", + "type": "uint256", + }, + { + "internalType": "struct Call[]", + "name": "calls", + "type": "tuple[]", + "components": [ + { + "internalType": "address", + "name": "target", + "type": "address", + }, + { + "internalType": "bytes", + "name": "data", + "type": "bytes", + }, + { + "internalType": "uint256", + "name": "gas", + "type": "uint256", + }, + ], + }, + ], + }, + { + "internalType": "uint256", + "name": "burstsGas", + "type": "uint256", + }, + { + "internalType": "struct Call[]", + "name": "nextBurstCalls", + "type": "tuple[]", + "components": [ + { + "internalType": "address", + "name": "target", + "type": "address", + }, + { + "internalType": "bytes", + "name": "data", + "type": "bytes", + }, + { + "internalType": "uint256", + "name": "gas", + "type": "uint256", + }, + ], + }, + ], + "stateMutability": "nonpayable", + "type": "function", + "name": "execNext", + "outputs": [ + { + "internalType": "uint256", + "name": "nextBurstGas", + "type": "uint256", + }, + ], + }, + { + "inputs": [ + { + "internalType": "struct Call[]", + "name": "calls", + "type": "tuple[]", + "components": [ + { + "internalType": "address", + "name": "target", + "type": "address", + }, + { + "internalType": "bytes", + "name": "data", + "type": "bytes", + }, + { + "internalType": "uint256", + "name": "gas", + "type": "uint256", + }, + ], + }, + ], + "stateMutability": "nonpayable", + "type": "function", + "name": "execSingle", + }, + ], + "devdoc": { + "kind": "dev", + "methods": { + "execNext((bool,uint256,(address,bytes,uint256)[])[],uint256,(address,bytes,uint256)[])": + { + "params": { + "burstsGas": + "The gas needed to execute `bursts`. It must be at least entire gas needed to call `exec`, including any leftover gas, but it may exclude the TX overhead, e.g. the calldata cost.", + }, + }, + }, + "version": 1, + }, + "userdoc": { + "kind": "user", + "methods": {}, + "version": 1, + }, + }, + "settings": { + "remappings": [ + "forge-std/=lib/forge-std/src/", + ], + "optimizer": { + "enabled": true, + "runs": 1000000, + }, + "metadata": { + "bytecodeHash": "ipfs", + }, + "compilationTarget": { + "src/Executor.sol": "Executor", + }, + "evmVersion": "constantinople", + "libraries": {}, + }, + "sources": { + "src/Executor.sol": { + "keccak256": "0x3db0e9e3f30c1beb5636ddcb0ac0d3007070b5f0228880fe4b115ffac03e7580", + "urls": [ + "bzz-raw://994faa3052a00e13cf04c89f0e5c08bf395e8604f8b6b0caff81998af6ea5b8e", + "dweb:/ipfs/QmbuQBAteLpRDktXcL1XrxCYkmQnbxnBirfUA7CRnyY1rY", + ], + "license": "GPL-3.0-only", + }, + }, + "version": 1, + }, + "id": 0, +} as const; +export const abi = artifact.abi satisfies Abi; +export const bytecode = artifact.bytecode.object satisfies Hex; diff --git a/src/generate-contracts.ts b/src/generate-contracts.ts new file mode 100644 index 0000000..af8fb96 --- /dev/null +++ b/src/generate-contracts.ts @@ -0,0 +1,58 @@ +const check = Deno.args.includes("--check"); + +function runCommand(command: string, options: Deno.CommandOptions = {}): string { + const optionsFull = { stdout: "piped", stderr: "piped", ...options } as const; + const { code, stdout, stderr } = new Deno.Command(command, optionsFull).outputSync(); + if (code) { + const cause = new TextDecoder().decode(stderr).trim(); + throw new Error(command + " exited with error code " + code, { cause }); + } + return new TextDecoder().decode(stdout).trim(); +} + +function runDeno(...args: string[]) { + runCommand(Deno.execPath(), { args }); +} + +// Solc appends a CBOR-encoded metadata blob (compiler version, a hash covering source paths, +// etc.), prefixed by its own 2-byte length, to the end of the bytecode. It has no effect on the +// deployed contract, but differs across machines/Foundry versions even when the contract itself +// is unchanged - so it's trimmed off before ever comparing bytecode for staleness. +function trimMetadata(bytecode: string): string { + const hex = bytecode.startsWith("0x") ? bytecode.slice(2) : bytecode; + const metadataLength = parseInt(hex.slice(-4), 16) * 2; + return hex.slice(0, hex.length - 4 - metadataLength); +} + +async function generateArtifact(contractName: string, outputPath: string) { + const artifactJson = runCommand("forge", { + args: ["inspect", "--force", contractName, "artifact"], + cwd: "contracts", + }); + const bytecode: string = JSON.parse(artifactJson).bytecode.object; + + if (check) { + const existing = await import(`file://${Deno.cwd()}/${outputPath}`); + if (trimMetadata(bytecode) !== trimMetadata(existing.bytecode)) { + throw new Error(`${outputPath} is out of date with the ${contractName} contract`); + } + return; + } + + Deno.writeTextFileSync( + outputPath, + ` + // DO NOT EDIT, code generated with task \`generate-contracts\` + import type { Abi, Hex } from "viem"; + + const artifact = ${artifactJson} as const; + export const abi = artifact.abi satisfies Abi; + export const bytecode = artifact.bytecode.object satisfies Hex; + `, + ); + runDeno("fmt", outputPath); + runDeno("check", outputPath); +} + +await generateArtifact("Executor", "src/executor.generated.ts"); +await generateArtifact("CallsLog", "tests/integration/calls-log.generated.ts"); diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..82018fe --- /dev/null +++ b/src/main.ts @@ -0,0 +1,20 @@ +import { drizzle } from "drizzle-orm/postgres-js"; +import { migrate } from "drizzle-orm/postgres-js/migrator"; +import { createApp } from "./app.ts"; +import { getConfig } from "./config.ts"; +import { runWorker } from "./worker.ts"; + +const { dbUrl, port, chainConfigs } = getConfig(); + +const db = drizzle({ connection: dbUrl, casing: "snake_case" }); +await migrate(db, { migrationsFolder: "./drizzle" }); + +const workersHealth = chainConfigs.values() + .map((chainConfig) => runWorker(chainConfig, db)) + .toArray() + .sort((workerA, workerB) => workerA.name.localeCompare(workerB.name)); + +const chainIds = new Set(chainConfigs.values().map(({ client }) => client.chain.id)); + +await createApp(db, chainIds, workersHealth) + .listen({ port, hostname: "[::]" }); diff --git a/src/worker.ts b/src/worker.ts new file mode 100644 index 0000000..eabf818 --- /dev/null +++ b/src/worker.ts @@ -0,0 +1,858 @@ +import { delay } from "async"; +import { + Address, + BaseError, + concat, + ContractFunctionRevertedError, + decodeEventLog, + encodeFunctionData, + ExecutionRevertedError, + FeeCapTooLowError, + FeeValues, + getAddress, + getContractAddress, + Hex, + InsufficientFundsError, + keccak256, + NonceTooLowError, + pad, + stringToHex, + TransactionReceipt, + TransactionReceiptNotFoundError, +} from "viem"; +import { getNodeError } from "viem/utils"; +import { and, eq, inArray, min } from "drizzle-orm"; +import { type PostgresJsDatabase } from "drizzle-orm/postgres-js"; +import { AsyncLocalStorage } from "node:async_hooks"; +import { + burstsTable, + callsTable, + sequenceEventsTable, + sequenceEventToDbValue, + sequencesTable, + txPayloadBurstsTable, + txPayloadsTable, + txSendersTable, + txsTable, +} from "./db/schema.ts"; +import { ChainConfig, Client } from "./config.ts"; +import { abi as executorAbi, bytecode as executorBytecode } from "./executor.generated.ts"; + +function matchViemError( + error: unknown, + // deno-lint-ignore no-explicit-any + ...types: (abstract new (...args: any[]) => Error)[] +): Error | null { + if (!(error instanceof BaseError)) return null; + return error.walk((e) => types.some((Type) => e instanceof Type)); +} + +const singletonFactory = "0x914d7Fec6aaC8cd542e72Bca78B30650d45643d7"; +const executorSalt = pad("0x"); +const executorAddr = getContractAddress({ + from: singletonFactory, + opcode: "CREATE2", + bytecode: executorBytecode, + salt: executorSalt, +}); +const burnAddr = getAddress(stringToHex("Nonce burning target")); + +const filecoinChainId = 314; +// Filecoin RPC ignores `gas` field in `eth_call` and always sets the gas limit to this value. +// This affects visible `gasLeft` inside contract executed with Viem `simulateContract`, +// and any gas cost measurement made this way must be corrected. +const filecoinSimulateContractGas = 10_000_000_000n; + +type PendingTxs = { + txSenderId: number; + senderAddr: Address; + nonce: number; + txHashes: Hex[]; + nextPayload?: { + txPayloadId: number; + target: Address; + calldata?: Hex; + gas?: bigint; + isBurn?: true; + }; +}; + +type WorkerContext = { + chainConfig: ChainConfig; + db: PostgresJsDatabase; + pendingTxs?: PendingTxs; + execBaseInclusionGas?: bigint; + burnGas?: bigint; +}; + +const workerContext = new AsyncLocalStorage(); + +function getWorkerContext(): WorkerContext { + const contextStore = workerContext.getStore(); + if (!contextStore) throw Error("No context set"); + return contextStore; +} + +function getClient(): Client { + return getChainConfig().client; +} + +function getChainConfig(): ChainConfig { + return getWorkerContext().chainConfig; +} + +function getDb(): PostgresJsDatabase { + return getWorkerContext().db; +} + +function getNonce(): Promise { + const client = getClient(); + return client.getTransactionCount({ address: client.account.address }); +} + +function getPendingTxs(): PendingTxs { + const pendingTxs = getWorkerContext().pendingTxs; + if (!pendingTxs) throw Error("No pending TXs set in the context"); + return pendingTxs; +} + +function setPendingTxs(pendingTxs?: PendingTxs) { + const contextStore = getWorkerContext(); + if (contextStore.pendingTxs && pendingTxs) throw Error("Pending TXs already set in the context"); + contextStore.pendingTxs = pendingTxs; +} + +async function getExecBaseInclusionGas(): Promise { + const workerContext = getWorkerContext(); + workerContext.execBaseInclusionGas ??= await calcExecBaseInclusionGas(); + return workerContext.execBaseInclusionGas; +} + +async function calcExecBaseInclusionGas(): Promise { + const client = getClient(); + const blockNumber = await client.getBlockNumber(); + const emptyExecGas = await client.estimateGas({ + account: client.account, + data: encodeFunctionData({ + abi: executorAbi, + functionName: "exec", + args: [[]], + }), + to: executorAddr, + blockNumber, + }); + let { result: [emptyExecStartGas] } = await client.simulateContract({ + account: client.account, + abi: executorAbi, + functionName: "exec", + args: [[]], + address: executorAddr, + blockNumber, + gas: emptyExecGas, + }); + if (client.chain.id === filecoinChainId) { + emptyExecStartGas -= filecoinSimulateContractGas - emptyExecGas; + if (emptyExecStartGas <= 0) throw Error("Filecoin base gas report correction failed"); + } + return emptyExecGas - emptyExecStartGas; +} + +async function getBurnGas(): Promise { + const workerContext = getWorkerContext(); + workerContext.burnGas ??= await calcBurnGas(); + return workerContext.burnGas; +} + +async function calcBurnGas(): Promise { + const client = getClient(); + return await client.estimateGas({ account: client.account, to: burnAddr }); +} + +function workerName(chainConfig?: ChainConfig): string { + return chainConfig?.client.chain.name ?? "main"; +} + +function log(...message: unknown[]) { + const name = workerName(workerContext.getStore()?.chainConfig); + console.log(`${new Date().toISOString()} [${name}]:`, ...message); +} + +type Task = () => Promise; +type Tasks = Task[] | Task | undefined; + +export type WorkerHealth = { + name: string; + runningSince: Date | null; +}; + +export function runWorker(chainConfig: ChainConfig, db: PostgresJsDatabase): WorkerHealth { + const workerHealth: WorkerHealth = { name: workerName(chainConfig), runningSince: null }; + (async () => { + while (true) { + await workerContext.run({ chainConfig, db }, async () => { + try { + workerHealth.runningSince = new Date(); + log("Worker started with a fresh state"); + const tasks: Task[] = [initRelay]; + while (true) { + const task = tasks.pop(); + if (!task) throw Error("Task queue empty"); + const newTasks = await task(); + tasks.push(...[newTasks ?? []].flat().reverse()); + } + } catch (error) { + workerHealth.runningSince = null; + log("Worker crashed with error:", error); + } + const { workerRestartDelayMs } = getChainConfig(); + log("Worker will restart in", workerRestartDelayMs / 1000, "seconds"); + await delay(workerRestartDelayMs); + }); + } + })(); + return workerHealth; +} + +async function initRelay(): Promise { + const isDeployed = (address: Address) => getClient().getCode({ address }); + if (!await isDeployed(singletonFactory)) { + throw new Error("Singleton factory not deployed at " + singletonFactory); + } + if (await isDeployed(executorAddr)) return cleanUpPendingTxs; + return [ + // Deploy Executor + () => sendTx({ target: singletonFactory, calldata: concat([executorSalt, executorBytecode]) }), + // Re-check if Executor is deployed + async () => { + if (!await isDeployed(executorAddr)) throw new Error("Failed to deploy Executor"); + return cleanUpPendingTxs; + }, + ]; +} + +async function cleanUpPendingTxs(): Promise { + const client = getClient(); + const db = getDb(); + const senderIdQuery = db.select({ id: min(txSendersTable.id) }) + .from(txSendersTable) + .innerJoin(txPayloadsTable, eq(txPayloadsTable.txSenderId, txSendersTable.id)) + .innerJoin(txsTable, eq(txsTable.txPayloadId, txPayloadsTable.id)) + .where(and( + eq(txSendersTable.chainId, client.chain.id), + eq(txsTable.state, "pending"), + )); + const txSender = await db.select({ + txSenderId: txSendersTable.id, + senderAddr: txSendersTable.address, + nonce: txSendersTable.nonce, + }) + .from(txSendersTable) + .where(eq(txSendersTable.id, senderIdQuery)); + // No more pending transactions, run the relay + if (!txSender.length) return sendNextBatch; + const [{ txSenderId, senderAddr, nonce }] = txSender; + + const txs = await db.select({ txHash: txsTable.txHash }) + .from(txsTable) + .innerJoin(txPayloadsTable, eq(txPayloadsTable.id, txsTable.txPayloadId)) + .where(eq(txPayloadsTable.txSenderId, txSenderId)) + .orderBy(txsTable.id); + + setPendingTxs({ + txSenderId, + senderAddr, + nonce, + txHashes: txs.map(({ txHash }) => txHash), + }); + + // Any transactions still pending are probably outdated. Burn the nonce to prevent mining them. + // If the nonce can't be burned, assume that the transactions are forgotten and won't be mined. + const onPending = senderAddr === client.account.address ? burnNonce : finalizeTx; + return [() => watchTxs({ onPending }), cleanUpPendingTxs]; +} + +type DbSequence = { id: string; bursts: DbBurst[] }; +type DbBurst = { + id: number; + idxInSequence: number; + gasBufferPercent: number | null; + calls: DbCall[]; +}; +type DbCall = { target: Address; calldata: Hex; gas: bigint | null }; + +async function sendNextBatch(lastCheckTime: number = 0): Promise { + await delay(lastCheckTime + getChainConfig().sendNextBatchMinRetryDelayMs - Date.now()); + log("Checking if a new batch needs to be sent"); + const sendNextBatchTask = () => sendNextBatch(Date.now()); + + const db = getDb(); + const dbCalls = await db.select({ + sequenceId: sequencesTable.id, + burstId: burstsTable.id, + burstIdxInSequence: burstsTable.idxInSequence, + gasBufferPercent: burstsTable.gasBufferPercent, + target: callsTable.target, + calldata: callsTable.calldata, + gas: callsTable.gas, + }) + .from(sequencesTable) + .innerJoin(burstsTable, eq(sequencesTable.id, burstsTable.sequenceId)) + .innerJoin(callsTable, eq(burstsTable.id, callsTable.burstId)) + .where(and( + eq(sequencesTable.chainId, getClient().chain.id), + eq(burstsTable.state, "pending"), + )) + .orderBy(sequencesTable.id, burstsTable.id, callsTable.id); + if (dbCalls.length == 0) return sendNextBatchTask; + + const dbSequences: DbSequence[] = []; + let prevDbCall; + for (const dbCall of dbCalls) { + if (dbCall.sequenceId !== prevDbCall?.sequenceId) { + dbSequences.push({ id: dbCall.sequenceId, bursts: [] }); + } + if (dbCall.burstId !== prevDbCall?.burstId) { + dbSequences.at(-1)!.bursts.push({ + id: dbCall.burstId, + idxInSequence: dbCall.burstIdxInSequence, + gasBufferPercent: dbCall.gasBufferPercent, + calls: [], + }); + } + dbSequences.at(-1)!.bursts.at(-1)!.calls + .push({ target: dbCall.target, calldata: dbCall.calldata, gas: dbCall.gas }); + prevDbCall = dbCall; + } + + const { submittedSequences, execGas, rejectedSequences } = await buildNextBatch(dbSequences); + if (rejectedSequences.length) { + await db.transaction(async (dbTx) => { + const failedBurstIds = rejectedSequences.map(({ burstIds }) => burstIds).flat(); + await dbTx.update(burstsTable).set({ state: "failure" }) + .where(inArray(burstsTable.id, failedBurstIds)); + + const events = rejectedSequences.map(({ id, fromIdxInSequence }) => + sequenceEventToDbValue(id, { kind: "rejected", details: { fromIdxInSequence } }) + ); + await dbTx.insert(sequenceEventsTable).values(events); + }); + } + if (!submittedSequences.length) return sendNextBatchTask; + + const submittedBursts = submittedSequences.map((sequence) => sequence.bursts).flat(); + const nonce = await getNonce(); + const calldata = encodeFunctionData({ + abi: executorAbi, + functionName: "exec", + args: [submittedBursts.map((burst) => burst.abiBurst)], + }); + await db.transaction(async (dbTx) => { + const { txPayloadId } = await registerPendingTx(dbTx, nonce, { + target: executorAddr, + calldata, + gas: execGas, + }); + await dbTx.insert(txPayloadBurstsTable).values(submittedBursts.map( + ({ id, inclusionGas }) => ({ txPayloadId, burstId: id, inclusionGas }), + )); + + const events = submittedSequences.map(({ id, fromIdxInSequence, bursts }) => + sequenceEventToDbValue(id, { + kind: "submitted", + details: { fromIdxInSequence, burstsCount: bursts.length }, + }) + ); + await dbTx.insert(sequenceEventsTable).values(events); + }); + + return [sendTxAttempt, sendNextBatchTask]; +} + +type AbiCall = { target: Address; data: Hex; gas: bigint }; +type AbiBurst = { needsPrev: boolean; gas: bigint; calls: AbiCall[] }; +type SubmittedSequence = { + id: string; + fromIdxInSequence: number; + bursts: { id: number; abiBurst: AbiBurst; inclusionGas: bigint }[]; +}; +type RejectedSequence = { id: string; fromIdxInSequence: number; burstIds: number[] }; + +async function buildNextBatch(dbSequences: DbSequence[]): Promise< + { + submittedSequences: SubmittedSequence[]; + execGas: bigint; + rejectedSequences: RejectedSequence[]; + } +> { + log("Building the batch"); + const submittedSequences: SubmittedSequence[] = []; + let execGas = 0n; + const rejectedSequences: RejectedSequence[] = []; + + const client = getClient(); + const blockNumber = await client.getBlockNumber(); + let lastBurstInclusionGas = await getExecBaseInclusionGas(); + + let outOfGasBursts = 0; + for (const dbSequence of dbSequences) { + if (outOfGasBursts >= 5) break; + log("Attempting adding sequence", dbSequence.id, "to the batch"); + let sequenceBurstsGas = 0n; + for (const [burstIdx, dbBurst] of dbSequence.bursts.entries()) { + let onRevert: "reject" | "outOfGas" | "skip" = "skip"; + try { + const nextAbiCalls: AbiCall[] = dbBurst.calls.map( + ({ target, calldata, gas }) => ({ target, data: calldata, gas: BigInt(gas ?? 0) }), + ); + const abiBursts = submittedSequences.flatMap((sequence) => sequence.bursts) + .map((burst) => burst.abiBurst); + const abiSequenceBursts = abiBursts.slice(abiBursts.length - burstIdx); + + // If the next burst runs out of available gas when executed in the sequence, + // it's treated as a revert, and isn't counted as out of gas. + onRevert = burstIdx == 0 ? "reject" : "skip"; + const execNextGas = await client.estimateGas({ + account: client.account, + data: encodeFunctionData({ + abi: executorAbi, + functionName: "execNext", + args: [abiSequenceBursts, sequenceBurstsGas, nextAbiCalls], + }), + to: executorAddr, + blockNumber, + gasPrice: 0n, + prepare: false, + }); + + let { result: nextBurstGas } = await client.simulateContract({ + account: client.account, + abi: executorAbi, + functionName: "execNext", + args: [abiSequenceBursts, sequenceBurstsGas, nextAbiCalls], + address: executorAddr, + blockNumber, + gas: execNextGas, + }); + if (client.chain.id === filecoinChainId) { + // The gas measurement is lowered with `* 63 / 64` in the contract, + // so the correction must be proportionally lowered too. + nextBurstGas -= (filecoinSimulateContractGas - execNextGas) * 63n / 64n; + if (nextBurstGas <= 0) throw Error("Filecoin gas reading correction failed"); + } + const nextAbiBurst = { + needsPrev: burstIdx > 0, + gas: nextBurstGas * (100n + BigInt(dbBurst.gasBufferPercent ?? 0)) / 100n, + calls: nextAbiCalls, + }; + + const nextAbiBursts = [...abiBursts, nextAbiBurst]; + onRevert = submittedSequences.length == 0 ? "reject" : "outOfGas"; + const nextExecGas = await client.estimateGas({ + account: getAddress(stringToHex("Executor - drain gas")), + data: encodeFunctionData({ + abi: executorAbi, + functionName: "exec", + args: [nextAbiBursts], + }), + to: executorAddr, + blockNumber, + gasPrice: 0n, + prepare: false, + }); + + if (onRevert === "outOfGas") onRevert = "skip"; + let { result: gasReport } = await client.simulateContract({ + account: client.account, + abi: executorAbi, + functionName: "exec", + args: [nextAbiBursts], + address: executorAddr, + blockNumber, + gas: nextExecGas, + }); + if (!gasReport.every((gas) => gas > 0n)) throw new ExecutionRevertedError(); + if (client.chain.id === filecoinChainId) { + gasReport = gasReport.map((gas) => { + gas -= filecoinSimulateContractGas - nextExecGas; + if (gas <= 0) throw Error("Filecoin gas report correction failed"); + return gas; + }); + } + + // Gas left when the sequence was starting execution, without any calldata overhead. + // It may be much more than is actually needed, but it's guaranteed + // to be enough for the sequence's execution including any leftover gas. + // 'gasReport' at -1 is gas left after the last burst and at -2 is before the last burst. + sequenceBurstsGas = gasReport.at(-2 - burstIdx)!; + const burstInclusionGas = nextExecGas - gasReport[0] - lastBurstInclusionGas; + if (burstInclusionGas <= 0) throw Error("Inclusion gas not positive: " + burstInclusionGas); + lastBurstInclusionGas += burstInclusionGas; + if (burstIdx == 0) { + submittedSequences.push({ + id: dbSequence.id, + fromIdxInSequence: dbBurst.idxInSequence, + bursts: [], + }); + } + submittedSequences.at(-1)!.bursts.push({ + id: dbBurst.id, + abiBurst: nextAbiBurst, + inclusionGas: burstInclusionGas, + }); + execGas = nextExecGas; + log("Burst", dbBurst.id, "accepted into the batch"); + } catch (error) { + // This catches execution errors in `simulateContract` and `estimateGas` + // covering reverting, out-of-gas or inability to estimate a valid gas limit. + if (!matchViemError(error, ExecutionRevertedError, ContractFunctionRevertedError)) { + throw error; + } + switch (onRevert) { + case "reject": + log("Burst", dbBurst.id, "rejected completely as failing"); + rejectedSequences.push({ + id: dbSequence.id, + fromIdxInSequence: dbBurst.idxInSequence, + // This branch may only be executed for the first burst in a sequence, + // so always all the bursts in that sequence are rejected. + burstIds: dbSequence.bursts.map(({ id }) => id), + }); + break; + case "outOfGas": + log("Burst", dbBurst.id, "doesn't fit in the batch gas limit, skipping"); + outOfGasBursts++; + break; + case "skip": + log("Burst", dbBurst.id, "can't be executed in the batch, skipping"); + } + break; + } + } + } + return { submittedSequences, execGas, rejectedSequences }; +} + +async function sendTx(payload: { target: Address; calldata?: Hex; gas?: bigint }): Promise { + log("Sending a transaction to", payload.target); + const nonce = await getNonce(); + await getDb().transaction((dbTx) => registerPendingTx(dbTx, nonce, payload)); + return sendTxAttempt(); +} + +// Must be called when there's no pending TX set. +async function registerPendingTx( + dbTx: PostgresJsDatabase, + nonce: number, + { target, calldata, gas }: { target: Address; calldata?: Hex; gas?: bigint }, +): Promise<{ txPayloadId: number }> { + const client = getClient(); + const address = client.account.address; + const chainId = client.chain.id; + await dbTx.insert(txSendersTable).values({ address, nonce, chainId }).onConflictDoNothing(); + const [{ txSenderId }] = await dbTx.select({ txSenderId: txSendersTable.id }).from(txSendersTable) + .where(and( + eq(txSendersTable.address, address), + eq(txSendersTable.nonce, nonce), + eq(txSendersTable.chainId, chainId), + )); + const [{ txPayloadId }] = await dbTx.insert(txPayloadsTable) + .values({ txSenderId, target, calldata, gas }).returning({ txPayloadId: txPayloadsTable.id }); + setPendingTxs({ + txSenderId, + senderAddr: address, + nonce, + txHashes: [], + nextPayload: { txPayloadId, target, calldata, gas }, + }); + return { txPayloadId }; +} + +// Must be called after 'registerPendingTx', when there's a pending TX set. +async function registerPendingBurnTx() { + const pendingTxs = getPendingTxs(); + // TODO estimate, insert and cache gas + const gas = await getBurnGas(); + const [{ txPayloadId }] = await getDb().insert(txPayloadsTable) + .values({ txSenderId: pendingTxs.txSenderId, target: burnAddr, gas }) + .returning({ txPayloadId: txPayloadsTable.id }); + pendingTxs.nextPayload = { txPayloadId, target: burnAddr, gas, isBurn: true }; +} + +function sendTxAttempt(): Promise { + log("Sending TX attempt"); + // Try to send 3 times, then burn nonce + const retryTask = getPendingTxs().txHashes.length < 2 ? sendTxAttempt : burnNonce; + return sendTxRaw(retryTask); +} + +async function increasedFees(fees: FeeValues): Promise { + const client = getClient(); + let lastTx; + for (const hash of getPendingTxs().txHashes.toReversed()) { + lastTx = await client.getTransaction({ hash }).catch(() => undefined); + if (lastTx) break; + } + if (!lastTx) return fees; + + const increasePercent = BigInt(getChainConfig().minGasIncreasePercent); + const increaseFee = (fee?: bigint, lastFee?: bigint): bigint | undefined | null => { + if (fee === undefined || lastFee === undefined) return fee; + const minFee = lastFee * (100n + increasePercent) / 100n + 1n; + if (minFee > fee * 150n / 100n) return null; + else if (minFee > fee) return minFee; + else return fee; + }; + const newFees = { + gasPrice: increaseFee(fees.gasPrice, lastTx.gasPrice), + maxFeePerBlobGas: increaseFee(fees.maxFeePerBlobGas, lastTx.maxFeePerBlobGas), + maxFeePerGas: increaseFee(fees.maxFeePerGas, lastTx.maxFeePerGas), + maxPriorityFeePerGas: increaseFee(fees.maxPriorityFeePerGas, lastTx.maxPriorityFeePerGas), + }; + if (Object.values(newFees).includes(null)) return null; + return newFees as FeeValues; +} + +async function burnNonce(delayMs?: number): Promise { + const pendingTxs = getPendingTxs(); + if (!pendingTxs.nextPayload?.isBurn) await registerPendingBurnTx(); + + log("Burning nonce", pendingTxs.nonce); + const config = getChainConfig(); + if (delayMs === undefined) delayMs = config.burnNonceDelayInitialMs; + else { + await delay(delayMs); + delayMs = Math.min(delayMs * config.burnNonceDelayMultiplier, config.burnNonceDelayMaxMs); + } + return sendTxRaw(() => burnNonce(delayMs)); +} + +async function sendTxRaw(retryTask: Task): Promise { + const client = getClient(); + const { + txHashes, + nonce, + nextPayload, + } = getPendingTxs(); + if (!nextPayload) throw Error("No payload set to send"); + const { txPayloadId, target, calldata, gas } = nextPayload; + + log("Attempting to send a transaction to", target); + const request = await client.prepareTransactionRequest( + { nonce, to: target, data: calldata, gas }, + ); + + const fees = await increasedFees(request); + if (!fees) return watchTxs({ onPending: retryTask }); + Object.assign(request, fees); + + const signedTx = await client.signTransaction(request); + const txHash = keccak256(signedTx); + txHashes.push(txHash); + await getDb().insert(txsTable).values({ txHash, txPayloadId }); + + try { + await client.sendRawTransaction({ serializedTransaction: signedTx }); + } catch (rawError) { + const error = rawError instanceof BaseError ? getNodeError(rawError, request) : rawError; + if (matchViemError(error, ExecutionRevertedError)) return burnNonce; + else if (matchViemError(error, FeeCapTooLowError)) return retryTask; + else if (matchViemError(error, NonceTooLowError)) { /* Continue normally */ } + else if (matchViemError(error, InsufficientFundsError)) { + return () => waitForBalance(request.gas * (request.maxFeePerGas ?? request.gasPrice)); + } else if ( + error instanceof BaseError && + error.walk((e) => + e instanceof BaseError && /replacement transaction underpriced/i.test(e.details ?? "") + ) + ) { + log("Replacement transaction", txHash, "underpriced"); // Continue normally + } else throw rawError; + } + return () => watchTxs({ onPending: retryTask }); +} + +async function waitForBalance(minBalance: bigint): Promise { + const pendingTxs = getWorkerContext().pendingTxs; + if (pendingTxs && !pendingTxs.nextPayload?.isBurn) { + return [burnNonce, () => waitForBalance(minBalance)]; + } + const { client, waitForBalanceRetryDelayMs } = getChainConfig(); + while (await client.getBalance({ address: client.account.address }) < minBalance) { + log("Waiting for the balance to be at least", minBalance, "for wallet", client.account.address); + await delay(waitForBalanceRetryDelayMs); + } + if (pendingTxs) return watchTxs({ onPending: burnNonce }); +} + +async function watchTxs( + { onPending }: { + onPending: Task; + }, +): Promise { + const pendingTxs = getPendingTxs(); + const config = getChainConfig(); + const client = config.client; + const txHashes = pendingTxs.txHashes.toReversed(); + const confirmations = BigInt(config.confirmations); + let nonceSkipConfirmed = false; + const onPendingFromBlock = await client.getBlockNumber() + BigInt(config.inclusionWaitBlocks); + retry: while (true) { + log("Watching transactions for nonce", pendingTxs.nonce); + for (const [hashIdx, hash] of txHashes.entries()) { + let receipt; + try { + receipt = await client.getTransactionReceipt({ hash }); + } catch (error) { + if (matchViemError(error, TransactionReceiptNotFoundError)) continue; + else throw error; + } + const confirmedFromBlock = receipt.blockNumber + confirmations; + if (await client.getBlockNumber() >= confirmedFromBlock) { + return () => finalizeTx(receipt); + } + await delayUntilBlockNumber(confirmedFromBlock); + txHashes.splice(hashIdx, 1); + txHashes.unshift(hash); + nonceSkipConfirmed = false; + continue retry; + } + + if (client.account.address === pendingTxs.senderAddr && await getNonce() > pendingTxs.nonce) { + if (nonceSkipConfirmed) return finalizeTx; + await delayUntilBlockNumber(await client.getBlockNumber() + confirmations); + nonceSkipConfirmed = true; + continue; + } + nonceSkipConfirmed = false; + + const blockNumber = await client.getBlockNumber(); + if (blockNumber >= onPendingFromBlock) return onPending; + await delayUntilBlockNumber(blockNumber + 1n); + } +} + +function delayUntilBlockNumber(targetBlockNumber: bigint): Promise { + const { promise, resolve, reject } = Promise.withResolvers(); + const unwatch = getClient().watchBlockNumber({ + pollingInterval: getChainConfig().delayUntilBlockNumberPollingIntervalMs, + onBlockNumber(blockNumber) { + if (blockNumber >= targetBlockNumber) resolve(blockNumber); + }, + onError: reject, + }); + return promise.finally(unwatch); +} + +async function finalizeTx(receipt?: TransactionReceipt): Promise { + log("Finalizing transaction", receipt?.transactionHash ?? "skipping"); + if (receipt?.status === "reverted") log("Transaction", receipt.transactionHash, "has reverted"); + const { txSenderId, txHashes } = getPendingTxs(); + setPendingTxs(undefined); + + await getDb().transaction(async (dbTx) => { + const skippedTxs = txHashes.filter((hash) => hash !== receipt?.transactionHash); + if (skippedTxs.length) { + await dbTx.update(txsTable).set({ state: "skipped" }) + .where(inArray(txsTable.txHash, skippedTxs)); + } + + let executedBursts: { + sequenceId: string; + burstId: number; + idxInSequence: number; + inclusionGas: bigint; + }[] = []; + if (receipt) { + await dbTx.update(txsTable) + .set({ state: receipt.status }) + .where(eq(txsTable.txHash, receipt.transactionHash)); + + executedBursts = await dbTx.select({ + sequenceId: burstsTable.sequenceId, + burstId: burstsTable.id, + idxInSequence: burstsTable.idxInSequence, + inclusionGas: txPayloadBurstsTable.inclusionGas, + }) + .from(burstsTable) + .innerJoin(txPayloadBurstsTable, eq(txPayloadBurstsTable.burstId, burstsTable.id)) + .innerJoin(txsTable, eq(txsTable.txPayloadId, txPayloadBurstsTable.txPayloadId)) + .where(eq(txsTable.txHash, receipt.transactionHash)) + .orderBy(txPayloadBurstsTable.id); + } + + if (receipt?.status !== "success" || !executedBursts.length) { + const skippedSequenceIds = await dbTx.selectDistinct({ sequenceId: burstsTable.sequenceId }) + .from(burstsTable) + .innerJoin(txPayloadBurstsTable, eq(txPayloadBurstsTable.burstId, burstsTable.id)) + .innerJoin(txPayloadsTable, eq(txPayloadsTable.id, txPayloadBurstsTable.txPayloadId)) + .where(eq(txPayloadsTable.txSenderId, txSenderId)); + const skippedEvents = skippedSequenceIds.map(({ sequenceId }) => + sequenceEventToDbValue(sequenceId, { kind: "skipped", details: {} }) + ); + if (skippedEvents.length) await dbTx.insert(sequenceEventsTable).values(skippedEvents); + return; + } + log("Got", executedBursts.length, "bursts finalized"); + + const lastLog = receipt.logs.at(-1)!; + const { gasReport } = decodeEventLog({ + abi: executorAbi, + eventName: "Receipt", + topics: lastLog.topics, + data: lastLog.data, + }) + .args as unknown as { gasReport: bigint[] }; + if (gasReport.length !== executedBursts.length + 1) throw Error("Invalid gas report"); + + const successBursts: number[] = []; + const failureSequences: string[] = []; + const executedEvents: { + sequenceId: string; + details: { fromIdxInSequence: number; successes: number; failed: boolean }; + }[] = []; + for (const [burstIdx, { burstId, sequenceId, idxInSequence }] of executedBursts.entries()) { + if (executedEvents.at(-1)?.sequenceId !== sequenceId) { + const details = { fromIdxInSequence: idxInSequence, successes: 0, failed: false }; + executedEvents.push({ sequenceId, details }); + } + const executedEvent = executedEvents.at(-1)!; + if (gasReport[burstIdx + 1] > 0) { + successBursts.push(burstId); + executedEvent.details.successes++; + } else { + if (failureSequences.at(-1) !== sequenceId) failureSequences.push(sequenceId); + executedEvent.details.failed = true; + } + } + if (successBursts.length) { + await dbTx.update(burstsTable).set({ state: "success" }) + .where(inArray(burstsTable.id, successBursts)); + } + if (failureSequences.length) { + await dbTx.update(burstsTable).set({ state: "failure" }).where(and( + inArray(burstsTable.sequenceId, failureSequences), + eq(burstsTable.state, "pending"), + )); + } + const events = executedEvents.map(({ sequenceId, details }) => + sequenceEventToDbValue(sequenceId, { kind: "executed", details }) + ); + await dbTx.insert(sequenceEventsTable).values(events); + + // Bursts cost calculation stub. + const burstCostShares: { burstId: number; cumulativeCostShare: bigint }[] = []; + let totalCostShares = 0n; + const abs = (num: bigint) => num < 0n ? -num : num; + for (const [burstIdx, { burstId, inclusionGas }] of executedBursts.entries()) { + totalCostShares += inclusionGas + abs(gasReport[burstIdx]) - abs(gasReport[burstIdx + 1]); + burstCostShares.push({ burstId, cumulativeCostShare: totalCostShares }); + } + const totalCost = receipt.gasUsed * receipt.effectiveGasPrice; + let assignedCost = 0n; + for (const { burstId, cumulativeCostShare } of burstCostShares) { + const burstCost = cumulativeCostShare * totalCost / totalCostShares - assignedCost; + assignedCost += burstCost; + log("Burst", burstId, "costed", burstCost); + } + }); +} diff --git a/tests/integration/anvil.ts b/tests/integration/anvil.ts new file mode 100644 index 0000000..44fad57 --- /dev/null +++ b/tests/integration/anvil.ts @@ -0,0 +1,141 @@ +import { assert } from "@std/assert"; +import { delay } from "async"; +import { + createTestClient, + type Hex, + http, + numberToHex, + publicActions, + serializeTransaction, + type Transaction, + walletActions, +} from "viem"; +import { foundry } from "viem/chains"; +import { privateKeyToAccount } from "viem/accounts"; + +const singletonFactory = "0x914d7Fec6aaC8cd542e72Bca78B30650d45643d7"; +const singletonFactoryBytecode = + "0x7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe03601600081602082378035828234f58015156039578182fd5b8082525050506014600cf3"; + +// Bound to Anvil's account #1, pre-funded with test ETH, so this doubles as the maintenance +// wallet for test-side on-chain setup (e.g. deploying a fixture contract) that isn't part of +// the app's own behavior - it must never be the app worker wallet's (account #0, see `app.ts`), +// or maintenance transactions would consume nonces the worker doesn't know about. +export const anvilClient = createTestClient({ + mode: "anvil", + chain: foundry, + transport: http(), + account: privateKeyToAccount( + "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d", + ), + cacheTime: 0, +}) + .extend(publicActions) + .extend(walletActions); + +export async function resetToGenesis() { + // viem's `reset` action always sends a `forking` param, which Anvil rejects when it wasn't + // started with `--fork-url`, so the plain `anvil_reset` (no params) is called directly. + await anvilClient.request({ method: "anvil_reset", params: [] }); +} + +export async function etchSingletonFactory() { + await anvilClient.setCode({ address: singletonFactory, bytecode: singletonFactoryBytecode }); +} + +// The cost of the cheapest possible TX at the current fee estimate - e.g. to fund a wallet with +// just enough for a plain nonce-burning transfer, but not for whatever heavier TX it's replacing. +export async function baseTxCost(): Promise { + const { gas, maxFeePerGas } = await anvilClient.prepareTransactionRequest({ + account: anvilClient.account, + to: anvilClient.account.address, + }); + return gas * maxFeePerGas; +} + +// Chain state, like any other set via a transaction, so - unlike automine - it's restored by +// `evm_revert`/`anvil_snapshot` but wiped by `resetToGenesis`, which needs to reapply it. +// Defaults to the value the test suite runs with, so callers that shrunk it can restore it with a +// bare call. +export async function setBlockGasLimit(gas: bigint = 1_000_000n) { + await anvilClient.request({ + method: "evm_setBlockGasLimit", + params: [numberToHex(gas)], + }); +} + +// Mines `blocks` blocks with the gas limit shrunk to 1 - too small for anything pending to fit - +// so the chain advances without any of it being touched, unlike dropping it outright. Restores +// the default gas limit afterward. +export async function mineEmptyBlocks(blocks: number) { + await setBlockGasLimit(1n); + await anvilClient.mine({ blocks }); + await setBlockGasLimit(); +} + +// Every transaction currently pending or queued in the mempool, in full (not just their count +// or hash), so callers can e.g. resubmit one verbatim after it's been dropped. +export async function getTxpoolTxs() { + const content = await anvilClient.getTxpoolContent(); + return [...Object.values(content.pending), ...Object.values(content.queued)] + .flatMap((byNonce) => Object.values(byNonce)); +} + +// Drops every pending and queued transaction from the mempool, so none of them can ever be +// mined - e.g. to simulate one being lost/abandoned rather than merely taking a while. +export async function dropPendingTxs() { + const txs = await getTxpoolTxs(); + for (const { hash } of txs) { + await anvilClient.request({ method: "anvil_dropTransaction", params: [hash] }); + } +} + +// Polls the mempool until it holds exactly `pending` pending and `queued` queued transactions. +// Throws immediately if either count overshoots its target, since that means something +// unexpected is happening rather than the target state simply not being reached yet. +export async function waitForTxpoolCounts(pending: number, queued = 0) { + while (true) { + const status = await anvilClient.getTxpoolStatus(); + if (status.pending === pending && status.queued === queued) return; + if (status.pending > pending || status.queued > queued) { + throw new Error( + `Expected ${pending} pending and ${queued} queued transactions, got ` + + `${status.pending} pending and ${status.queued} queued`, + ); + } + await delay(10); + } +} + +// Polls the mempool until its single pending transaction is no longer the one with the given +// hash, returning the new one's hash - e.g. to observe a repriced replacement land. +export async function waitForNewTxpoolTx(previousHash: Hex): Promise { + while (true) { + const [tx] = await getTxpoolTxs(); + if (tx && tx.hash !== previousHash) return tx.hash; + await delay(10); + } +} + +// Resubmits `tx` (as returned by `getTransaction`) verbatim, signature included - e.g. to restore +// an earlier attempt after a repriced retry replaced it, proving the worker still recognizes +// success via any of its historical attempts, not just the latest one. +export async function resubmitTx(tx: Transaction) { + assert(tx.type === "eip1559" && tx.yParity !== undefined, "Expected an EIP-1559 transaction"); + // `serializeTransaction` expects `data`, but the parsed `Transaction` names the same field + // `input` - passing `tx` directly would silently serialize it as empty calldata. + await anvilClient.sendRawTransaction({ + serializedTransaction: serializeTransaction({ + type: "eip1559", + chainId: anvilClient.chain.id, + nonce: tx.nonce, + to: tx.to, + value: tx.value, + data: tx.input, + gas: tx.gas, + maxFeePerGas: tx.maxFeePerGas, + maxPriorityFeePerGas: tx.maxPriorityFeePerGas, + accessList: tx.accessList, + }, { r: tx.r, s: tx.s, yParity: tx.yParity }), + }); +} diff --git a/tests/integration/app.ts b/tests/integration/app.ts new file mode 100644 index 0000000..59fa6ea --- /dev/null +++ b/tests/integration/app.ts @@ -0,0 +1,153 @@ +import { delay, retry } from "async"; +import { assert, assertEquals } from "@std/assert"; +import { Hex } from "viem"; +import { foundry } from "viem/chains"; +import { + healthSchema, + type SendSequencesArg, + sendSequencesSchema, + type SequencesStatesArg, + sequencesStatesSchema, +} from "../../src/app.ts"; +import { type SequenceEvent } from "../../src/db/schema.ts"; + +// Anvil's default account #0, pre-funded with test ETH. This is the wallet the app's own +// worker uses to send transactions - tests must never use it for their own on-chain setup +// (e.g. deploying fixtures), since that would consume nonces the worker doesn't know about +// and desync its nonce tracking. Use `anvil.ts`'s `maintenanceClient` for that instead. +export const workerPrivateKey: Hex = + "0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80"; + +// Anvil's default account #2, pre-funded with test ETH. Used in place of `workerPrivateKey` by +// tests simulating the app restarting with a different wallet for the same chain. +export const otherWorkerPrivateKey: Hex = + "0x5de4111afa1a4b94908f83103eb1f1706367c2e68ca870fc3fb9a804cdab365a"; + +export const port = 8000; + +// Passed explicitly into the worker's chain config below, rather than relying on the app's own +// default, so callers needing to mine confirming blocks (e.g. `relay.test.ts`) have one shared, +// explicit source of truth for how many are required instead of an assumed/hardcoded number. +export const confirmations = 1; + +// Same as `confirmations` above, but for how many blocks the worker waits for a transaction to +// even land in the mempool before giving up on it as stale. +export const inclusionWaitBlocks = 2; + +// Same as `confirmations` above, but for how much a repriced retry's fee must exceed the previous +// attempt's. +export const minGasIncreasePercent = 10; + +// Minimizes every worker delay/poll cadence, trading CPU usage for latency, so tests aren't +// stuck waiting out multi-second production defaults (e.g. the 1s batch-retry or 2s block-poll +// interval). These fields only exist for this purpose and are never meant to be set in prod. +const testConfig = { + workerRestartDelayMs: 1, + sendNextBatchMinRetryDelayMs: 1, + delayUntilBlockNumberPollingIntervalMs: 1, + waitForBalanceRetryDelayMs: 1, + burnNonceDelayInitialMs: 1, + burnNonceDelayMultiplier: 1, + burnNonceDelayMaxMs: 1, +}; + +export async function startApp(privateKey: Hex = workerPrivateKey): Promise { + const config = { + wallets: [{ + privateKey, + chains: [{ chainId: foundry.id, confirmations, inclusionWaitBlocks, minGasIncreasePercent }], + }], + }; + const command = new Deno.Command("deno", { + args: ["task", "start"], + cwd: new URL("../..", import.meta.url), + env: { CONFIG: JSON.stringify(config), TEST_CONFIG: JSON.stringify(testConfig) }, + stdout: "inherit", + stderr: "inherit", + }); + const app = command.spawn(); + try { + await retry(async () => { + const response = await fetch(`http://localhost:${port}/health`); + assert(response.ok, `/health returned ${response.status}`); + const { workers } = healthSchema.parse(await response.json()); + assert( + workers.length > 0 && workers.every((w) => w.runningSince !== null), + "worker(s) not running yet", + ); + }, { minTimeout: 10, maxTimeout: 10, multiplier: 1, maxAttempts: 1500 }); + } catch (error) { + await stopApp(app); + throw error; + } + return app; +} + +export async function stopApp(app: Deno.ChildProcess) { + app.kill("SIGTERM"); + await app.status; +} + +export async function sendSequences(arg: SendSequencesArg): Promise { + const response = await fetch(`http://localhost:${port}/send-sequences`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(arg), + }); + assert(response.ok, `/send-sequences returned ${response.status}`); + const { sequences } = sendSequencesSchema.parse(await response.json()); + return sequences.map(({ id }) => id); +} + +// Polls a sequence's state until it holds exactly the expected successes, failures and pending +// counts. Throws immediately if successes or failures overshoot, or pending undershoots, since +// that means the sequence resolved differently than expected rather than just not being there +// yet. +// +// If `events` is given, it's checked once that final state is reached: their timestamps must be +// non-decreasing (ties are allowed - events inserted together in the same DB transaction all +// share Postgres' `now()` for that transaction), and their kinds and details must exactly match +// `events`, in order. +export async function assertSequenceState( + id: string, + expectedState: { successes: number; failures: number; pending?: number }, + events?: SequenceEvent[], +) { + const { successes, failures, pending = 0 } = expectedState; + while (true) { + const arg: SequencesStatesArg = { sequences: [{ id }] }; + const response = await fetch(`http://localhost:${port}/sequences-states`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(arg), + }); + assert(response.ok, `/sequences-states returned ${response.status}`); + const { sequences: [state] } = sequencesStatesSchema.parse(await response.json()); + if (state.successes === successes && state.failures === failures && state.pending === pending) { + if (events) assertEvents(state.events, events); + return; + } + if (state.successes > successes || state.failures > failures || state.pending < pending) { + throw new Error( + `Expected ${successes} successes, ${failures} failures and ${pending} pending, got ` + + `${state.successes} successes, ${state.failures} failures and ${state.pending} pending`, + ); + } + await delay(50); + } +} + +function assertEvents( + events: (SequenceEvent & { timestamp: Date })[], + expected: SequenceEvent[], +) { + for (let i = 1; i < events.length; i++) { + assert( + events[i].timestamp.getTime() >= events[i - 1].timestamp.getTime(), + `Expected event timestamps to be non-decreasing, but event ${i} (${events[i].kind}) at ` + + `${events[i].timestamp.toISOString()} precedes event ${i - 1} (${events[i - 1].kind}) at ` + + `${events[i - 1].timestamp.toISOString()}`, + ); + } + assertEquals(events.map(({ timestamp: _timestamp, ...event }) => event), expected); +} diff --git a/tests/integration/calls-log.generated.ts b/tests/integration/calls-log.generated.ts new file mode 100644 index 0000000..22a2d05 --- /dev/null +++ b/tests/integration/calls-log.generated.ts @@ -0,0 +1,190 @@ +// DO NOT EDIT, code generated with task `generate-contracts` +import type { Abi, Hex } from "viem"; + +const artifact = { + "abi": [ + { + "type": "function", + "name": "addLog", + "inputs": [ + { + "name": "log", + "type": "string", + "internalType": "string", + }, + ], + "outputs": [], + "stateMutability": "nonpayable", + }, + { + "type": "function", + "name": "getLogs", + "inputs": [], + "outputs": [ + { + "name": "", + "type": "string[]", + "internalType": "string[]", + }, + ], + "stateMutability": "view", + }, + { + "type": "function", + "name": "setGasPenalty", + "inputs": [ + { + "name": "log", + "type": "string", + "internalType": "string", + }, + { + "name": "gas", + "type": "uint256", + "internalType": "uint256", + }, + ], + "outputs": [], + "stateMutability": "nonpayable", + }, + { + "type": "function", + "name": "setReverts", + "inputs": [ + { + "name": "log", + "type": "string", + "internalType": "string", + }, + ], + "outputs": [], + "stateMutability": "nonpayable", + }, + ], + "bytecode": { + "object": + "0x6080604052348015600f57600080fd5b5061073b8061001f6000396000f3fe608060405234801561001057600080fd5b506004361061004c5760003560e01c8063391b868014610051578063724405e11461006657806391dc956d14610079578063fe290ccb1461008c575b600080fd5b61006461005f366004610356565b6100aa565b005b6100646100743660046103a2565b6100d2565b6100646100873660046103a2565b610127565b610094610234565b6040516100a191906103e4565b60405180910390f35b80600284846040516100bd9291906104bd565b90815260405190819003602001902055505050565b60018083836040516100e59291906104bd565b90815260405190819003602001902080549115157fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff009092169190911790555050565b600182826040516101399291906104bd565b9081526040519081900360200190205460ff16156101b7576040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601e60248201527f43616c6c734c6f673a20636f6e6669677572656420746f207265766572740000604482015260640160405180910390fd5b6000600283836040516101cb9291906104bd565b908152602001604051809103902054905060005a90505b815a6101ee90836104cd565b106101e257600080546001810182559080527f290decd9548b62a8d60345a988386fc84ba6bc95484008f6362f93160ef3e5630161022d8486836105eb565b5050505050565b60606000805480602002602001604051908101604052809291908181526020016000905b828210156103045783829060005260206000200180546102779061053c565b80601f01602080910402602001604051908101604052809291908181526020018280546102a39061053c565b80156102f05780601f106102c5576101008083540402835291602001916102f0565b820191906000526020600020905b8154815290600101906020018083116102d357829003601f168201915b505050505081526020019060010190610258565b50505050905090565b60008083601f84011261031f57600080fd5b50813567ffffffffffffffff81111561033757600080fd5b60208301915083602082850101111561034f57600080fd5b9250929050565b60008060006040848603121561036b57600080fd5b833567ffffffffffffffff81111561038257600080fd5b61038e8682870161030d565b909790965060209590950135949350505050565b600080602083850312156103b557600080fd5b823567ffffffffffffffff8111156103cc57600080fd5b6103d88582860161030d565b90969095509350505050565b6000602082016020835280845180835260408501915060408160051b86010192506020860160005b828110156104b1577fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc08786030184528151805180875260005b8181101561046157602081840181015189830182015201610445565b5060006020828901015260207fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe0601f8301168801019650505060208201915060208401935060018101905061040c565b50929695505050505050565b8183823760009101908152919050565b81810381811115610507577f4e487b7100000000000000000000000000000000000000000000000000000000600052601160045260246000fd5b92915050565b7f4e487b7100000000000000000000000000000000000000000000000000000000600052604160045260246000fd5b600181811c9082168061055057607f821691505b602082108103610589577f4e487b7100000000000000000000000000000000000000000000000000000000600052602260045260246000fd5b50919050565b601f8211156105e657828211156105e657806000526020600020601f840160051c60208510156105bd575060005b90810190601f840160051c0360005b818110156105e2576000838201556001016105cc565b5050505b505050565b67ffffffffffffffff8311156106035761060361050d565b61061783610611835461053c565b8361058f565b6000601f84116001811461066957600085156106335750838201355b7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff600387901b1c1916600186901b17835561022d565b6000838152602090207fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe0861690835b828110156106b85786850135825560209485019460019092019101610698565b50868210156106f3577fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff60f88860031b161c19848701351681555b505060018560011b018355505050505056fea2646970667358221220f729eb02df7f63f3b62f7c9d7972906d54828f5e5539d24ac4df29e86dc9651b64736f6c63430008240033", + "sourceMap": "67:730:0:-:0;;;;;;;;;;;;;;;;;;;", + "linkReferences": {}, + }, + "deployedBytecode": { + "object": + "0x608060405234801561001057600080fd5b506004361061004c5760003560e01c8063391b868014610051578063724405e11461006657806391dc956d14610079578063fe290ccb1461008c575b600080fd5b61006461005f366004610356565b6100aa565b005b6100646100743660046103a2565b6100d2565b6100646100873660046103a2565b610127565b610094610234565b6040516100a191906103e4565b60405180910390f35b80600284846040516100bd9291906104bd565b90815260405190819003602001902055505050565b60018083836040516100e59291906104bd565b90815260405190819003602001902080549115157fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff009092169190911790555050565b600182826040516101399291906104bd565b9081526040519081900360200190205460ff16156101b7576040517f08c379a000000000000000000000000000000000000000000000000000000000815260206004820152601e60248201527f43616c6c734c6f673a20636f6e6669677572656420746f207265766572740000604482015260640160405180910390fd5b6000600283836040516101cb9291906104bd565b908152602001604051809103902054905060005a90505b815a6101ee90836104cd565b106101e257600080546001810182559080527f290decd9548b62a8d60345a988386fc84ba6bc95484008f6362f93160ef3e5630161022d8486836105eb565b5050505050565b60606000805480602002602001604051908101604052809291908181526020016000905b828210156103045783829060005260206000200180546102779061053c565b80601f01602080910402602001604051908101604052809291908181526020018280546102a39061053c565b80156102f05780601f106102c5576101008083540402835291602001916102f0565b820191906000526020600020905b8154815290600101906020018083116102d357829003601f168201915b505050505081526020019060010190610258565b50505050905090565b60008083601f84011261031f57600080fd5b50813567ffffffffffffffff81111561033757600080fd5b60208301915083602082850101111561034f57600080fd5b9250929050565b60008060006040848603121561036b57600080fd5b833567ffffffffffffffff81111561038257600080fd5b61038e8682870161030d565b909790965060209590950135949350505050565b600080602083850312156103b557600080fd5b823567ffffffffffffffff8111156103cc57600080fd5b6103d88582860161030d565b90969095509350505050565b6000602082016020835280845180835260408501915060408160051b86010192506020860160005b828110156104b1577fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffc08786030184528151805180875260005b8181101561046157602081840181015189830182015201610445565b5060006020828901015260207fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe0601f8301168801019650505060208201915060208401935060018101905061040c565b50929695505050505050565b8183823760009101908152919050565b81810381811115610507577f4e487b7100000000000000000000000000000000000000000000000000000000600052601160045260246000fd5b92915050565b7f4e487b7100000000000000000000000000000000000000000000000000000000600052604160045260246000fd5b600181811c9082168061055057607f821691505b602082108103610589577f4e487b7100000000000000000000000000000000000000000000000000000000600052602260045260246000fd5b50919050565b601f8211156105e657828211156105e657806000526020600020601f840160051c60208510156105bd575060005b90810190601f840160051c0360005b818110156105e2576000838201556001016105cc565b5050505b505050565b67ffffffffffffffff8311156106035761060361050d565b61061783610611835461053c565b8361058f565b6000601f84116001811461066957600085156106335750838201355b7fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff600387901b1c1916600186901b17835561022d565b6000838152602090207fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffe0861690835b828110156106b85786850135825560209485019460019092019101610698565b50868210156106f3577fffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff60f88860031b161c19848701351681555b505060018560011b018355505050505056fea2646970667358221220f729eb02df7f63f3b62f7c9d7972906d54828f5e5539d24ac4df29e86dc9651b64736f6c63430008240033", + "sourceMap": + "67:730:0:-:0;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;689:106;;;;;;:::i;:::-;;:::i;:::-;;597:86;;;;;;:::i;:::-;;:::i;217:281::-;;;;;;:::i;:::-;;:::i;504:87::-;;;:::i;:::-;;;;;;;:::i;:::-;;;;;;;;689:106;785:3;765:12;778:3;;765:17;;;;;;;:::i;:::-;;;;;;;;;;;;;;:23;-1:-1:-1;;;689:106:0:o;597:86::-;672:4;657:7;665:3;;657:12;;;;;;;:::i;:::-;;;;;;;;;;;;;;:19;;;;;;;;;;;;;;;-1:-1:-1;;597:86:0:o;217:281::-;282:7;290:3;;282:12;;;;;;;:::i;:::-;;;;;;;;;;;;;;;;;281:13;273:56;;;;;;;3025:2:1;273:56:0;;;3007:21:1;3064:2;3044:18;;;3037:30;3103:32;3083:18;;;3076:60;3153:18;;273:56:0;;;;;;;;340:18;361:12;374:3;;361:17;;;;;;;:::i;:::-;;;;;;;;;;;;;;340:38;;388:11;402:9;388:23;;421:45;446:10;434:9;428:15;;:3;:15;:::i;:::-;:28;458:8;421:45;477:4;:14;;;;;;;;;;;;;487:3;;477:14;;:::i;:::-;;263:235;;217:281;;:::o;504:87::-;546:15;580:4;573:11;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;:::i;:::-;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;:::i;:::-;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;504:87;:::o;14:348:1:-;66:8;76:6;130:3;123:4;115:6;111:17;107:27;97:55;;148:1;145;138:12;97:55;-1:-1:-1;171:20:1;;214:18;203:30;;200:50;;;246:1;243;236:12;200:50;283:4;275:6;271:17;259:29;;335:3;328:4;319:6;311;307:19;303:30;300:39;297:59;;;352:1;349;342:12;297:59;14:348;;;;;:::o;367:479::-;447:6;455;463;516:2;504:9;495:7;491:23;487:32;484:52;;;532:1;529;522:12;484:52;572:9;559:23;605:18;597:6;594:30;591:50;;;637:1;634;627:12;591:50;676:59;727:7;718:6;707:9;703:22;676:59;:::i;:::-;754:8;;650:85;;-1:-1:-1;836:2:1;821:18;;;;808:32;;367:479;-1:-1:-1;;;;367:479:1:o;851:411::-;922:6;930;983:2;971:9;962:7;958:23;954:32;951:52;;;999:1;996;989:12;951:52;1039:9;1026:23;1072:18;1064:6;1061:30;1058:50;;;1104:1;1101;1094:12;1058:50;1143:59;1194:7;1185:6;1174:9;1170:22;1143:59;:::i;:::-;1221:8;;1117:85;;-1:-1:-1;851:411:1;-1:-1:-1;;;;851:411:1:o;1267:1273::-;1429:4;1477:2;1466:9;1462:18;1507:2;1496:9;1489:21;1530:6;1565;1559:13;1596:6;1588;1581:22;1634:2;1623:9;1619:18;1612:25;;1696:2;1686:6;1683:1;1679:14;1668:9;1664:30;1660:39;1646:53;;1734:2;1726:6;1722:15;1755:1;1765:746;1779:6;1776:1;1773:13;1765:746;;;1868:66;1856:9;1848:6;1844:22;1840:95;1835:3;1828:108;1965:6;1959:13;2007:2;2001:9;2038:8;2030:6;2023:24;2071:1;2085:157;2101:8;2096:3;2093:17;2085:157;;;2223:2;2209:12;;;2205:21;;2199:28;2176:16;;;2172:25;;2165:63;2120:12;2085:157;;;2089:3;2294:1;2289:2;2278:8;2270:6;2266:21;2262:30;2255:41;2428:2;2358:66;2353:2;2343:8;2339:17;2335:90;2327:6;2323:103;2319:112;2309:122;;;;2466:2;2458:6;2454:15;2444:25;;2498:2;2493:3;2489:12;2482:19;;1801:1;1798;1794:9;1789:14;;1765:746;;;-1:-1:-1;2528:6:1;;1267:1273;-1:-1:-1;;;;;;1267:1273:1:o;2545:273::-;2730:6;2722;2717:3;2704:33;2686:3;2756:16;;2781:13;;;2756:16;2545:273;-1:-1:-1;2545:273:1:o;3182:282::-;3249:9;;;3270:11;;;3267:191;;;3314:77;3311:1;3304:88;3415:4;3412:1;3405:15;3443:4;3440:1;3433:15;3267:191;3182:282;;;;:::o;3469:184::-;3521:77;3518:1;3511:88;3618:4;3615:1;3608:15;3642:4;3639:1;3632:15;3658:437;3737:1;3733:12;;;;3780;;;3801:61;;3855:4;3847:6;3843:17;3833:27;;3801:61;3908:2;3900:6;3897:14;3877:18;3874:38;3871:218;;3945:77;3942:1;3935:88;4046:4;4043:1;4036:15;4074:4;4071:1;4064:15;3871:218;;3658:437;;;:::o;4226:692::-;4328:2;4323:3;4320:11;4317:595;;;4365:10;4360:3;4357:19;4354:548;;;4417:5;4414:1;4407:16;4465:4;4462:1;4452:18;4530:2;4518:10;4514:19;4511:1;4507:27;4569:4;4557:10;4554:20;4551:45;;;-1:-1:-1;4593:1:1;4551:45;4632:23;;;;4702:2;4693:12;;4690:1;4686:20;4682:39;4747:1;4765:123;4779:2;4776:1;4773:9;4765:123;;;4868:1;4847:19;;;4840:30;4797:1;4790:9;4765:123;;;4769:3;;;4354:548;4226:692;;;:::o;5154:1325::-;5278:18;5273:3;5270:27;5267:53;;;5300:18;;:::i;:::-;5329:94;5419:3;5379:38;5411:4;5405:11;5379:38;:::i;:::-;5373:4;5329:94;:::i;:::-;5449:1;5474:2;5469:3;5466:11;5491:1;5486:735;;;;6265:1;6282:3;6279:93;;;-1:-1:-1;6338:19:1;;;6325:33;6279:93;5060:66;5051:1;5047:11;;;5043:84;5039:89;5029:100;5135:1;5131:11;;;5026:117;6385:78;;5459:1014;;5486:735;4173:1;4166:14;;;4210:4;4197:18;;5531:66;5522:76;;;5682:9;5704:229;5718:7;5715:1;5712:14;5704:229;;;5807:19;;;5794:33;5779:49;;5914:4;5899:20;;;;5867:1;5855:14;;;;5734:12;5704:229;;;5708:3;5961;5952:7;5949:16;5946:219;;;6081:66;6075:3;6069;6066:1;6062:11;6058:21;6054:94;6050:99;6037:9;6032:3;6028:19;6015:33;6011:139;6003:6;5996:155;5946:219;;;6208:1;6202:3;6199:1;6195:11;6191:19;6185:4;6178:33;5459:1014;;5154:1325;;;:::o", + "linkReferences": {}, + }, + "methodIdentifiers": { + "addLog(string)": "91dc956d", + "getLogs()": "fe290ccb", + "setGasPenalty(string,uint256)": "391b8680", + "setReverts(string)": "724405e1", + }, + "rawMetadata": + '{"compiler":{"version":"0.8.36+commit.8a079791"},"language":"Solidity","output":{"abi":[{"inputs":[{"internalType":"string","name":"log","type":"string"}],"name":"addLog","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[],"name":"getLogs","outputs":[{"internalType":"string[]","name":"","type":"string[]"}],"stateMutability":"view","type":"function"},{"inputs":[{"internalType":"string","name":"log","type":"string"},{"internalType":"uint256","name":"gas","type":"uint256"}],"name":"setGasPenalty","outputs":[],"stateMutability":"nonpayable","type":"function"},{"inputs":[{"internalType":"string","name":"log","type":"string"}],"name":"setReverts","outputs":[],"stateMutability":"nonpayable","type":"function"}],"devdoc":{"kind":"dev","methods":{},"version":1},"userdoc":{"kind":"user","methods":{},"version":1}},"settings":{"compilationTarget":{"test/CallsLog.sol":"CallsLog"},"evmVersion":"constantinople","libraries":{},"metadata":{"bytecodeHash":"ipfs"},"optimizer":{"enabled":true,"runs":1000000},"remappings":[":forge-std/=lib/forge-std/src/"]},"sources":{"test/CallsLog.sol":{"keccak256":"0x044b47681edb16b21d862d806586102de2a45af0dcd4ebf69418c8097ae30b7d","license":"GPL-3.0-only","urls":["bzz-raw://619de17bd4f493300b9235be5237f42535c9872cbd892eab4b093aa5a9f3ddbf","dweb:/ipfs/QmSgVxdQVdQ8bNza54saFoq83JhWxY3ygxwtF4ArLEBFd7"]}},"version":1}', + "metadata": { + "compiler": { + "version": "0.8.36+commit.8a079791", + }, + "language": "Solidity", + "output": { + "abi": [ + { + "inputs": [ + { + "internalType": "string", + "name": "log", + "type": "string", + }, + ], + "stateMutability": "nonpayable", + "type": "function", + "name": "addLog", + }, + { + "inputs": [], + "stateMutability": "view", + "type": "function", + "name": "getLogs", + "outputs": [ + { + "internalType": "string[]", + "name": "", + "type": "string[]", + }, + ], + }, + { + "inputs": [ + { + "internalType": "string", + "name": "log", + "type": "string", + }, + { + "internalType": "uint256", + "name": "gas", + "type": "uint256", + }, + ], + "stateMutability": "nonpayable", + "type": "function", + "name": "setGasPenalty", + }, + { + "inputs": [ + { + "internalType": "string", + "name": "log", + "type": "string", + }, + ], + "stateMutability": "nonpayable", + "type": "function", + "name": "setReverts", + }, + ], + "devdoc": { + "kind": "dev", + "methods": {}, + "version": 1, + }, + "userdoc": { + "kind": "user", + "methods": {}, + "version": 1, + }, + }, + "settings": { + "remappings": [ + "forge-std/=lib/forge-std/src/", + ], + "optimizer": { + "enabled": true, + "runs": 1000000, + }, + "metadata": { + "bytecodeHash": "ipfs", + }, + "compilationTarget": { + "test/CallsLog.sol": "CallsLog", + }, + "evmVersion": "constantinople", + "libraries": {}, + }, + "sources": { + "test/CallsLog.sol": { + "keccak256": "0x044b47681edb16b21d862d806586102de2a45af0dcd4ebf69418c8097ae30b7d", + "urls": [ + "bzz-raw://619de17bd4f493300b9235be5237f42535c9872cbd892eab4b093aa5a9f3ddbf", + "dweb:/ipfs/QmSgVxdQVdQ8bNza54saFoq83JhWxY3ygxwtF4ArLEBFd7", + ], + "license": "GPL-3.0-only", + }, + }, + "version": 1, + }, + "id": 0, +} as const; +export const abi = artifact.abi satisfies Abi; +export const bytecode = artifact.bytecode.object satisfies Hex; diff --git a/tests/integration/calls-log.ts b/tests/integration/calls-log.ts new file mode 100644 index 0000000..ae494e1 --- /dev/null +++ b/tests/integration/calls-log.ts @@ -0,0 +1,66 @@ +import { assert, assertEquals } from "@std/assert"; +import { type Address, encodeFunctionData } from "viem"; +import { abi as callsLogAbi, bytecode as callsLogBytecode } from "./calls-log.generated.ts"; +import { anvilClient } from "./anvil.ts"; +import { type SendSequencesArg } from "../../src/app.ts"; + +let callsLogAddress: Address | undefined; + +function getCallsLogAddress(): Address { + assert(callsLogAddress, "CallsLog not deployed yet"); + return callsLogAddress; +} + +// Deploys the CallsLog test fixture from the maintenance wallet and returns its address. +export async function deployCallsLog(): Promise
{ + const hash = await anvilClient.deployContract({ abi: callsLogAbi, bytecode: callsLogBytecode }); + await anvilClient.mine({ blocks: 1 }); + const receipt = await anvilClient.waitForTransactionReceipt({ hash }); + assert(receipt.contractAddress, "CallsLog deployment failed"); + callsLogAddress = receipt.contractAddress; + return callsLogAddress; +} + +export async function assertLogs(expectedLogs: string[]) { + const logs = await anvilClient.readContract({ + address: getCallsLogAddress(), + abi: callsLogAbi, + functionName: "getLogs", + }); + assertEquals(logs, expectedLogs); +} + +type Call = SendSequencesArg["sequences"][number]["bursts"][number]["calls"][number]; + +// A call to the CallsLog fixture's `addLog(value)`, for use in a `SendSequencesArg` burst. +export function addLog(log: string, gas?: number): Call { + return { + target: getCallsLogAddress(), + calldata: encodeFunctionData({ abi: callsLogAbi, functionName: "addLog", args: [log] }), + gas, + }; +} + +// A call to the CallsLog fixture's `setReverts(value)`, for use in a `SendSequencesArg` burst. +// Put it in the same burst as the `addLog(value)` call it's meant to affect, so the two are +// guaranteed to execute in order within a single atomic transaction. +export function setReverts(log: string): Call { + return { + target: getCallsLogAddress(), + calldata: encodeFunctionData({ abi: callsLogAbi, functionName: "setReverts", args: [log] }), + }; +} + +// A call to the CallsLog fixture's `setGasPenalty(value, gas)`, for use in a `SendSequencesArg` +// burst. Put it in the same burst as the `addLog(value)` call it's meant to affect, so the two +// are guaranteed to execute in order within a single atomic transaction. +export function setGasPenalty(log: string, gas: bigint): Call { + return { + target: getCallsLogAddress(), + calldata: encodeFunctionData({ + abi: callsLogAbi, + functionName: "setGasPenalty", + args: [log, gas], + }), + }; +} diff --git a/tests/integration/db.ts b/tests/integration/db.ts new file mode 100644 index 0000000..67eabb4 --- /dev/null +++ b/tests/integration/db.ts @@ -0,0 +1,19 @@ +import postgres from "postgres"; + +// Matches docker-compose.yml's `db` service and the app's own default `DB_URL`. +const sql = postgres("postgres://user:password@localhost:5432/tx_relay"); + +// Reverting Anvil to a checkpoint rolls back its chain state (nonces, contract state, etc.) +// but leaves Postgres untouched, so the two would otherwise drift out of sync between tests +// (e.g. a `tx_senders` row referencing a nonce that no longer has a matching on-chain history). +// This must be called alongside every Anvil revert to keep them in lockstep. +export async function resetDb() { + // Drizzle's own migration-tracking table lives in a separate "drizzle" schema, so this + // naturally truncates only the app's own tables without needing to name them individually. + const tables = await sql<{ tablename: string }[]>` + SELECT tablename FROM pg_tables WHERE schemaname = 'public' + `; + if (tables.length === 0) return; + const identifiers = tables.map(({ tablename }) => `"${tablename}"`).join(", "); + await sql.unsafe(`TRUNCATE ${identifiers} RESTART IDENTITY CASCADE`); +} diff --git a/tests/integration/relay.test.ts b/tests/integration/relay.test.ts new file mode 100644 index 0000000..4bc63d6 --- /dev/null +++ b/tests/integration/relay.test.ts @@ -0,0 +1,1076 @@ +import { assert, assertEquals } from "@std/assert"; +import { deadline, delay } from "async"; +import { createWalletClient, http, parseEther, publicActions, type Transaction } from "viem"; +import { privateKeyToAccount } from "viem/accounts"; +import { + assertSequenceState, + confirmations, + inclusionWaitBlocks, + minGasIncreasePercent, + otherWorkerPrivateKey, + sendSequences, + startApp, + stopApp, + workerPrivateKey, +} from "./app.ts"; +import { + anvilClient, + baseTxCost, + dropPendingTxs, + etchSingletonFactory, + getTxpoolTxs, + mineEmptyBlocks, + resetToGenesis, + resubmitTx, + setBlockGasLimit, + waitForNewTxpoolTx, + waitForTxpoolCounts, +} from "./anvil.ts"; +import { addLog, assertLogs, deployCallsLog, setGasPenalty, setReverts } from "./calls-log.ts"; +import { resetDb } from "./db.ts"; +import { type SendSequencesArg } from "../../src/app.ts"; +import { type SequenceEvent } from "../../src/db/schema.ts"; + +const chainId = anvilClient.chain.id; + +let checkpointId: `0x${string}`; +let app: Deno.ChildProcess; + +// Requires automine to be off. Waits for exactly one pending transaction and none queued, then +// mines it plus its confirmation blocks, returning the block it landed in. +// Mining is unaffected by automine being off - it still packs in whatever is pending. +// +// Mined in two steps with a real pause in between, rather than one fixed-size batch: the worker's +// own check for whether something else has taken its nonce (see `watchTxs`) can rarely race its +// receipt check at this mining speed (never at real block times), taking a detour that needs +// `confirmations` more blocks than usual. That detour computes its wait target from the chain +// height at the moment it's taken, so a single, larger mine call can't pre-empt it - the target +// just becomes that much higher, always one `confirmations` step past whatever was last mined. +// Splitting into two calls lets that target, if taken, settle against the first call's height +// before the second one covers it. +async function mineNextTx(): Promise { + await waitForTxpoolCounts(1); + const [{ hash }] = await getTxpoolTxs(); + await anvilClient.mine({ blocks: 1 + confirmations }); + await delay(20); + await anvilClient.mine({ blocks: confirmations }); + return (await anvilClient.getTransactionReceipt({ hash })).blockNumber; +} + +// Asserts that `secondTx` is a valid repriced replacement of `firstTx` - both EIP-1559, with both +// fee fields increased by at least `minGasIncreasePercent`, but never by more than 1.5x the +// current network fee estimate (see `increasedFees` in worker.ts, which refuses to reprice past +// that cap at all). +// +// The current estimate is fetched via `prepareTransactionRequest`, the same call the worker +// itself uses, rather than the lower-level `estimateFeesPerGas` - the latter's own fee padding +// isn't necessarily what the worker's client actually applies, and comparing against it directly +// proved unreliable. +async function assertRepriced(firstTx: Transaction, secondTx: Transaction) { + assert( + firstTx.type === "eip1559" && secondTx.type === "eip1559", + "Expected both TXs to be EIP-1559", + ); + const minIncrease = (fee: bigint) => fee * BigInt(100 + minGasIncreasePercent) / 100n; + const maxCap = (fee: bigint) => fee * 150n / 100n; + const { maxFeePerGas: currentFee, maxPriorityFeePerGas: currentPriorityFee } = await anvilClient + .prepareTransactionRequest({ account: anvilClient.account, to: anvilClient.account.address }); + + assert( + secondTx.maxFeePerGas >= minIncrease(firstTx.maxFeePerGas), + `Expected maxFeePerGas to increase by at least ${minGasIncreasePercent}%, went from ` + + `${firstTx.maxFeePerGas} to ${secondTx.maxFeePerGas}`, + ); + assert( + secondTx.maxFeePerGas <= maxCap(currentFee), + `Expected maxFeePerGas to not exceed 1.5x the current fee estimate (${currentFee}), got ` + + `${secondTx.maxFeePerGas}`, + ); + assert( + secondTx.maxPriorityFeePerGas >= minIncrease(firstTx.maxPriorityFeePerGas), + `Expected maxPriorityFeePerGas to increase by at least ${minGasIncreasePercent}%, went from ` + + `${firstTx.maxPriorityFeePerGas} to ${secondTx.maxPriorityFeePerGas}`, + ); + assert( + secondTx.maxPriorityFeePerGas <= maxCap(currentPriorityFee), + `Expected maxPriorityFeePerGas to not exceed 1.5x the current fee estimate ` + + `(${currentPriorityFee}), got ${secondTx.maxPriorityFeePerGas}`, + ); +} + +Deno.test.beforeAll(async () => { + // Disabled for the whole suite so every transaction has to be mined explicitly (see + // `mineNextTx`), rather than relying on Anvil's automine to include it as soon as it's sent. + await anvilClient.setAutomine(false); + await resetToGenesis(); + await setBlockGasLimit(); + await etchSingletonFactory(); + await deployCallsLog(); +}); + +Deno.test.beforeEach(async () => { + await resetDb(); + if (checkpointId) await anvilClient.revert({ id: checkpointId }); + checkpointId = await anvilClient.snapshot(); + + // The app also carries its own in-memory state (e.g. transactions it believes are still + // in-flight), which no individual test can be trusted to leave clean, so it's restarted + // fresh here rather than relying on each test to manage its own lifecycle correctly. + app = await startApp(); + // Every test starts from the checkpoint taken right after CallsLog was deployed, so the + // Executor is never deployed yet and this app instance always has to (re)deploy it here, + // which needs confirming before the relay can make any further progress. + await mineNextTx(); +}); + +Deno.test.afterEach(async () => { + await stopApp(app); +}); + +Deno.test.afterAll(async () => { + // Automine is a node-level setting, not chain state, so `evm_revert`/`anvil_reset` never + // restore it - it must always be re-enabled explicitly, or it leaks into whatever runs next + // against this Anvil instance. + await anvilClient.setAutomine(true); + await resetToGenesis(); +}); + +Deno.test({ + name: "smoke: app starts up and its worker connects to the chain", + timeout: 30_000, + // `beforeEach` already asserts this by successfully starting the app - nothing further to do. + fn() {}, +}); + +Deno.test({ + name: "send-sequences: executes a successful call", + timeout: 30_000, + async fn() { + const arg: SendSequencesArg = { + sequences: [{ chainId, bursts: [{ calls: [addLog("ok")] }] }], + }; + const [successId] = await sendSequences(arg); + await mineNextTx(); + + await assertSequenceState(successId, { successes: 1, failures: 0 }, [ + { kind: "created", details: { burstsCount: 1 } }, + { kind: "submitted", details: { fromIdxInSequence: 0, burstsCount: 1 } }, + { kind: "executed", details: { fromIdxInSequence: 0, successes: 1, failed: false } }, + ]); + await assertLogs(["ok"]); + }, +}); + +Deno.test({ + name: "send-sequences: rejects a reverting call", + timeout: 30_000, + async fn() { + // `setReverts` and `addLog` are in the same burst, so they execute together in a single + // atomic transaction - there's no window where `addLog` could run against stale state. + const arg: SendSequencesArg = { + sequences: [{ chainId, bursts: [{ calls: [setReverts("bad"), addLog("bad")] }] }], + }; + const [revertingId] = await sendSequences(arg); + + await assertSequenceState(revertingId, { successes: 0, failures: 1 }, [ + { kind: "created", details: { burstsCount: 1 } }, + { kind: "rejected", details: { fromIdxInSequence: 0 } }, + ]); + await assertLogs([]); + }, +}); + +Deno.test({ + name: "send-sequences: a failing burst skips the rest until it's reached, then fails them too", + timeout: 30_000, + async fn() { + // The 2nd burst fails alongside the 1st being accepted into the same batch, so it's left + // pending (skipped) rather than rejected outright - the 3rd is never even considered, since + // a sequence's bursts only run in order. + const arg: SendSequencesArg = { + sequences: [{ + chainId, + bursts: [ + { calls: [addLog("a")] }, + { calls: [setReverts("bad"), addLog("bad")] }, + { calls: [addLog("c")] }, + ], + }], + }; + const [sequenceId] = await sendSequences(arg); + // Only the 1st burst's transaction is pending - the 2nd (and, behind it, the 3rd) was + // skipped, not rejected outright, so all 3 bursts are still pending at this point. + await waitForTxpoolCounts(1); + await assertSequenceState(sequenceId, { successes: 0, failures: 0, pending: 3 }); + await mineNextTx(); + + // Once the 1st burst is mined, the 2nd is retried as the sole item in a fresh batch, so this + // time it's rejected outright - which fails the rest of the sequence (the 3rd) without ever + // attempting it. + await assertSequenceState(sequenceId, { successes: 1, failures: 2 }, [ + { kind: "created", details: { burstsCount: 3 } }, + { kind: "submitted", details: { fromIdxInSequence: 0, burstsCount: 1 } }, + { kind: "executed", details: { fromIdxInSequence: 0, successes: 1, failed: false } }, + { kind: "rejected", details: { fromIdxInSequence: 1 } }, + ]); + await assertLogs(["a"]); + }, +}); + +Deno.test({ + name: "send-sequences: a failing 1st burst fails only its own sequence, immediately and fully", + timeout: 30_000, + async fn() { + // Unlike a later burst in an already-running sequence, a 1st burst always rejects outright + // on failure, regardless of what else is in the same batch - so the 2nd sequence here fails + // immediately, without affecting the 1st. + const arg: SendSequencesArg = { + sequences: [ + { chainId, bursts: [{ calls: [addLog("ok")] }] }, + { chainId, bursts: [{ calls: [setReverts("bad"), addLog("bad")] }] }, + ], + }; + const [successId, revertingId] = await sendSequences(arg); + await assertSequenceState(revertingId, { successes: 0, failures: 1 }, [ + { kind: "created", details: { burstsCount: 1 } }, + { kind: "rejected", details: { fromIdxInSequence: 0 } }, + ]); + await mineNextTx(); + + await assertSequenceState(successId, { successes: 1, failures: 0 }, [ + { kind: "created", details: { burstsCount: 1 } }, + { kind: "submitted", details: { fromIdxInSequence: 0, burstsCount: 1 } }, + { kind: "executed", details: { fromIdxInSequence: 0, successes: 1, failed: false } }, + ]); + await assertLogs(["ok"]); + }, +}); + +Deno.test({ + name: "send-sequences: a 2nd sequence triggering a revert set up by the 1st is skipped, " + + "then fails once the 1st is mined", + timeout: 30_000, + async fn() { + const arg: SendSequencesArg = { + sequences: [ + { chainId, bursts: [{ calls: [setReverts("bad")] }] }, + { chainId, bursts: [{ calls: [addLog("bad")] }] }, + ], + }; + const [setupId, triggerId] = await sendSequences(arg); + // Only the setup sequence's transaction is pending - the trigger sequence was skipped, not + // rejected outright, so it's still pending too at this point. + await waitForTxpoolCounts(1); + await assertSequenceState(setupId, { successes: 0, failures: 0, pending: 1 }); + await assertSequenceState(triggerId, { successes: 0, failures: 0, pending: 1 }); + await mineNextTx(); + + await assertSequenceState(setupId, { successes: 1, failures: 0 }, [ + { kind: "created", details: { burstsCount: 1 } }, + { kind: "submitted", details: { fromIdxInSequence: 0, burstsCount: 1 } }, + { kind: "executed", details: { fromIdxInSequence: 0, successes: 1, failed: false } }, + ]); + await assertSequenceState(triggerId, { successes: 0, failures: 1 }, [ + { kind: "created", details: { burstsCount: 1 } }, + { kind: "rejected", details: { fromIdxInSequence: 0 } }, + ]); + await assertLogs([]); + }, +}); + +Deno.test({ + name: "send-sequences: a burst burns 1.1M gas", + timeout: 30_000, + async fn() { + // The block gas limit is 1M (see `setBlockGasLimit` in `beforeAll`), so this can never fit + // in any block - it should be rejected outright, without ever being submitted on-chain. + const arg: SendSequencesArg = { + sequences: [{ + chainId, + bursts: [{ calls: [setGasPenalty("heavy", 1_100_000n), addLog("heavy")] }], + }], + }; + const [sequenceId] = await sendSequences(arg); + + await assertSequenceState(sequenceId, { successes: 0, failures: 1 }, [ + { kind: "created", details: { burstsCount: 1 } }, + { kind: "rejected", details: { fromIdxInSequence: 0 } }, + ]); + await assertLogs([]); + }, +}); + +Deno.test({ + name: "send-sequences: burst 1 burns 600K gas, burst 2 500K gas", + timeout: 30_000, + async fn() { + // Each burst fits in a block alone, but not together, so the 2nd doesn't fit in the same + // batch as the 1st - it's left pending (not failed) and only succeeds once retried once the + // 1st is mined and out of the way. + const arg: SendSequencesArg = { + sequences: [{ + chainId, + bursts: [ + { calls: [setGasPenalty("a", 600_000n), addLog("a")] }, + { calls: [setGasPenalty("b", 500_000n), addLog("b")] }, + ], + }], + }; + const [sequenceId] = await sendSequences(arg); + await mineNextTx(); + await assertSequenceState(sequenceId, { successes: 1, failures: 0, pending: 1 }); + await mineNextTx(); + + await assertSequenceState(sequenceId, { successes: 2, failures: 0 }, [ + { kind: "created", details: { burstsCount: 2 } }, + { kind: "submitted", details: { fromIdxInSequence: 0, burstsCount: 1 } }, + { kind: "executed", details: { fromIdxInSequence: 0, successes: 1, failed: false } }, + { kind: "submitted", details: { fromIdxInSequence: 1, burstsCount: 1 } }, + { kind: "executed", details: { fromIdxInSequence: 1, successes: 1, failed: false } }, + ]); + await assertLogs(["a", "b"]); + }, +}); + +Deno.test({ + name: "send-sequences: burst 1 burns 0 gas, burst 2 1.1M gas", + timeout: 30_000, + async fn() { + // Burst 1 doesn't fit alongside burst 2 either, so it's initially left pending too, just + // like the 600K/500K case. But once burst 1 is mined, burst 2 becomes the earliest pending + // burst of its own sequence, and - since it alone already exceeds the 1M block gas limit - + // it's rejected outright rather than merely skipped again. + const arg: SendSequencesArg = { + sequences: [{ + chainId, + bursts: [ + { calls: [addLog("a")] }, + { calls: [setGasPenalty("heavy", 1_100_000n), addLog("heavy")] }, + ], + }], + }; + const [sequenceId] = await sendSequences(arg); + // Only burst 1's transaction is pending - burst 2 was skipped, not rejected outright, so the + // sequence still has both bursts pending at this point. + await waitForTxpoolCounts(1); + await assertSequenceState(sequenceId, { successes: 0, failures: 0, pending: 2 }); + await mineNextTx(); + + await assertSequenceState(sequenceId, { successes: 1, failures: 1 }, [ + { kind: "created", details: { burstsCount: 2 } }, + { kind: "submitted", details: { fromIdxInSequence: 0, burstsCount: 1 } }, + { kind: "executed", details: { fromIdxInSequence: 0, successes: 1, failed: false } }, + { kind: "rejected", details: { fromIdxInSequence: 1 } }, + ]); + await assertLogs(["a"]); + }, +}); + +Deno.test({ + name: "send-sequences: sequence 1 burns 600K gas, sequence 2 500K gas", + timeout: 30_000, + async fn() { + // Same as bursts within one sequence not fitting together, but across two independent + // sequences instead - the 2nd is left pending until the 1st is mined and out of the way. + const arg: SendSequencesArg = { + sequences: [ + { chainId, bursts: [{ calls: [setGasPenalty("a", 600_000n), addLog("a")] }] }, + { chainId, bursts: [{ calls: [setGasPenalty("b", 500_000n), addLog("b")] }] }, + ], + }; + const [seq1Id, seq2Id] = await sendSequences(arg); + await mineNextTx(); + await assertSequenceState(seq1Id, { successes: 1, failures: 0 }); + await assertSequenceState(seq2Id, { successes: 0, failures: 0, pending: 1 }); + await mineNextTx(); + const expectedSingleBurstEvents: SequenceEvent[] = [ + { kind: "created", details: { burstsCount: 1 } }, + { kind: "submitted", details: { fromIdxInSequence: 0, burstsCount: 1 } }, + { kind: "executed", details: { fromIdxInSequence: 0, successes: 1, failed: false } }, + ]; + await assertSequenceState(seq1Id, { successes: 1, failures: 0 }, expectedSingleBurstEvents); + await assertSequenceState(seq2Id, { successes: 1, failures: 0 }, expectedSingleBurstEvents); + await assertLogs(["a", "b"]); + }, +}); + +Deno.test({ + name: "send-sequences: sequence 1 burns 600K gas, sequence 2 0 gas then 500K gas", + timeout: 30_000, + async fn() { + // Sequence 2's 1st burst (0 gas) is cheap enough to fit alongside sequence 1's in the same + // batch and mines together with it, but its 2nd burst (500K) doesn't fit alongside sequence + // 1's - it's left pending until sequence 1's burst is mined and out of the way. + const arg: SendSequencesArg = { + sequences: [ + { chainId, bursts: [{ calls: [setGasPenalty("a", 600_000n), addLog("a")] }] }, + { + chainId, + bursts: [ + { calls: [addLog("b")] }, + { calls: [setGasPenalty("c", 500_000n), addLog("c")] }, + ], + }, + ], + }; + const [seq1Id, seq2Id] = await sendSequences(arg); + await mineNextTx(); + await assertSequenceState(seq1Id, { successes: 1, failures: 0 }); + await assertSequenceState(seq2Id, { successes: 1, failures: 0, pending: 1 }); + await mineNextTx(); + + await assertSequenceState(seq1Id, { successes: 1, failures: 0 }, [ + { kind: "created", details: { burstsCount: 1 } }, + { kind: "submitted", details: { fromIdxInSequence: 0, burstsCount: 1 } }, + { kind: "executed", details: { fromIdxInSequence: 0, successes: 1, failed: false } }, + ]); + await assertSequenceState(seq2Id, { successes: 2, failures: 0 }, [ + { kind: "created", details: { burstsCount: 2 } }, + { kind: "submitted", details: { fromIdxInSequence: 0, burstsCount: 1 } }, + { kind: "executed", details: { fromIdxInSequence: 0, successes: 1, failed: false } }, + { kind: "submitted", details: { fromIdxInSequence: 1, burstsCount: 1 } }, + { kind: "executed", details: { fromIdxInSequence: 1, successes: 1, failed: false } }, + ]); + await assertLogs(["a", "b", "c"]); + }, +}); + +Deno.test({ + name: "send-sequences: a call's gas limit is enforced", + timeout: 30_000, + async fn() { + // Both calls burn close to a 200K gas limit, but only the 2nd exceeds it - proving the + // limit is actually enforced on-chain, not just advisory. + const arg: SendSequencesArg = { + sequences: [ + { + chainId, + bursts: [{ calls: [setGasPenalty("under", 100_000n), addLog("under", 200_000)] }], + }, + { + chainId, + bursts: [{ calls: [setGasPenalty("over", 201_000n), addLog("over", 200_000)] }], + }, + ], + }; + const [underId, overId] = await sendSequences(arg); + await assertSequenceState(overId, { successes: 0, failures: 1 }, [ + { kind: "created", details: { burstsCount: 1 } }, + { kind: "rejected", details: { fromIdxInSequence: 0 } }, + ]); + await mineNextTx(); + + await assertSequenceState(underId, { successes: 1, failures: 0 }, [ + { kind: "created", details: { burstsCount: 1 } }, + { kind: "submitted", details: { fromIdxInSequence: 0, burstsCount: 1 } }, + { kind: "executed", details: { fromIdxInSequence: 0, successes: 1, failed: false } }, + ]); + await assertLogs(["under"]); + }, +}); + +Deno.test({ + name: "send-sequences: gasBufferPercent inflates the transaction's gas limit", + timeout: 30_000, + async fn() { + // 50% on top of the ~500K actually needed should give a transaction gas limit of at least + // 750K, not just however much the burst actually used. + const arg: SendSequencesArg = { + sequences: [{ + chainId, + bursts: [{ + gasBufferPercent: 50, + calls: [setGasPenalty("buffered", 500_000n), addLog("buffered")], + }], + }], + }; + const [sequenceId] = await sendSequences(arg); + const blockNumber = await mineNextTx(); + + await assertSequenceState(sequenceId, { successes: 1, failures: 0 }, [ + { kind: "created", details: { burstsCount: 1 } }, + { kind: "submitted", details: { fromIdxInSequence: 0, burstsCount: 1 } }, + { kind: "executed", details: { fromIdxInSequence: 0, successes: 1, failed: false } }, + ]); + await assertLogs(["buffered"]); + + const block = await anvilClient.getBlock({ blockNumber, includeTransactions: true }); + assertEquals(block.transactions.length, 1); + assert( + block.transactions[0].gas >= 750_000n, + `Expected a gas limit of at least 750000, got ${block.transactions[0].gas}`, + ); + }, +}); + +Deno.test({ + name: "send-sequences: a sequence's transaction is picked up again after the app restarts", + timeout: 30_000, + async fn() { + const arg: SendSequencesArg = { + sequences: [{ chainId, bursts: [{ calls: [addLog("ok")] }] }], + }; + const [sequenceId] = await sendSequences(arg); + await waitForTxpoolCounts(1); + + await stopApp(app); + app = await startApp(); + + await mineNextTx(); + await assertSequenceState(sequenceId, { successes: 1, failures: 0 }, [ + { kind: "created", details: { burstsCount: 1 } }, + { kind: "submitted", details: { fromIdxInSequence: 0, burstsCount: 1 } }, + { kind: "executed", details: { fromIdxInSequence: 0, successes: 1, failed: false } }, + ]); + await assertLogs(["ok"]); + }, +}); + +Deno.test({ + name: "send-sequences: after a restart with a different wallet, " + + "a stale TX is skipped and resent from the new wallet", + timeout: 30_000, + async fn() { + const arg: SendSequencesArg = { + sequences: [{ chainId, bursts: [{ calls: [addLog("ok")] }] }], + }; + const [sequenceId] = await sendSequences(arg); + await waitForTxpoolCounts(1); + + await stopApp(app); + + // Dropped instead of just left unmined, since mining always packs in whatever's pending + // regardless of automine - it would otherwise get mined as soon as the next blocks are. + await dropPendingTxs(); + + app = await startApp(otherWorkerPrivateKey); + // `startApp` only waits for `/health` to report the worker as running, not for it to have + // reached the point of recording the block number it'll wait `inclusionWaitBlocks` from - + // mining immediately risks the worker capturing a later block than intended, undercounting + // how many blocks are actually left to mine below. + await delay(100); + + // The old wallet's TX is never mined, so once the new wallet has waited `inclusionWaitBlocks` + // for it to land, it gives up - the sequence's burst is left pending, to be sent again. + await anvilClient.mine({ blocks: inclusionWaitBlocks }); + await assertSequenceState(sequenceId, { successes: 0, failures: 0, pending: 1 }); + + const blockNumber = await mineNextTx(); + await assertSequenceState(sequenceId, { successes: 1, failures: 0 }, [ + { kind: "created", details: { burstsCount: 1 } }, + { kind: "submitted", details: { fromIdxInSequence: 0, burstsCount: 1 } }, + { kind: "skipped", details: {} }, + { kind: "submitted", details: { fromIdxInSequence: 0, burstsCount: 1 } }, + { kind: "executed", details: { fromIdxInSequence: 0, successes: 1, failed: false } }, + ]); + await assertLogs(["ok"]); + + const block = await anvilClient.getBlock({ blockNumber, includeTransactions: true }); + assertEquals(block.transactions.length, 1); + assertEquals( + block.transactions[0].from.toLowerCase(), + privateKeyToAccount(otherWorkerPrivateKey).address.toLowerCase(), + ); + }, +}); + +Deno.test({ + name: "send-sequences: a dropped TX is repriced, then the original is restored and mined", + timeout: 30_000, + async fn() { + const arg: SendSequencesArg = { + sequences: [{ chainId, bursts: [{ calls: [addLog("ok")] }] }], + }; + const [sequenceId] = await sendSequences(arg); + await waitForTxpoolCounts(1); + + // Captured in full (including its signature) before being dropped, so it can be resubmitted + // verbatim later - proving the worker still recognizes success via an earlier attempt, not + // just its latest one. + const [{ hash: firstTxHash }] = await getTxpoolTxs(); + const firstTx = await anvilClient.getTransaction({ hash: firstTxHash }); + await dropPendingTxs(); + + // The worker gives up waiting on the dropped TX after `inclusionWaitBlocks` and sends a + // repriced retry at the same nonce. + await anvilClient.mine({ blocks: inclusionWaitBlocks }); + await waitForTxpoolCounts(1); + await dropPendingTxs(); + + // The original TX is restored - despite the repriced retry being the worker's latest + // attempt, this earlier one lands and mines instead. + await resubmitTx(firstTx); + await mineNextTx(); + + await assertSequenceState(sequenceId, { successes: 1, failures: 0 }, [ + { kind: "created", details: { burstsCount: 1 } }, + { kind: "submitted", details: { fromIdxInSequence: 0, burstsCount: 1 } }, + { kind: "executed", details: { fromIdxInSequence: 0, successes: 1, failed: false } }, + ]); + await assertLogs(["ok"]); + }, +}); + +Deno.test({ + name: "send-sequences: a TX priced out by rising fees is repriced and mined", + timeout: 30_000, + async fn() { + const arg: SendSequencesArg = { + sequences: [{ chainId, bursts: [{ calls: [addLog("ok")] }] }], + }; + const [sequenceId] = await sendSequences(arg); + await waitForTxpoolCounts(1); + + const [{ hash: firstTxHash }] = await getTxpoolTxs(); + const firstTx = await anvilClient.getTransaction({ hash: firstTxHash }); + + // The TX is left genuinely pending the whole time, the same as if real fees had simply risen + // past what it offered - unlike dropping it outright. + await mineEmptyBlocks(inclusionWaitBlocks); + + // The worker gives up waiting on the original TX after `inclusionWaitBlocks` and sends a + // repriced retry at the same nonce - Anvil drops the original from the pool on its own once a + // valid, pricier replacement for the same nonce arrives, so the pool holds only the new one. + const secondTxHash = await waitForNewTxpoolTx(firstTxHash); + const secondTx = await anvilClient.getTransaction({ hash: secondTxHash }); + await assertRepriced(firstTx, secondTx); + + await mineNextTx(); + await assertSequenceState(sequenceId, { successes: 1, failures: 0 }, [ + { kind: "created", details: { burstsCount: 1 } }, + { kind: "submitted", details: { fromIdxInSequence: 0, burstsCount: 1 } }, + { kind: "executed", details: { fromIdxInSequence: 0, successes: 1, failed: false } }, + ]); + await assertLogs(["ok"]); + }, +}); + +Deno.test({ + name: "send-sequences: repriced retries cap out at 1.5x, then the burst is skipped and resent", + timeout: 30_000, + async fn() { + const arg: SendSequencesArg = { + sequences: [{ chainId, bursts: [{ calls: [addLog("ok")] }] }], + }; + const [sequenceId] = await sendSequences(arg); + await waitForTxpoolCounts(1); + + let [{ hash: txHash }] = await getTxpoolTxs(); + let tx = await anvilClient.getTransaction({ hash: txHash }); + + // Each repriced retry - whether resending the burst's own TX or, once that's been tried 3 + // times, burning the nonce instead (see `sendTxAttempt`/`burnNonce`) - bumps the fee by at + // least `minGasIncreasePercent` over the last one actually sent. Compounded, that soon demands + // more than 1.5x the network's current fee estimate, which the worker refuses to pay - at that + // point it stops sending anything further, leaving the last attempt sitting pending. + let repriceCount = 0; + while (true) { + await mineEmptyBlocks(inclusionWaitBlocks); + const newTxHash = await deadline(waitForNewTxpoolTx(txHash), 500) + .catch((error) => { + if (error instanceof DOMException && error.name === "TimeoutError") return undefined; + throw error; + }); + if (!newTxHash) break; + const newTx = await anvilClient.getTransaction({ hash: newTxHash }); + await assertRepriced(tx, newTx); + tx = newTx; + txHash = newTxHash; + repriceCount++; + } + assert( + repriceCount >= 2, + `Expected at least 2 repriced retries before capping, got ${repriceCount}`, + ); + + // The last (capped) attempt still gets mined normally, skipping the burst instead of failing + // it - then it's resent fresh, with a reset fee estimate, at the next nonce and succeeds. + await mineNextTx(); + await assertSequenceState(sequenceId, { successes: 0, failures: 0, pending: 1 }); + await mineNextTx(); + await assertSequenceState(sequenceId, { successes: 1, failures: 0 }, [ + { kind: "created", details: { burstsCount: 1 } }, + { kind: "submitted", details: { fromIdxInSequence: 0, burstsCount: 1 } }, + { kind: "skipped", details: {} }, + { kind: "submitted", details: { fromIdxInSequence: 0, burstsCount: 1 } }, + { kind: "executed", details: { fromIdxInSequence: 0, successes: 1, failed: false } }, + ]); + await assertLogs(["ok"]); + }, +}); + +Deno.test({ + name: "send-sequences: reviving an older reprice makes the next one match it, not the newest", + timeout: 30_000, + async fn() { + const arg: SendSequencesArg = { + sequences: [{ chainId, bursts: [{ calls: [addLog("ok")] }] }], + }; + const [sequenceId] = await sendSequences(arg); + await waitForTxpoolCounts(1); + + const [{ hash: firstTxHash }] = await getTxpoolTxs(); + const firstTx = await anvilClient.getTransaction({ hash: firstTxHash }); + + // 1st reprice: the original TX is priced out, so the app sends a repriced replacement - + // captured in full so it can be restored later, the same as in the single-reprice case. + await mineEmptyBlocks(inclusionWaitBlocks); + const secondTxHash = await waitForNewTxpoolTx(firstTxHash); + const secondTx = await anvilClient.getTransaction({ hash: secondTxHash }); + await assertRepriced(firstTx, secondTx); + + // 2nd reprice: the 1st reprice is ALSO priced out, so the app sends another, pricier one. + await mineEmptyBlocks(inclusionWaitBlocks); + const thirdTxHash = await waitForNewTxpoolTx(secondTxHash); + const thirdTx = await anvilClient.getTransaction({ hash: thirdTxHash }); + await assertRepriced(secondTx, thirdTx); + + // The 2nd reprice is dropped, and the 1st reprice - captured above, before Anvil's own + // replace-by-fee handling evicted it in favor of the 2nd - is restored in its place. + await dropPendingTxs(); + await resubmitTx(secondTx); + await waitForTxpoolCounts(1); + + // The app still believes its latest attempt was the (now-gone) 2nd reprice, but since that's + // no longer found on-chain, `increasedFees` falls back to the next hash it still recognizes - + // the 1st reprice, now visibly pending again - and reprices relative to THAT, not the 2nd. By + // now the burst's own TX has already been retried 3 times (the original plus the 1st and 2nd + // reprices), so this 3rd reprice is the nonce-burning attempt (see + // `sendTxAttempt`/`burnNonce`) - which reprices exactly the same way, so the same invariant + // still applies. + await mineEmptyBlocks(inclusionWaitBlocks); + const fourthTxHash = await waitForNewTxpoolTx(secondTxHash); + const fourthTx = await anvilClient.getTransaction({ hash: fourthTxHash }); + await assertRepriced(secondTx, fourthTx); + // Asserting fourthTx's exact fee (rather than just that it's NOT a bump over the 2nd) would be + // fragile - the network's fee estimate can drift block to block, even on Anvil, so the 1st + // reprice's own bump isn't guaranteed to reproduce bit-for-bit. + const minIncrease = (fee: bigint) => fee * BigInt(100 + minGasIncreasePercent) / 100n; + assert( + fourthTx.maxFeePerGas! < minIncrease(thirdTx.maxFeePerGas!) || + fourthTx.maxPriorityFeePerGas! < minIncrease(thirdTx.maxPriorityFeePerGas!), + "Expected the 3rd reprice to NOT be a bump over the 2nd - got " + + `${fourthTx.maxFeePerGas}/${fourthTx.maxPriorityFeePerGas}, which is already a ` + + `${minGasIncreasePercent}% bump over the 2nd's ` + + `${thirdTx.maxFeePerGas}/${thirdTx.maxPriorityFeePerGas}`, + ); + + // The burn TX still gets mined normally, skipping the burst instead of failing it - then it's + // resent fresh, with a reset fee estimate, at the next nonce and succeeds. + await mineNextTx(); + await assertSequenceState(sequenceId, { successes: 0, failures: 0, pending: 1 }); + await mineNextTx(); + await assertSequenceState(sequenceId, { successes: 1, failures: 0 }, [ + { kind: "created", details: { burstsCount: 1 } }, + { kind: "submitted", details: { fromIdxInSequence: 0, burstsCount: 1 } }, + { kind: "skipped", details: {} }, + { kind: "submitted", details: { fromIdxInSequence: 0, burstsCount: 1 } }, + { kind: "executed", details: { fromIdxInSequence: 0, successes: 1, failed: false } }, + ]); + await assertLogs(["ok"]); + }, +}); + +Deno.test({ + name: "send-sequences: a nonce bumped directly (not via a real TX) also makes the app skip " + + "and resend", + timeout: 30_000, + async fn() { + const arg: SendSequencesArg = { + sequences: [{ chainId, bursts: [{ calls: [addLog("ok")] }] }], + }; + const [sequenceId] = await sendSequences(arg); + await waitForTxpoolCounts(1); + + const [{ hash: firstTxHash }] = await getTxpoolTxs(); + const { nonce } = await anvilClient.getTransaction({ hash: firstTxHash }); + await dropPendingTxs(); + + // Bumps the worker's nonce directly, via Anvil's own test API, rather than through any real + // TX - the app only ever observes the nonce being ahead of what it expects, regardless of + // whether that's from a competing TX or anything else. + const workerAddress = privateKeyToAccount(workerPrivateKey).address; + await anvilClient.setNonce({ address: workerAddress, nonce: nonce + 1 }); + + // Noticing the nonce skip, then sending, mining and confirming the resend all need further + // confirmations that can't be pinned to an exact block - each is mined with a short pause so + // the worker (a separate process) gets a chance to react before the next one lands, and the + // sequence's final state is polled for below rather than any one intermediate step. One more + // round than the dummy-TX version of this test, which gets the mining its own dummy TX needs + // as a head start for free - there's no such TX here to mine. + for (let i = 0; i < 6; i++) { + await delay(20); + await anvilClient.mine({ blocks: 1 }); + } + + await assertSequenceState(sequenceId, { successes: 1, failures: 0 }, [ + { kind: "created", details: { burstsCount: 1 } }, + { kind: "submitted", details: { fromIdxInSequence: 0, burstsCount: 1 } }, + { kind: "skipped", details: {} }, + { kind: "submitted", details: { fromIdxInSequence: 0, burstsCount: 1 } }, + { kind: "executed", details: { fromIdxInSequence: 0, successes: 1, failed: false } }, + ]); + await assertLogs(["ok"]); + }, +}); + +Deno.test({ + name: "send-sequences: an early attempt is restored and mined after nonce burning starts", + timeout: 30_000, + async fn() { + const arg: SendSequencesArg = { + sequences: [{ chainId, bursts: [{ calls: [addLog("ok")] }] }], + }; + const [sequenceId] = await sendSequences(arg); + await waitForTxpoolCounts(1); + + const [{ hash: firstTxHash }] = await getTxpoolTxs(); + + // The 2nd attempt (1st reprice) - captured in full so it can be restored later, well after + // the worker's moved on from it. + await mineEmptyBlocks(inclusionWaitBlocks); + const secondTxHash = await waitForNewTxpoolTx(firstTxHash); + const secondTx = await anvilClient.getTransaction({ hash: secondTxHash }); + + // The 3rd attempt (2nd reprice) - the last of the 3 tries before the worker gives up resending + // the burst's own TX and starts burning the nonce instead (see `sendTxAttempt`/`burnNonce`). + await mineEmptyBlocks(inclusionWaitBlocks); + const thirdTxHash = await waitForNewTxpoolTx(secondTxHash); + + // The nonce-burning TX - reaching this confirms the worker's given up on the burst's own TX + // entirely. + await mineEmptyBlocks(inclusionWaitBlocks); + await waitForNewTxpoolTx(thirdTxHash); + + // The 2nd attempt is restored - despite the burn TX being the worker's latest by far, this + // much earlier one lands and mines instead, still recognized as this sequence's success since + // `watchTxs` tracks every historical attempt's hash, not just the latest. + await dropPendingTxs(); + await resubmitTx(secondTx); + await mineNextTx(); + + await assertSequenceState(sequenceId, { successes: 1, failures: 0 }, [ + { kind: "created", details: { burstsCount: 1 } }, + { kind: "submitted", details: { fromIdxInSequence: 0, burstsCount: 1 } }, + { kind: "executed", details: { fromIdxInSequence: 0, successes: 1, failed: false } }, + ]); + await assertLogs(["ok"]); + }, +}); + +Deno.test({ + name: "send-sequences: a burst reverting on-chain, after the TX was already built, " + + "cascades to the bursts after it", + timeout: 30_000, + async fn() { + // Cheap enough that all 3 fit in a single batch/TX together. + const arg: SendSequencesArg = { + sequences: [{ + chainId, + bursts: [ + { calls: [addLog("a")] }, + { calls: [addLog("b")] }, + { calls: [addLog("c")] }, + ], + }], + }; + const [sequenceId] = await sendSequences(arg); + await waitForTxpoolCounts(1); + + const [{ hash: txHash }] = await getTxpoolTxs(); + const tx = await anvilClient.getTransaction({ hash: txHash }); + await dropPendingTxs(); + + // Set up from the maintenance wallet, not the app's own - using the app's wallet for this + // would consume a nonce it doesn't know about and desync its tracking. The TX above was + // already built (and signed) before this, so the app has no way to notice ahead of time. + const { target, calldata } = setReverts("b"); + const setRevertsHash = await anvilClient.sendTransaction({ to: target, data: calldata }); + await anvilClient.mine({ blocks: 1 }); + await anvilClient.waitForTransactionReceipt({ hash: setRevertsHash }); + + // The original TX is restored and lands as originally built, all 3 bursts bundled together. + await resubmitTx(tx); + await mineNextTx(); + + // The 2nd burst reverts on-chain; the 3rd, which needs the 2nd to have succeeded (see + // `Executor.sol`'s `needsPrev`), is skipped and fails alongside it - even though its own call + // would have succeeded in isolation. + await assertSequenceState(sequenceId, { successes: 1, failures: 2 }, [ + { kind: "created", details: { burstsCount: 3 } }, + { kind: "submitted", details: { fromIdxInSequence: 0, burstsCount: 3 } }, + { kind: "executed", details: { fromIdxInSequence: 0, successes: 1, failed: true } }, + ]); + await assertLogs(["a"]); + }, +}); + +Deno.test({ + name: "send-sequences: insufficient funds for a heavy burst burns the nonce, " + + "then a fresh batch succeeds once funded", + timeout: 30_000, + async fn() { + const workerAddress = privateKeyToAccount(workerPrivateKey).address; + // Enough for the nonce-burning TX below, but nowhere near enough for the heavy burst's own. + await anvilClient.setBalance({ address: workerAddress, value: (await baseTxCost()) * 2n }); + + const arg: SendSequencesArg = { + sequences: [{ + chainId, + bursts: [{ calls: [setGasPenalty("heavy", 500_000n), addLog("heavy")] }], + }], + }; + const [sequenceId] = await sendSequences(arg); + + // The burst's own TX can't be afforded, so the worker waits, then burns the nonce instead - + // affordable on its own - leaving the burst pending rather than failed. + await mineNextTx(); + await assertSequenceState(sequenceId, { successes: 0, failures: 0, pending: 1 }); + + // Funded enough for the burst itself, so the fresh batch built for it succeeds normally. + await anvilClient.setBalance({ address: workerAddress, value: parseEther("1") }); + await mineNextTx(); + await assertSequenceState(sequenceId, { successes: 1, failures: 0 }, [ + { kind: "created", details: { burstsCount: 1 } }, + { kind: "submitted", details: { fromIdxInSequence: 0, burstsCount: 1 } }, + { kind: "skipped", details: {} }, + { kind: "submitted", details: { fromIdxInSequence: 0, burstsCount: 1 } }, + { kind: "executed", details: { fromIdxInSequence: 0, successes: 1, failed: false } }, + ]); + await assertLogs(["heavy"]); + }, +}); + +Deno.test({ + name: "send-sequences: insufficient funds even to burn the nonce waits, " + + "then a fresh batch succeeds once funded", + timeout: 30_000, + async fn() { + const workerAddress = privateKeyToAccount(workerPrivateKey).address; + // Not enough to afford even the nonce-burning TX, let alone the heavy burst. + await anvilClient.setBalance({ address: workerAddress, value: (await baseTxCost()) / 2n }); + + const arg: SendSequencesArg = { + sequences: [{ + chainId, + bursts: [{ calls: [setGasPenalty("heavy", 500_000n), addLog("heavy")] }], + }], + }; + const [sequenceId] = await sendSequences(arg); + + // Neither the burst's own TX nor the much cheaper nonce-burning one can be afforded, so + // nothing ever reaches the mempool - the worker just keeps waiting for funds. + await deadline(waitForTxpoolCounts(1), 300).catch((error) => { + if (!(error instanceof DOMException && error.name === "TimeoutError")) throw error; + }); + await waitForTxpoolCounts(0); + + // Funded enough for the burst itself, and so, easily, the nonce-burning TX too. + await anvilClient.setBalance({ address: workerAddress, value: parseEther("1") }); + + // The failed nonce-burning attempt is only retried after `inclusionWaitBlocks` - unlike a + // dropped TX, nothing was ever actually sent for `waitForTxpoolCounts` to wait out, so those + // blocks need mining directly to get the retry going at all. A short pause first gives + // `watchTxs` a chance to grab the block number it'll wait `inclusionWaitBlocks` from, once the + // balance check above unblocks it - mining immediately risks it capturing a later block than + // intended, undercounting how many blocks are actually left to mine below. + await delay(50); + await anvilClient.mine({ blocks: inclusionWaitBlocks }); + + // The nonce-burning TX goes through this time, leaving the burst pending rather than failed. + await mineNextTx(); + await assertSequenceState(sequenceId, { successes: 0, failures: 0, pending: 1 }); + + // The fresh batch built for it succeeds normally. + await mineNextTx(); + await assertSequenceState(sequenceId, { successes: 1, failures: 0 }, [ + { kind: "created", details: { burstsCount: 1 } }, + { kind: "submitted", details: { fromIdxInSequence: 0, burstsCount: 1 } }, + { kind: "skipped", details: {} }, + { kind: "submitted", details: { fromIdxInSequence: 0, burstsCount: 1 } }, + { kind: "executed", details: { fromIdxInSequence: 0, successes: 1, failed: false } }, + ]); + await assertLogs(["heavy"]); + }, +}); + +Deno.test({ + name: "send-sequences: a dummy TX using up the nonce makes the app skip and resend", + timeout: 30_000, + async fn() { + const arg: SendSequencesArg = { + sequences: [{ chainId, bursts: [{ calls: [addLog("ok")] }] }], + }; + const [sequenceId] = await sendSequences(arg); + await waitForTxpoolCounts(1); + + const [{ hash: firstTxHash }] = await getTxpoolTxs(); + const { nonce } = await anvilClient.getTransaction({ hash: firstTxHash }); + await dropPendingTxs(); + + // Consumes the worker's nonce with an unrelated transaction, simulating something outside + // the relay's control (not a repriced retry of its own) having used it up. + const workerAccount = privateKeyToAccount(workerPrivateKey); + const dummyClient = createWalletClient({ + account: workerAccount, + chain: anvilClient.chain, + transport: http(), + }).extend(publicActions); + await dummyClient.sendTransaction({ to: workerAccount.address, value: 0n, nonce }); + await mineNextTx(); + + // Noticing the nonce skip, then sending, mining and confirming the resend all need further + // confirmations that can't be pinned to an exact block - each is mined with a short pause so + // the worker (a separate process) gets a chance to react before the next one lands, and the + // sequence's final state is polled for below rather than any one intermediate step. + for (let i = 0; i < 4; i++) { + await delay(20); + await anvilClient.mine({ blocks: 1 }); + } + + await assertSequenceState(sequenceId, { successes: 1, failures: 0 }, [ + { kind: "created", details: { burstsCount: 1 } }, + { kind: "submitted", details: { fromIdxInSequence: 0, burstsCount: 1 } }, + { kind: "skipped", details: {} }, + { kind: "submitted", details: { fromIdxInSequence: 0, burstsCount: 1 } }, + { kind: "executed", details: { fromIdxInSequence: 0, successes: 1, failed: false } }, + ]); + await assertLogs(["ok"]); + }, +}); + +Deno.test({ + name: + "send-sequences: after 3 dropped attempts, the app burns the nonce, then resends and succeeds", + timeout: 30_000, + async fn() { + const arg: SendSequencesArg = { + sequences: [{ chainId, bursts: [{ calls: [addLog("ok")] }] }], + }; + const [sequenceId] = await sendSequences(arg); + + // The worker retries the same nonce 3 times before giving up on it - each dropped attempt is + // resent only once `inclusionWaitBlocks` have passed without a receipt for it. + for (let attempt = 0; attempt < 3; attempt++) { + await waitForTxpoolCounts(1); + await dropPendingTxs(); + await anvilClient.mine({ blocks: inclusionWaitBlocks }); + } + + // After the 3rd dropped attempt, the worker burns the nonce instead of retrying the burst + // again - the burn TX is let through this time, so the burst itself is left pending rather + // than failed, ready to be resent under the next nonce. + await mineNextTx(); + await assertSequenceState(sequenceId, { successes: 0, failures: 0, pending: 1 }); + + await mineNextTx(); + await assertSequenceState(sequenceId, { successes: 1, failures: 0 }, [ + { kind: "created", details: { burstsCount: 1 } }, + { kind: "submitted", details: { fromIdxInSequence: 0, burstsCount: 1 } }, + { kind: "skipped", details: {} }, + { kind: "submitted", details: { fromIdxInSequence: 0, burstsCount: 1 } }, + { kind: "executed", details: { fromIdxInSequence: 0, successes: 1, failed: false } }, + ]); + await assertLogs(["ok"]); + }, +});