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"]);
+ },
+});