diff --git a/.eslintrc.json b/.eslintrc.json index 7b21d659..b7b9ac74 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -1,9 +1,10 @@ { "extends": ["next/core-web-vitals", "prettier"], - "plugins": ["prettier", "@typescript-eslint"], + "plugins": ["unused-imports", "prettier", "@typescript-eslint"], "rules": { "@next/next/no-img-element": "off", - "no-constant-condition": "warn" + "no-constant-condition": "warn", + "unused-imports/no-unused-imports-ts": "error" }, "ignorePatterns": ["node_modules/", ".next/", ".github/", "src/types/contracts"] } diff --git a/.github/workflows/cla.yml b/.github/workflows/cla.yml index 4dc448c6..0f6b86a0 100644 --- a/.github/workflows/cla.yml +++ b/.github/workflows/cla.yml @@ -24,7 +24,7 @@ jobs: # branch should not be protected branch: 'main' # user names of users allowed to contribute without CLA - allowlist: lukasschor,mikheevm,rmeissner,germartinez,Uxio0,dasanra,francovenica,tschubotz,luarx,bot*,DaniSomoza,iamacook,yagopv,usame-algan,schmanu,DiogoSoaress,JagoFigueroa + allowlist: lukasschor,mikheevm,rmeissner,germartinez,Uxio0,dasanra,francovenica,tschubotz,luarx,bot*,DaniSomoza,iamacook,yagopv,usame-algan,schmanu,DiogoSoaress,JagoFigueroa,jmealy # the followings are the optional inputs - If the optional inputs are not given, then default values will be taken # enter the remote organization name where the signatures should be stored (Default is storing the signatures in the same repository) diff --git a/.github/workflows/tag-release.yml b/.github/workflows/tag-release.yml index 2c6aeaa7..2cd7e5da 100644 --- a/.github/workflows/tag-release.yml +++ b/.github/workflows/tag-release.yml @@ -19,10 +19,12 @@ jobs: - name: Extract version if: github.event.pull_request.merged == true id: version + env: + PR_BODY: ${{ github.event.pull_request.body }} run: | NEW_VERSION=$(node -p 'require("./package.json").version') echo "version=v$NEW_VERSION" >> $GITHUB_OUTPUT - echo "${{ github.event.pull_request.body }}" > CHANGELOG.md + echo "$PR_BODY" > CHANGELOG.md - name: Create a git tag if: github.event.pull_request.merged == true diff --git a/.gitignore b/.gitignore index c0d2ec8d..7e820010 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,7 @@ # misc .DS_Store *.pem +.idea # debug npm-debug.log* @@ -27,7 +28,8 @@ yarn-error.log* .pnpm-debug.log* # local env files -.env*.local +.env +*.local # vercel .vercel diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..0ad25db4 --- /dev/null +++ b/LICENSE @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/jest.config.js b/jest.config.js index 665b8bcd..6e88b8fb 100644 --- a/jest.config.js +++ b/jest.config.js @@ -15,4 +15,7 @@ const customJestConfig = { testEnvironment: 'jest-environment-jsdom', } -module.exports = createJestConfig(customJestConfig) +module.exports = async () => ({ + ...(await createJestConfig(customJestConfig)()), + transformIgnorePatterns: ['node_modules/(?!(isows)/)'], +}) diff --git a/jest.setup.js b/jest.setup.js index e643574f..8af8c3ed 100644 --- a/jest.setup.js +++ b/jest.setup.js @@ -43,3 +43,7 @@ jest.mock('@web3-onboard/core', () => () => ({ get: () => mockOnboardState, }, })) + +const { TextEncoder, TextDecoder } = require('util') +global.TextEncoder = TextEncoder +global.TextDecoder = TextDecoder diff --git a/package.json b/package.json index 3222d9c1..12dc5bb4 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "safe-dao-governance-app", "homepage": "https://github.com/safe-global/safe-dao-governance-app", "license": "GPL-3.0", - "version": "1.2.2", + "version": "2.0.0", "scripts": { "build": "next build && next export", "lint": "tsc && next lint", @@ -24,10 +24,11 @@ "@emotion/react": "^11.10.5", "@emotion/server": "^11.10.0", "@emotion/styled": "^11.10.5", - "@gnosis.pm/safe-apps-provider": "^0.15.1", - "@gnosis.pm/safe-apps-react-sdk": "^4.6.2", - "@mui/icons-material": "^5.11.0", + "@mui/icons-material": "^5.15.15", "@mui/material": "^5.11.5", + "@mui/x-date-pickers": "^7.1.1", + "@safe-global/safe-apps-provider": "^0.18.2", + "@safe-global/safe-apps-react-sdk": "^4.7.1", "@safe-global/safe-gateway-typescript-sdk": "^3.5.6", "@web3-onboard/coinbase": "^2.2.4", "@web3-onboard/core": "2.20.4", @@ -40,12 +41,14 @@ "bezier-easing": "^2.1.0", "clsx": "^1.2.1", "date-fns": "^2.29.3", + "dayjs": "^1.11.10", "ethereum-blockies-base64": "^1.0.2", "ethers": "^5.7.2", "next": "13.1.2", "react": "18.2.0", "react-dom": "18.2.0", - "swr": "^2.1.0" + "swr": "^2.1.0", + "victory": "^36.9.2" }, "devDependencies": { "@svgr/webpack": "^6.5.1", @@ -62,6 +65,7 @@ "eslint-config-next": "13.1.2", "eslint-config-prettier": "^8.6.0", "eslint-plugin-prettier": "^4.2.1", + "eslint-plugin-unused-imports": "^3.1.0", "hardhat": "^2.12.6", "jest": "^29.3.1", "jest-environment-jsdom": "^29.3.1", diff --git a/pages/_app.tsx b/pages/_app.tsx index 3d1057f4..a5f23004 100644 --- a/pages/_app.tsx +++ b/pages/_app.tsx @@ -1,11 +1,11 @@ -import SafeProvider from '@gnosis.pm/safe-apps-react-sdk' +import SafeProvider from '@safe-global/safe-apps-react-sdk' import { useEffect, useMemo } from 'react' import { CacheProvider } from '@emotion/react' import { Experimental_CssVarsProvider as CssVarsProvider, experimental_extendTheme as extendMuiTheme, } from '@mui/material/styles' -import { useMediaQuery, CssBaseline } from '@mui/material' +import { CssBaseline } from '@mui/material' import { setBaseUrl as setGatewayBaseUrl } from '@safe-global/safe-gateway-typescript-sdk' import { useRouter } from 'next/router' import type { EmotionCache } from '@emotion/react' @@ -22,12 +22,14 @@ import { useIsTokenPaused } from '@/hooks/useIsTokenPaused' import { useInitWallet } from '@/hooks/useWallet' import { EnsureWalletConnection } from '@/components/EnsureWalletConnection' import { createEmotionCache } from '@/styles/emotion' -import { GATEWAY_URL } from '@/config/constants' import { isDashboard } from '@/utils/routes' import { useSafeSnapshot } from '@/hooks/useSafeSnapshot' import { usePendingDelegations } from '@/hooks/usePendingDelegations' import '@/styles/globals.css' +import { useSafeTokenBalance } from '@/hooks/useSafeTokenBalance' +import { useLockHistory } from '@/hooks/useLockHistory' +import { GATEWAY_URL } from '@/config/constants' const InitApp = (): null => { setGatewayBaseUrl(GATEWAY_URL) @@ -38,12 +40,16 @@ const InitApp = (): null => { usePendingDelegations() - // Populate caches + // Populate claiming app caches useChain() useDelegatesFile() useIsTokenPaused() useSafeSnapshot() + // Populate locking app caches + useSafeTokenBalance() + useLockHistory() + return null } @@ -58,10 +64,9 @@ const App = ({ emotionCache?: EmotionCache }): ReactElement => { const { pathname, query } = useRouter() - const prefersDarkMode = useMediaQuery('(prefers-color-scheme: dark)') // Workaround for dark mode widgets - const isDarkMode = query.theme ? query.theme === 'dark' : prefersDarkMode + const isDarkMode = query.theme ? query.theme !== 'light' : true useEffect(() => { if (typeof document !== 'undefined') { diff --git a/pages/_document.tsx b/pages/_document.tsx index ae80932a..b6486dbd 100644 --- a/pages/_document.tsx +++ b/pages/_document.tsx @@ -10,12 +10,14 @@ import createEmotionServer from '@emotion/server/create-instance' import { Favicons } from '@/components/Favicons' import { createEmotionCache } from '@/styles/emotion' +import { IS_PRODUCTION } from '@/config/constants' export default class MyDocument extends Document { render() { return ( + {(this.props as any).emotionStyleTags} diff --git a/pages/activity-program.tsx b/pages/activity-program.tsx new file mode 100644 index 00000000..8cc152f1 --- /dev/null +++ b/pages/activity-program.tsx @@ -0,0 +1,9 @@ +import type { NextPage } from 'next' + +import Activities from '@/components/Activities' + +const ActivityProgramPage: NextPage = () => { + return +} + +export default ActivityProgramPage diff --git a/pages/activity.tsx b/pages/activity.tsx new file mode 100644 index 00000000..680dd281 --- /dev/null +++ b/pages/activity.tsx @@ -0,0 +1,9 @@ +import type { NextPage } from 'next' + +import TokenLocking from '@/components/TokenLocking' + +const LockPage: NextPage = () => { + return +} + +export default LockPage diff --git a/pages/claim.tsx b/pages/claim.tsx index d11c07d2..06b182b9 100644 --- a/pages/claim.tsx +++ b/pages/claim.tsx @@ -1,9 +1,20 @@ import type { NextPage } from 'next' -import { Claim } from '@/components/Claim' +import SuccessfulClaim from '@/components/Claim/SuccessfulClaim' +import { useRouter, useSearchParams } from 'next/navigation' +import { AppRoutes } from '@/config/routes' +import MediumPaper from '@/components/MediumPaper' const ClaimPage: NextPage = () => { - return + const router = useRouter() + const query = useSearchParams() + const claimedAmount = query.get('claimedAmount') || '' + + return ( + + router.push(AppRoutes.governance)} /> + + ) } export default ClaimPage diff --git a/pages/governance.tsx b/pages/governance.tsx new file mode 100644 index 00000000..90fb783b --- /dev/null +++ b/pages/governance.tsx @@ -0,0 +1,9 @@ +import type { NextPage } from 'next' + +import { GovernanceAndClaiming } from '@/components/GovernanceAndClaiming' + +const GovernanceAndClaimingPage: NextPage = () => { + return +} + +export default GovernanceAndClaimingPage diff --git a/pages/index.tsx b/pages/index.tsx index a66de8e1..359c5b0f 100644 --- a/pages/index.tsx +++ b/pages/index.tsx @@ -1,15 +1,9 @@ import type { NextPage } from 'next' -import { useWallet } from '@/hooks/useWallet' -import { ConnectWallet } from '@/components/ConnectWallet' -import { Intro } from '@/components/Intro' -import { useIsSafeApp } from '@/hooks/useIsSafeApp' +import { SplashScreen } from '@/components/SplashScreen' const IndexPage: NextPage = () => { - const isSafeApp = useIsSafeApp() - const wallet = useWallet() - - return !isSafeApp && !wallet ? : + return } export default IndexPage diff --git a/pages/safedao.tsx b/pages/safedao.tsx deleted file mode 100644 index 0592e286..00000000 --- a/pages/safedao.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import type { NextPage } from 'next' - -import { EducationSeries } from '@/components/EducationSeries' - -const SafeDaoPage: NextPage = () => { - return -} - -export default SafeDaoPage diff --git a/pages/splash.tsx b/pages/splash.tsx new file mode 100644 index 00000000..14e7fd0e --- /dev/null +++ b/pages/splash.tsx @@ -0,0 +1,9 @@ +import type { NextPage } from 'next' + +import { SplashScreen } from '@/components/SplashScreen' + +const SplashPage: NextPage = () => { + return +} + +export default SplashPage diff --git a/pages/unlock.tsx b/pages/unlock.tsx new file mode 100644 index 00000000..fefe3fee --- /dev/null +++ b/pages/unlock.tsx @@ -0,0 +1,9 @@ +import type { NextPage } from 'next' + +import TokenUnlocking from '@/components/TockenUnlocking' + +const UnlockPage: NextPage = () => { + return +} + +export default UnlockPage diff --git a/public/images/assets-stored.png b/public/images/assets-stored.png new file mode 100644 index 00000000..ebe65c57 Binary files /dev/null and b/public/images/assets-stored.png differ diff --git a/public/images/asterix.svg b/public/images/asterix.svg new file mode 100644 index 00000000..ce7d4b3f --- /dev/null +++ b/public/images/asterix.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/public/images/barcode.svg b/public/images/barcode.svg new file mode 100644 index 00000000..e8700e26 --- /dev/null +++ b/public/images/barcode.svg @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/public/images/clock-alt.svg b/public/images/clock-alt.svg new file mode 100644 index 00000000..6490aced --- /dev/null +++ b/public/images/clock-alt.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/images/clock.svg b/public/images/clock.svg index 65952798..06676e74 100644 --- a/public/images/clock.svg +++ b/public/images/clock.svg @@ -1,4 +1,4 @@ - - + + diff --git a/public/images/diamond.png b/public/images/diamond.png new file mode 100644 index 00000000..c0c88edc Binary files /dev/null and b/public/images/diamond.png differ diff --git a/public/images/empty-activity.png b/public/images/empty-activity.png new file mode 100644 index 00000000..90ec5731 Binary files /dev/null and b/public/images/empty-activity.png differ diff --git a/public/images/empty-breakdown.svg b/public/images/empty-breakdown.svg new file mode 100644 index 00000000..5f93565f --- /dev/null +++ b/public/images/empty-breakdown.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/images/leaderboard-first-place.svg b/public/images/leaderboard-first-place.svg new file mode 100644 index 00000000..f63d6f2d --- /dev/null +++ b/public/images/leaderboard-first-place.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/public/images/leaderboard-second-place.svg b/public/images/leaderboard-second-place.svg new file mode 100644 index 00000000..9ad42c95 --- /dev/null +++ b/public/images/leaderboard-second-place.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/public/images/leaderboard-third-place.svg b/public/images/leaderboard-third-place.svg new file mode 100644 index 00000000..efc2e956 --- /dev/null +++ b/public/images/leaderboard-third-place.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/public/images/leaderboard-title-star.svg b/public/images/leaderboard-title-star.svg new file mode 100644 index 00000000..0d6ea86c --- /dev/null +++ b/public/images/leaderboard-title-star.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/images/safe-logo-round.svg b/public/images/safe-logo-round.svg new file mode 100644 index 00000000..348f9003 --- /dev/null +++ b/public/images/safe-logo-round.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/public/images/safe-pass.svg b/public/images/safe-pass.svg new file mode 100644 index 00000000..7da9462c --- /dev/null +++ b/public/images/safe-pass.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/public/images/star.svg b/public/images/star.svg new file mode 100644 index 00000000..f5ce86af --- /dev/null +++ b/public/images/star.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/images/transaction-number.png b/public/images/transaction-number.png new file mode 100644 index 00000000..6b34d562 Binary files /dev/null and b/public/images/transaction-number.png differ diff --git a/public/images/transaction-volume.png b/public/images/transaction-volume.png new file mode 100644 index 00000000..633e0d50 Binary files /dev/null and b/public/images/transaction-volume.png differ diff --git a/public/images/user-bust.png b/public/images/user-bust.png new file mode 100644 index 00000000..6c78736f Binary files /dev/null and b/public/images/user-bust.png differ diff --git a/public/images/weekly-user.png b/public/images/weekly-user.png new file mode 100644 index 00000000..86ef7296 Binary files /dev/null and b/public/images/weekly-user.png differ diff --git a/public/manifest.json b/public/manifest.json index 32c32a70..b0759199 100644 --- a/public/manifest.json +++ b/public/manifest.json @@ -1,7 +1,7 @@ { - "short_name": "safe-dao-governance-app", - "name": "Safe{DAO} Governance", - "description": "The portal to Safe{DAO} governance, voting power delegation and allocation claiming.", + "short_name": "safe-pass-governance", + "name": "Safe{Pass}|{DAO}", + "description": "The portal to the Safe community. Get your Safe{Pass}, get rewarded, and participate in governance.", "icons": [ { "src": "/images/app-logo.svg", diff --git a/src/analytics/lockEvents.ts b/src/analytics/lockEvents.ts new file mode 100644 index 00000000..27f14b4a --- /dev/null +++ b/src/analytics/lockEvents.ts @@ -0,0 +1,31 @@ +export const LOCK_EVENTS = { + LOCK_BUTTON: { + action: 'Lock tokens button', + }, + UNLOCK_BUTTON: { + action: 'Unlock tokens button', + }, + WITHDRAW_BUTTON: { + action: 'Withdraw tokens button', + }, + LOCK_SUCCESS: { + action: 'Transaction proposed', + label: 'locking', + }, + UNLOCK_SUCCESS: { + action: 'Transaction proposed', + label: 'unlocking', + }, + WITHDRAW_SUCCESS: { + action: 'Transaction proposed', + label: 'withdrawal', + }, + CHANGE_LOCK_AMOUNT: { + action: 'Change amount', + label: 'lock', + }, + CHANGE_UNLOCK_AMOUNT: { + action: 'Change amount', + label: 'unlock', + }, +} diff --git a/src/analytics/navigation.ts b/src/analytics/navigation.ts new file mode 100644 index 00000000..b7f63108 --- /dev/null +++ b/src/analytics/navigation.ts @@ -0,0 +1,23 @@ +export const NAVIGATION_EVENTS = { + OPEN_LOCKING: { + action: 'Open locking page', + }, + OPEN_UNLOCKING: { + action: 'Open unlocking/withdrawal page', + }, + OPEN_CLAIM: { + action: 'Open claim page', + }, + LEADERBOARD_SHOW_MORE: { + action: 'Leaderboard show more', + }, + OPEN_TERMS: { + action: 'Open terms and conditions', + }, + OPEN_BOOST_INFO: { + action: 'Open boost info', + }, + OPEN_ACTIVITY_INFO: { + action: 'Open activity info', + }, +} diff --git a/src/components/AccordionContainer/index.tsx b/src/components/AccordionContainer/index.tsx new file mode 100644 index 00000000..4ca7125c --- /dev/null +++ b/src/components/AccordionContainer/index.tsx @@ -0,0 +1,24 @@ +import { Typography, Accordion, AccordionSummary, AccordionDetails, useMediaQuery } from '@mui/material' +import { useTheme } from '@mui/material/styles' +import ExpandMoreIcon from '@mui/icons-material/ExpandMore' + +import css from './styles.module.css' +import { ReactElement } from 'react' + +export const AccordionContainer = ({ children, title }: { children: ReactElement; title: string }): ReactElement => { + const theme = useTheme() + const isSmallScreen = useMediaQuery(theme.breakpoints.down('lg')) + + if (!isSmallScreen) return <>{children} + + return ( + + }> + + {title} + + + {children} + + ) +} diff --git a/src/components/AccordionContainer/styles.module.css b/src/components/AccordionContainer/styles.module.css new file mode 100644 index 00000000..f26698a2 --- /dev/null +++ b/src/components/AccordionContainer/styles.module.css @@ -0,0 +1,12 @@ +.container :global .MuiAccordionSummary-content { + margin: auto; + height: 64px; +} + +.container :global .Mui-expanded.MuiAccordionSummary-root { + border-bottom: 1px solid var(--mui-palette-border-light); +} + +.container :global .MuiAccordionDetails-root { + padding: 0; +} diff --git a/src/components/AccountCenter/index.tsx b/src/components/AccountCenter/index.tsx index d6a6447d..9ce6a36a 100644 --- a/src/components/AccountCenter/index.tsx +++ b/src/components/AccountCenter/index.tsx @@ -11,18 +11,24 @@ import { useChain } from '@/hooks/useChain' import { WalletInfo, UNKNOWN_CHAIN_NAME } from '@/components/WalletInfo' import { EthHashInfo } from '@/components/EthHashInfo' import type { ConnectedWallet } from '@/hooks/useWallet' +import { useIsSafeApp } from '@/hooks/useIsSafeApp' +import { AppRoutes } from '@/config/routes' +import { useRouter } from 'next/router' import css from './styles.module.css' const Popper = ({ wallet }: { wallet: ConnectedWallet }): ReactElement => { const [anchorEl, setAnchorEl] = useState(null) const onboard = useOnboard() + const router = useRouter() + const isSafeApp = useIsSafeApp() const chain = useChain() const connectedChain = chain?.chainId === wallet.chainId ? chain : undefined const handleDisconnect = () => { onboard?.disconnectWallet({ label: wallet.label }) + if (!isSafeApp) router.push({ pathname: AppRoutes.splash }) } const handleClick = (event: MouseEvent) => { diff --git a/src/components/Activities/index.tsx b/src/components/Activities/index.tsx new file mode 100644 index 00000000..2d40e0a0 --- /dev/null +++ b/src/components/Activities/index.tsx @@ -0,0 +1,125 @@ +import { ReactNode } from 'react' +import NextLink from 'next/link' +import { Box, Link, Stack, SvgIcon, Typography } from '@mui/material' +import { AppRoutes } from '@/config/routes' +import PaperContainer from '@/components//PaperContainer' +import { ExternalLink } from '@/components/ExternalLink' +import { ChevronLeft } from '@mui/icons-material' +import SafePass from '@/public/images/safe-pass.svg' +import UserBustIcon from '@/public/images/user-bust.png' +import WeeklyUser from '@/public/images/weekly-user.png' +import TransactionsVolumeIcon from '@/public/images/transaction-volume.png' +import TransactionsNumberIcon from '@/public/images/transaction-number.png' +import AssetsStoredIcon from '@/public/images/assets-stored.png' +import EmptyActivityIcon from '@/public/images/empty-activity.png' +import Image from 'next/image' +import { SAFE_PASS_LANDING_PAGE } from '@/config/constants' + +const ActivityItem = ({ title, description, icon }: { title: string; description: ReactNode; icon: ReactNode }) => { + return ( + + {icon} + + {title} + + + {description} + + + ) +} + +const Activities = () => { + return ( + + + palette.primary.main }} + > + + Back to main + + + Eligible activities + + + User bust + + + + With{' '} + + {'Safe {Pass}'} + {' '} + you can earn Points by using your Safe Account. Some activities are rewarded throughout the entire season, + some activities are only rewarded temporarily. + + + Learn more about {'Safe {Pass}'} + + + } + title="Weekly user" + description="Transacting with your Safe Account on a weekly basis" + /> + + } + title="Volume transacted" + description="Volume of your transactions" + /> + + } + title="No. of transactions" + description="The number of transactions made with your Safe Account" + /> + } + title="Assets stored" + description="The total assets value in your Safe Account" + /> + } + title="Other activities coming soon" + description={ + <> + To stay updated{' '} + + follow us on X + {' '} + and{' '} + + Farcaster + + + } + /> + + + + + + ) +} + +export default Activities diff --git a/src/components/BackgroundCircles/index.tsx b/src/components/BackgroundCircles/index.tsx index 0e77f3c3..eba38484 100644 --- a/src/components/BackgroundCircles/index.tsx +++ b/src/components/BackgroundCircles/index.tsx @@ -1,25 +1,7 @@ import { Box } from '@mui/material' -import clsx from 'clsx' - -import palette from '@/styles/colors' import css from './styles.module.css' -const Circle = ({ color, className }: { color: string; className: string }) => { - return ( - - ) -} - -export const TopCircle = () => { - return -} - -export const BottomCircle = () => { - return +export const BackgroundCircles = () => { + return } diff --git a/src/components/BackgroundCircles/styles.module.css b/src/components/BackgroundCircles/styles.module.css index ce85cb4c..cf2bb002 100644 --- a/src/components/BackgroundCircles/styles.module.css +++ b/src/components/BackgroundCircles/styles.module.css @@ -1,25 +1,12 @@ -.circle { - position: absolute; - border-radius: 50%; +.circles { z-index: -1; -} - -.top { - width: 350px; - height: 350px; - left: -100px; - top: 50px; -} - -.bottom { - width: 450px; - height: 450px; - right: -100px; - bottom: -100px; -} - -@media (max-width: 600px) { - .circle { - display: none; - } + position: fixed; + height: 100%; + width: 100%; + overflow: hidden; + top: 0; + left: 0; + background: radial-gradient(circle at 75% 25%, rgba(18, 255, 128, 0.3) 0%, #000000 40%), + radial-gradient(circle at 25% 125%, rgba(41, 182, 246, 0.5) 0%, #121312 50%); + background-blend-mode: screen; } diff --git a/src/components/BoostCounter/index.tsx b/src/components/BoostCounter/index.tsx new file mode 100644 index 00000000..b1beea67 --- /dev/null +++ b/src/components/BoostCounter/index.tsx @@ -0,0 +1,109 @@ +import { floorNumber } from '@/utils/boost' +import { NorthRounded, SouthRounded } from '@mui/icons-material' +import { Box, Typography } from '@mui/material' +import { TypographyProps } from '@mui/material/Typography' +import BezierEasing from 'bezier-easing' +import { useState, useEffect, useRef } from 'react' +const easeInOut = BezierEasing(0.42, 0, 0.58, 1) +const DURATION = 1000 +const SCALE_FACTOR = 0.15 + +const digitRotations: Record = { + [1]: 0, + [2]: Math.random() * 6 - 3, + [3]: Math.random() * 6 - 3, + [4]: Math.random() * 6 - 3, + [5]: Math.random() * 6 - 3, +} + +const BoostCounter = ({ + value, + direction, + ...props +}: TypographyProps & { value: number; direction?: 'north' | 'south' }) => { + const [isAnimating, setIsAnimating] = useState(false) + const [start, setStart] = useState(0) + const [target, setTarget] = useState(0) + + const targetRef = useRef(1) + + const [currentNumber, setCurrentNumber] = useState(target) + + const rotationRef = useRef(0) + + const scale = 1 + Math.floor(currentNumber) * SCALE_FACTOR + + useEffect(() => { + if (targetRef.current === target) { + // No new target + return + } + + targetRef.current = target + const startTime = new Date().getTime() + const offset = target - start + + const startRotation = digitRotations[floorNumber(currentNumber, 0)] + + const tick = () => { + setIsAnimating(true) + const elapsed = new Date().getTime() - startTime + + // If the browser window is in the background / minimized it will optimize and not call the requestAnimationFrame until in foreground causing wrong numbers for progress. + const progress = Math.min(elapsed / DURATION, 1) + const easedProgress = easeInOut(progress) + + const newNumber = start + easedProgress * offset + + if ( + startRotation !== digitRotations[floorNumber(newNumber, 0)] && + rotationRef.current !== digitRotations[floorNumber(newNumber, 0)] + ) { + rotationRef.current = digitRotations[floorNumber(newNumber, 0)] + } + setCurrentNumber(floorNumber(newNumber, 3)) + + if (elapsed < DURATION && targetRef.current === target) { + requestAnimationFrame(tick) + } else { + setIsAnimating(false) + rotationRef.current = 0 + } + } + + tick() + }, [target, currentNumber, start]) + + useEffect(() => { + setStart(target) + setTarget(value) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [value]) + + const digit = currentNumber.toString().slice(0, 1) + const localeSeparator = (1.1).toLocaleString().charAt(1) + const decimals = currentNumber.toString().slice(2).slice(0, 2) + + return ( + + {direction === 'north' && } + {direction === 'south' && } + + + {digit} + + + {decimals !== '' ? `${localeSeparator}${decimals}x` : 'x'} + + + ) +} + +export default BoostCounter diff --git a/src/components/BoostCounter/styles.module.css b/src/components/BoostCounter/styles.module.css new file mode 100644 index 00000000..e69de29b diff --git a/src/components/Claim/steps/SuccessfulClaim/index.tsx b/src/components/Claim/SuccessfulClaim.tsx similarity index 53% rename from src/components/Claim/steps/SuccessfulClaim/index.tsx rename to src/components/Claim/SuccessfulClaim.tsx index 9bb71e5a..0ae88431 100644 --- a/src/components/Claim/steps/SuccessfulClaim/index.tsx +++ b/src/components/Claim/SuccessfulClaim.tsx @@ -1,33 +1,28 @@ import { Grid, Typography, Button } from '@mui/material' -import type { Theme } from '@mui/material/styles' import type { ReactElement } from 'react' -import { useIsDarkMode } from '@/hooks/useIsDarkMode' import SafeLogo from '@/public/images/safe-logo.svg' -import type { ClaimFlow } from '@/components/Claim' -const SuccessfulClaim = ({ data, onNext }: { data: ClaimFlow; onNext: () => void }): ReactElement => { - const isDarkMode = useIsDarkMode() - - const backgroundColor = ({ palette }: Theme) => (isDarkMode ? undefined : palette.primary.main) - const textColor = isDarkMode ? undefined : 'white' - const buttonColor = isDarkMode ? undefined : 'secondary' +export type ClaimFlow = { + claimedAmount: string +} +const SuccessfulClaim = ({ data, onNext }: { data: ClaimFlow; onNext: () => void }): ReactElement => { return ( - + - + Congrats! - + You successfully started claiming {data.claimedAmount || '0'} Safe Tokens!
Once the transaction is signed and executed, the Safe Tokens will be available in your Safe Account.
-
diff --git a/src/components/Claim/index.tsx b/src/components/Claim/index.tsx index 4df0af7b..af267211 100644 --- a/src/components/Claim/index.tsx +++ b/src/components/Claim/index.tsx @@ -1,34 +1,262 @@ -import { useRouter } from 'next/router' -import type { ReactElement } from 'react' +import { + Grid, + Typography, + Button, + Paper, + Box, + Stack, + SvgIcon, + Divider, + TextField, + CircularProgress, + InputAdornment, +} from '@mui/material' +import { useState, type ReactElement, ChangeEvent } from 'react' + +import PaperContainer from '../PaperContainer' -import { useStepper } from '@/hooks/useStepper' -import ClaimOverview from '@/components/Claim/steps/ClaimOverview' -import SuccessfulClaim from '@/components/Claim/steps/SuccessfulClaim' +import StarIcon from '@/public/images/star.svg' +import { maxDecimals, minMaxValue, mustBeFloat } from '@/utils/validation' +import { useIsTokenPaused } from '@/hooks/useIsTokenPaused' +import { useSafeTokenAllocation } from '@/hooks/useSafeTokenAllocation' +import { useTaggedAllocations } from '@/hooks/useTaggedAllocations' +import { getVestingTypes } from '@/utils/vesting' +import { formatEther } from 'ethers/lib/utils' +import { createClaimTxs } from '@/utils/claim' +import { useSafeAppsSDK } from '@safe-global/safe-apps-react-sdk' +import { useIsWrongChain } from '@/hooks/useIsWrongChain' +import SafeToken from '@/public/images/token.svg' + +import css from './styles.module.css' +import { useRouter } from 'next/router' import { AppRoutes } from '@/config/routes' -import { ProgressBar } from '@/components/ProgressBar' +import { canRedeemSep5Airdrop } from '@/utils/airdrop' +import { Sep5InfoBox } from '../Sep5InfoBox' +import { formatAmount } from '@/utils/formatters' +import { ClaimCard } from '../ClaimCard' +import { InfoAlert } from '../InfoAlert' +import { useWallet } from '@/hooks/useWallet' +import { isSafe } from '@/utils/wallet' +import { getGovernanceAppSafeAppUrl } from '@/utils/safe-apps' +import { useChainId } from '@/hooks/useChainId' + +const validateAmount = (amount: string, maxAmount: string) => { + if (isNaN(Number(amount))) { + return 'The value must be a number' + } + return mustBeFloat(amount) || minMaxValue(0, maxAmount, amount) || maxDecimals(amount, 18) +} + +const getDecimalLength = (amount: string) => { + const length = Number(amount).toFixed(2).length + + if (length > 10) { + return 0 + } -export type ClaimFlow = { - claimedAmount: string + if (length > 9) { + return 1 + } + + return 2 } -export const Claim = (): ReactElement => { +const ClaimOverview = (): ReactElement => { + const { sdk, safe } = useSafeAppsSDK() + const isWrongChain = useIsWrongChain() const router = useRouter() + const wallet = useWallet() + const chainId = useChainId() + + const [amount, setAmount] = useState('0') + const [isMaxAmountSelected, setIsMaxAmountSelected] = useState(false) + const [amountError, setAmountError] = useState() + const [creatingTxs, setCreatingTxs] = useState(false) + + const { data: isTokenPaused } = useIsTokenPaused() + + // Allocation, vesting and voting power + const { data: allocation } = useSafeTokenAllocation() + + const canRedeemSep5 = canRedeemSep5Airdrop(allocation) + + const { ecosystemVesting, investorVesting } = getVestingTypes(allocation?.vestingData ?? []) + + const { sep5, user, ecosystem, investor, total } = useTaggedAllocations() + const totalClaimableAmountInEth = formatEther(total.claimable) + + const decimals = getDecimalLength(total.inVesting) - const { step, data, nextStep } = useStepper({ - claimedAmount: '', + // Flags + const isInvestorClaimingDisabled = !!investorVesting && isTokenPaused + + const isAmountGTZero = !!amount && !amountError && Number.parseFloat(amount) > 0 + + const isClaimDisabled = + !isAmountGTZero || (isInvestorClaimingDisabled && isAmountGTZero) || !!amountError || creatingTxs || isWrongChain + + // Handlers + const onChangeAmount = (event: ChangeEvent) => { + const error = validateAmount(event.target.value || '0', totalClaimableAmountInEth) + setAmount(event.target.value) + setAmountError(error) + + setIsMaxAmountSelected(false) + } + + const onClick = (handler: () => Promise) => async () => { + // Safe is connected via WC + if (wallet && (await isSafe(wallet))) { + window.open(getGovernanceAppSafeAppUrl(chainId, wallet.address), '_blank')?.focus() + } else { + handler() + } + } + + const onClaim = onClick(async () => { + setCreatingTxs(true) + + const txs = createClaimTxs({ + vestingData: allocation?.vestingData ?? [], + safeAddress: safe.safeAddress, + isMax: isMaxAmountSelected, + amount: amount || '0', + sep5Claimable: sep5.claimable, + userClaimable: user.claimable, + investorClaimable: investor.claimable, + isTokenPaused: !!isTokenPaused, + }) + + try { + await sdk.txs.send({ txs }) + router.push({ pathname: AppRoutes.claim, query: { claimedAmount: amount } }) + setCreatingTxs(false) + } catch (error) { + console.error(error) + setCreatingTxs(false) + } }) - const steps = [ - nextStep(data)} />, - router.push(AppRoutes.index)} />, - ] + const setToMaxAmount = () => { + const amountAsNumber = Number(formatEther(total.claimable)) + setAmount(amountAsNumber.toFixed(2)) + setAmountError(undefined) - const progress = ((step + 1) / steps.length) * 100 + setIsMaxAmountSelected(true) + } return ( - <> - - {steps[step]} - + + + + + + + + + + + + + + + Total awarded allocation is{' '} + + {formatAmount(formatEther(total.allocation), 2)} SAFE + + + + + palette.background.default, + color: ({ palette }) => palette.text.primary, + position: 'relative', + height: '100%', + display: 'flex', + flexDirection: 'column', + gap: 1, + }} + > + + + + + Claim your tokens as rewards! + + You get more tokens if you are active in activity program. + + + + + + + + + How much do you want to claim? + + + Select all tokens or define a custom amount. + + + + {canRedeemSep5 && ( + + + + )} + + + + + + + ), + endAdornment: ( + + + + ), + }} + className={css.input} + /> + + + + + + + + + + ) } + +export default ClaimOverview diff --git a/src/components/Claim/steps/ClaimOverview/index.tsx b/src/components/Claim/steps/ClaimOverview/index.tsx deleted file mode 100644 index e7079fd4..00000000 --- a/src/components/Claim/steps/ClaimOverview/index.tsx +++ /dev/null @@ -1,236 +0,0 @@ -import { Button, CircularProgress, Divider, Grid, InputAdornment, TextField, Typography } from '@mui/material' -import { useState } from 'react' -import SafeToken from '@/public/images/token.svg' -import { formatEther } from 'ethers/lib/utils' -import { useSafeAppsSDK } from '@gnosis.pm/safe-apps-react-sdk' -import type { ChangeEvent, ReactElement } from 'react' - -import { SelectedDelegate } from '@/components/SelectedDelegate' -import { maxDecimals, minMaxValue, mustBeFloat } from '@/utils/validation' -import { ClaimCard } from '@/components/ClaimCard' -import { useSafeTokenAllocation } from '@/hooks/useSafeTokenAllocation' -import { useDelegate } from '@/hooks/useDelegate' -import { InfoAlert } from '@/components/InfoAlert' -import { getVestingTypes } from '@/utils/vesting' -import { formatAmount } from '@/utils/formatters' -import { StepHeader } from '@/components/StepHeader' -import { createClaimTxs } from '@/utils/claim' -import { useIsTokenPaused } from '@/hooks/useIsTokenPaused' -import { useTaggedAllocations } from '@/hooks/useTaggedAllocations' -import { useIsWrongChain } from '@/hooks/useIsWrongChain' -import { Sep5InfoBox } from '@/components/Sep5InfoBox' -import { SEP5_EXPIRATION } from '@/config/constants' -import { canRedeemSep5Airdrop } from '@/utils/airdrop' -import type { ClaimFlow } from '@/components/Claim' - -import css from './styles.module.css' - -const validateAmount = (amount: string, maxAmount: string) => { - return mustBeFloat(amount) || minMaxValue(0, maxAmount, amount) || maxDecimals(amount, 18) -} - -const getDecimalLength = (amount: string) => { - const length = Number(amount).toFixed(2).length - - if (length > 10) { - return 0 - } - - if (length > 9) { - return 1 - } - - return 2 -} - -const ClaimOverview = ({ onNext }: { onNext: (data: ClaimFlow) => void }): ReactElement => { - const { sdk, safe } = useSafeAppsSDK() - const isWrongChain = useIsWrongChain() - - const [amount, setAmount] = useState('') - const [isMaxAmountSelected, setIsMaxAmountSelected] = useState(false) - const [amountError, setAmountError] = useState() - const [creatingTxs, setCreatingTxs] = useState(false) - - const delegate = useDelegate() - - const { data: isTokenPaused } = useIsTokenPaused() - - // Allocation, vesting and voting power - const { data: allocation } = useSafeTokenAllocation() - - const { sep5Vesting, ecosystemVesting, investorVesting } = getVestingTypes(allocation?.vestingData ?? []) - - const { sep5, user, ecosystem, investor, total } = useTaggedAllocations() - const totalClaimableAmountInEth = formatEther(total.claimable) - - const decimals = getDecimalLength(total.inVesting) - - // Flags - const canRedeemSep5 = canRedeemSep5Airdrop(allocation) - - const isInvestorClaimingDisabled = !!investorVesting && isTokenPaused - - const isAmountGTZero = !!amount && !amountError && Number.parseFloat(amount) > 0 - - const isClaimDisabled = - !isAmountGTZero || (isInvestorClaimingDisabled && isAmountGTZero) || !!amountError || creatingTxs || isWrongChain - - // Handlers - const onChangeAmount = (event: ChangeEvent) => { - const error = validateAmount(amount || '0', totalClaimableAmountInEth) - setAmount(event.target.value) - setAmountError(error) - - setIsMaxAmountSelected(false) - } - - const setToMaxAmount = () => { - const amountAsNumber = Number(formatEther(total.claimable)) - setAmount(amountAsNumber.toFixed(2)) - setAmountError(undefined) - - setIsMaxAmountSelected(true) - } - - const onClaim = async () => { - setCreatingTxs(true) - - const txs = createClaimTxs({ - vestingData: allocation?.vestingData ?? [], - safeAddress: safe.safeAddress, - isMax: isMaxAmountSelected, - amount: amount || '0', - sep5Claimable: sep5.claimable, - userClaimable: user.claimable, - investorClaimable: investor.claimable, - isTokenPaused: !!isTokenPaused, - }) - - try { - await sdk.txs.send({ txs }) - - onNext({ claimedAmount: amount }) - } catch (error) { - console.error(error) - } - - setCreatingTxs(false) - } - - return ( - - - - - - - - - - - - - - - - - Total allocation is{' '} - - {formatAmount(formatEther(total.allocation), 2)} SAFE - - - - - {canRedeemSep5 && ( - <> - - - - - - - - Execute at least one claim of any amount of your allocation before {SEP5_EXPIRATION} otherwise it will - be transferred back to the Safe{`{DAO}`} treasury. - - - - - )} - - - - - - - How much do you want to claim? - - - Select all Safe Tokens or define a custom amount. - - - - - - - - ), - endAdornment: ( - - - - ), - }} - className={css.input} - /> - - - - - - - - {isInvestorClaimingDisabled && ( - - - Claiming will be available once the Safe Token is transferable - - - )} - - {delegate && ( - - - - )} - - ) -} - -export default ClaimOverview diff --git a/src/components/Claim/steps/ClaimOverview/styles.module.css b/src/components/Claim/styles.module.css similarity index 100% rename from src/components/Claim/steps/ClaimOverview/styles.module.css rename to src/components/Claim/styles.module.css diff --git a/src/components/ClaimCard/index.tsx b/src/components/ClaimCard/index.tsx index 02e6034b..355d3bf8 100644 --- a/src/components/ClaimCard/index.tsx +++ b/src/components/ClaimCard/index.tsx @@ -1,12 +1,9 @@ import { ShieldOutlined } from '@mui/icons-material' import { Grid, Paper, Typography, Tooltip, Badge } from '@mui/material' -import { useTheme } from '@mui/material/styles' import { formatEther } from 'ethers/lib/utils' import InfoOutlined from '@mui/icons-material/InfoOutlined' import type { ReactElement } from 'react' -import SingleGreenTile from '@/public/images/single-green-tile.svg' -import DoubleGreenTile from '@/public/images/double-green-tile.svg' import { formatAmount } from '@/utils/formatters' import { Odometer } from '@/components/Odometer' @@ -25,33 +22,14 @@ export const ClaimCard = ({ variant: 'claimable' | 'vesting' decimals: number }): ReactElement => { - const { palette } = useTheme() - const ecosystemAmountInEth = formatEther(ecosystemAmount) const totalAmountInEth = formatEther(totalAmount) - const numericalTotalAmountInEth = Number(totalAmountInEth) const isClaimable = variant === 'claimable' - const color = isClaimable ? palette.background.default : palette.text.primary - return ( - (isClaimable ? palette.primary.main : palette.background.default), - color, - position: 'relative', - }} - > - {isClaimable && ( - <> - - - - )} - - + + {isClaimable ? 'Claim now' : 'Claim in future (vesting)'} {!isClaimable && ( @@ -60,21 +38,14 @@ export const ClaimCard = ({ arrow placement="top" > - + )} - + Total { - const onboard = useOnboard() - const chainId = useChainId() - - const onClick = async () => { - if (!onboard) { - return - } - - try { - const wallets = await onboard.connectWallet() - const wallet = getConnectedWallet(wallets) - - // Here we check non-hardware wallets. Hardware wallets will always be on the correct - // chain as onboard is only ever initialised with the current chain config - const isWrongChain = wallet && wallet.chainId !== chainId - if (isWrongChain) { - await onboard.setChain({ wallet: wallet.label, chainId: hexValue(parseInt(chainId)) }) - } - } catch { - return - } - } - - return ( - - - - - Welcome to the next generation of digital ownership - - - - - - - - Connect your wallet to view your SAFE balance and delegate voting power - - - - - - - - - - - ) -} diff --git a/src/components/ConnectWallet/styles.module.css b/src/components/ConnectWallet/styles.module.css new file mode 100644 index 00000000..d3ccbb5c --- /dev/null +++ b/src/components/ConnectWallet/styles.module.css @@ -0,0 +1,38 @@ +.milesReceipt { + width: 90%; + height: 537px; + display: flex; + overflow: hidden; + margin: 16px auto; +} + +.leftReceipt, +.rightReceipt { + background-color: #121312; + border-radius: 20px; + padding: 40px; +} + +.leftReceipt { + position: relative; + flex-grow: 1; +} + +.leftReceipt:after { + content: ''; + border-right: 1px dashed white; + position: absolute; + right: 0; + top: 20px; + height: calc(100% - 40px); +} + +.rightReceipt { + width: 388px; + position: relative; +} + +.barcode { + position: absolute; + right: 0; +} diff --git a/src/components/DashboardWidgets/ClaimingWidget.tsx b/src/components/DashboardWidgets/ClaimingWidget.tsx index 3287d2ec..4b8eed59 100644 --- a/src/components/DashboardWidgets/ClaimingWidget.tsx +++ b/src/components/DashboardWidgets/ClaimingWidget.tsx @@ -1,6 +1,6 @@ import { BigNumber } from 'ethers' import { formatEther } from 'ethers/lib/utils' -import { useSafeAppsSDK } from '@gnosis.pm/safe-apps-react-sdk' +import { useSafeAppsSDK } from '@safe-global/safe-apps-react-sdk' import { Box, Button, Typography, Link, Skeleton, Card, IconButton, Grid, Tooltip } from '@mui/material' import ModeEditOutlinedIcon from '@mui/icons-material/ModeEditOutlined' import CheckSharpIcon from '@mui/icons-material/CheckSharp' @@ -19,7 +19,6 @@ import { formatAmount } from '@/utils/formatters' import { Sep5DeadlineChip } from '@/components/Sep5DeadlineChip' import { TypographyChip } from '@/components/TypographyChip' import { canRedeemSep5Airdrop } from '@/utils/airdrop' -import { useIsDarkMode } from '@/hooks/useIsDarkMode' import css from './styles.module.css' @@ -166,7 +165,6 @@ const VotingPowerWidget = (): ReactElement => { export const ClaimingWidget = (): ReactElement => { const { data: allocation, isLoading } = useSafeTokenAllocation() const canRedeemSep5 = canRedeemSep5Airdrop(allocation) - const isDarkMode = useIsDarkMode() if (isLoading) { return ( @@ -188,9 +186,7 @@ export const ClaimingWidget = (): ReactElement => { sx={{ minWidth: WIDGET_WIDTH, maxWidth: WIDGET_WIDTH, - border: canRedeemSep5 - ? ({ palette }) => (isDarkMode ? `1px solid ${palette.primary.main}` : `1px solid ${palette.secondary.main}`) - : undefined, + border: canRedeemSep5 ? ({ palette }) => `1px solid ${palette.primary.main}` : undefined, }} > <>{allocation?.votingPower.eq(0) ? : } diff --git a/src/components/Delegation/index.tsx b/src/components/Delegation/index.tsx index d28e856a..9011289c 100644 --- a/src/components/Delegation/index.tsx +++ b/src/components/Delegation/index.tsx @@ -12,6 +12,7 @@ import { ProgressBar } from '@/components/ProgressBar' import { AppRoutes } from '@/config/routes' import type { Delegate } from '@/hooks/useDelegate' import type { ContractDelegate } from '@/hooks/useContractDelegate' +import MediumPaper from '../MediumPaper' export type DelegateFlow = { safeGuardian?: FileDelegate @@ -36,15 +37,17 @@ export const Delegation = (): ReactElement => { const steps = [ nextStep(data)} />, nextStep(data)} />, - router.push(AppRoutes.index)} />, + router.push(AppRoutes.governance)} />, ] const progress = ((step + 1) / steps.length) * 100 return ( <> - - {steps[step]} + + + {steps[step]} + ) } diff --git a/src/components/EducationSeries/index.tsx b/src/components/EducationSeries/index.tsx deleted file mode 100644 index 7c88935c..00000000 --- a/src/components/EducationSeries/index.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import type { ReactElement } from 'react' - -import { useRouter } from 'next/router' -import { useStepper } from '@/hooks/useStepper' -import SafeInfo from '@/components/EducationSeries/steps/SafeInfo' -import Distribution from '@/components/EducationSeries/steps/Distribution' -import SafeToken from '@/components/EducationSeries/steps/SafeToken' -import SafeDao from '@/components/EducationSeries/steps/SafeDao' -import Disclaimer from '@/components/EducationSeries/steps/Disclaimer' -import { AppRoutes } from '@/config/routes' -import { ProgressBar } from '@/components/ProgressBar' - -export const EducationSeries = (): ReactElement => { - const router = useRouter() - - const { step, prevStep, nextStep } = useStepper(undefined) - - const onNext = () => { - nextStep(undefined) - } - - const steps = [ - , - , - , - , - router.push(AppRoutes.index)} />, - ] - - const progress = ((step + 1) / steps.length) * 100 - - return ( - <> - - {steps[step]} - - ) -} diff --git a/src/components/EducationSeries/steps/Disclaimer/index.tsx b/src/components/EducationSeries/steps/Disclaimer/index.tsx deleted file mode 100644 index 867a462b..00000000 --- a/src/components/EducationSeries/steps/Disclaimer/index.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import { Grid, Typography } from '@mui/material' -import type { ReactElement } from 'react' - -import { StepHeader } from '@/components/StepHeader' -import { NavButtons } from '@/components/NavButtons' -import { useIsSafeApp } from '@/hooks/useIsSafeApp' - -const Disclaimer = ({ onBack, onNext }: { onBack: () => void; onNext: () => void }): ReactElement => { - const isSafeApp = useIsSafeApp() - - return ( - - - - - - - This {isSafeApp ? 'Safe{App}' : 'App'} is for our community to encourage Safe ecosystem contributors and - users to unlock Safe{`{DAO}`} governance. -
-
- THIS APP IS PROVIDED “AS IS” AND “AS AVAILABLE,” AT YOUR OWN RISK, AND WITHOUT WARRANTIES OF ANY KIND. We will - not be liable for any loss, whether such loss is direct, indirect, special or consequential, suffered by any - party as a result of their use of this app. -
-
- By accessing this app, you represent and warrant -
- that you are of legal age and that you will comply with any laws applicable to you and not engage in any - illegal activities; -
- that you are claiming Safe Tokens to participate in the Safe{`{DAO}`} governance process and that they - do not represent consideration for past or future services; -
- that you, the country you are a resident of and your wallet address is not on any sanctions lists - maintained by the United Nations, Switzerland, the EU, UK or the US; -
- that you are responsible for any tax obligations arising out of the interaction with this app. -
-
- None of the information available on this app, or made otherwise available to you in relation to its use, - constitutes any legal, tax, financial or other advice. Where in doubt as to the action you should take, please - consult your own legal, financial, tax or other professional advisors. -
- - -
- ) -} - -export default Disclaimer diff --git a/src/components/EducationSeries/steps/Distribution/index.tsx b/src/components/EducationSeries/steps/Distribution/index.tsx deleted file mode 100644 index 796bf87c..00000000 --- a/src/components/EducationSeries/steps/Distribution/index.tsx +++ /dev/null @@ -1,150 +0,0 @@ -import { Box, Grid, Typography } from '@mui/material' -import type { ReactElement } from 'react' - -import { StepHeader } from '@/components/StepHeader' -import DistributionChart from '@/public/images/distribution-chart.svg' -import lightPalette from '@/styles/colors' -import { ExternalLink } from '@/components/ExternalLink' -import { NavButtons } from '@/components/NavButtons' - -import css from './styles.module.css' - -const DISTRIBUTION_PROPOSAL_URL = 'https://forum.gnosis-safe.io/t/safe-voting-power-and-circulating-supply/558' - -const Distribution = ({ onBack, onNext }: { onBack: () => void; onNext: () => void }): ReactElement => { - return ( - - - - - - - Safe Tokens are distributed to stakeholders of the ecosystem interested in shaping the future of Safe and - Safe Accounts. - - - Read full proposal - - - - - - - - - - palette.secondary.main, - color: lightPalette.text.primary, - }} - > - 60% - - - Community Treasuries - - - - 40% Safe{`{DAO}`} Treasury -
- 15% GnosisDAO Treasury -
- 5% Joint Treasury (GNO <> SAFE) -
-
- - - - palette.secondary.light, - color: lightPalette.text.primary, - }} - > - 15% - - - Core Contributors - - - Current and future core contributor teams - - - - - palette.info.main, - color: lightPalette.text.primary, - }} - > - 15% - - - Safe Foundation - - - - 8% strategic raise -
- 7% grants and reserve -
-
- - - - palette.success.main, - color: ({ palette }) => palette.background.paper, - }} - > - 5% - - - Guardians - - - - 1.25% allocation -
- 1.25% vested allocation -
- 2.5% future programs -
-
- - - - palette.warning.main, - color: ({ palette }) => palette.background.paper, - }} - > - 5% - - - User - - - - 2.5% allocation -
- 2.5% vested allocation -
-
-
- - -
- ) -} - -export default Distribution diff --git a/src/components/EducationSeries/steps/Distribution/styles.module.css b/src/components/EducationSeries/steps/Distribution/styles.module.css deleted file mode 100644 index 4b0b75b0..00000000 --- a/src/components/EducationSeries/steps/Distribution/styles.module.css +++ /dev/null @@ -1,4 +0,0 @@ -.percentage { - border-radius: var(--mui-shape-borderRadius); - padding: 0px 8px; -} diff --git a/src/components/EducationSeries/steps/SafeDao/index.tsx b/src/components/EducationSeries/steps/SafeDao/index.tsx deleted file mode 100644 index 56dc2267..00000000 --- a/src/components/EducationSeries/steps/SafeDao/index.tsx +++ /dev/null @@ -1,76 +0,0 @@ -import { Grid, Typography, Box, Paper } from '@mui/material' -import Checkmark from '@mui/icons-material/Check' -import type { ReactElement, ReactNode } from 'react' - -import { StepHeader } from '@/components/StepHeader' -import { ExternalLink } from '@/components/ExternalLink' -import { NavButtons } from '@/components/NavButtons' -import { DISCORD_URL, FORUM_URL, GOVERNANCE_URL, CHAIN_SNAPSHOT_URL } from '@/config/constants' -import { useChainId } from '@/hooks/useChainId' - -import css from './styles.module.css' - -const Point = ({ children }: { children: ReactNode }): ReactElement => { - return ( - - - {children} - - ) -} - -const SafeDao = ({ onBack, onNext }: { onBack: () => void; onNext: () => void }): ReactElement => { - const chainId = useChainId() - - const snapshotUrl = CHAIN_SNAPSHOT_URL[chainId] - - return ( - - - - - - - Safe{`{DAO}`} aims to foster a vibrant ecosystem of applications and wallets leveraging Safe Accounts. This will - be achieved through data-backed discussions, grants, ecosystem investments, as well as providing developer tools - and infrastructure. - - - - How to get involved: - - - - - Discuss Safe{`{DAO}`} improvements - post topics and discuss in our{' '} - Forum - - - - Propose improvements - read our governance process and post - an "SEP". - - - - Govern improvements - vote on our Snapshot. - - - - Chat with the community - join our Safe Discord. - - - - - Now… -
- Help decide on the future of ownership with SAFE. -
-
-
- - -
- ) -} - -export default SafeDao diff --git a/src/components/EducationSeries/steps/SafeDao/styles.module.css b/src/components/EducationSeries/steps/SafeDao/styles.module.css deleted file mode 100644 index 94822e1f..00000000 --- a/src/components/EducationSeries/steps/SafeDao/styles.module.css +++ /dev/null @@ -1,6 +0,0 @@ -.info { - background-color: var(--mui-palette-secondary-background); - border-radius: var(--mui-shape-borderRadius); - padding: 24px; - min-width: 220px; -} diff --git a/src/components/EducationSeries/steps/SafeInfo/index.tsx b/src/components/EducationSeries/steps/SafeInfo/index.tsx deleted file mode 100644 index 50426ed2..00000000 --- a/src/components/EducationSeries/steps/SafeInfo/index.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import { Grid, Typography, Box } from '@mui/material' -import type { ReactElement } from 'react' - -import LockIcon from '@/public/images/lock.svg' -import AssetsIcon from '@/public/images/assets.svg' -import { StepHeader } from '@/components/StepHeader' -import { NavButtons } from '@/components/NavButtons' -import { InfoBox } from '@/components/InfoBox' - -const SafeInfo = ({ onNext }: { onNext: () => void }): ReactElement => { - return ( - - - - What is Safe? - - } - /> - - - - Safe is critical infrastructure for Web3. It is a programmable wallet that enables secure management of - digital assets, data and identity. - - - - - - - Total Safe Accounts created - - - - - >1,000,000 - - - - - - - - - Total value protected - - - - - $41B - - - - - - - - Why do we have a Token? - - - - As critical web3 infrastructure, Safe needs to be a community-owned, censorship resistant project, with a - committed ecosystem stewarding its decisions. A governance Token is needed to help coordinate this effort. - - - - - ) -} - -export default SafeInfo diff --git a/src/components/EducationSeries/steps/SafeToken/index.tsx b/src/components/EducationSeries/steps/SafeToken/index.tsx deleted file mode 100644 index 5523c75c..00000000 --- a/src/components/EducationSeries/steps/SafeToken/index.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import { Accordion, AccordionSummary, Typography, AccordionDetails, List, ListItem, Grid } from '@mui/material' -import ExpandMoreIcon from '@mui/icons-material/ExpandMore' -import type { ReactElement } from 'react' - -import { StepHeader } from '@/components/StepHeader' -import { NavButtons } from '@/components/NavButtons' - -import css from './styles.module.css' - -const InfoAccordion = ({ summaryText, details }: { summaryText: ReactElement | string; details: string[] }) => { - return ( - - } className={css.summary}> - {summaryText} - - - - - {details.map((detail, i) => ( - {detail} - ))} - - - - ) -} - -const SafeToken = ({ onBack, onNext }: { onBack: () => void; onNext: () => void }): ReactElement => { - return ( - - - - - - - SAFE is an ERC-20 governance token that stewards infrastructure components of the Safe ecosystem, - including: - - - - - - - - - - - - - ) -} - -export default SafeToken diff --git a/src/components/EducationSeries/steps/SafeToken/styles.module.css b/src/components/EducationSeries/steps/SafeToken/styles.module.css deleted file mode 100644 index 02ea8dfa..00000000 --- a/src/components/EducationSeries/steps/SafeToken/styles.module.css +++ /dev/null @@ -1,57 +0,0 @@ -.accordion { - border: 1px solid var(--mui-palette-border-light); - border-radius: var(--mui-shape-borderRadius); - margin-bottom: 16px; - box-shadow: none; - width: 100%; -} - -.accordion::before { - display: none; -} - -.accordion :global .Mui-expanded { - border-radius: var(--mui-shape-borderRadius); - background-color: var(--mui-palette-background-light); -} - -.accordion:hover { - border: 1px solid var(--mui-palette-secondary-main); - background-color: var(--mui-palette-secondary-background); -} - -[data-theme='dark'] .accordion:hover { - border: 1px solid var(--mui-palette-primary-main); - background-color: var(--mui-palette-background-light); -} - -.details { - padding-top: 0; - background-color: var(--mui-palette-background-light); -} - -.summaryText { - font-weight: 700; - position: relative; - margin-left: 24px; - list-style-type: none; -} - -.summaryText::before { - content: ''; - border-radius: 2px; - position: absolute; - background-color: var(--mui-palette-text-primary); - min-width: 6px; - min-height: 6px; - top: 9px; - left: -24px; -} - -.list { - padding-top: 0; -} - -.list :global .MuiListItem-root { - padding: 4px 0 4px 24px; -} diff --git a/src/components/EnsureWalletConnection/index.tsx b/src/components/EnsureWalletConnection/index.tsx index c2b6c93e..a5a23d3e 100644 --- a/src/components/EnsureWalletConnection/index.tsx +++ b/src/components/EnsureWalletConnection/index.tsx @@ -6,7 +6,9 @@ import { Redirect } from '../Redirect' import { useWeb3 } from '@/hooks/useWeb3' const isProviderRoute = (pathname: string) => { - return [AppRoutes.claim, AppRoutes.delegate].includes(pathname) + return [AppRoutes.claim, AppRoutes.delegate, AppRoutes.activity, AppRoutes.governance, AppRoutes.unlock].includes( + pathname, + ) } export const EnsureWalletConnection = ({ children }: { children: ReactElement }): ReactElement => { @@ -15,5 +17,5 @@ export const EnsureWalletConnection = ({ children }: { children: ReactElement }) const shouldRedirect = !web3 && isProviderRoute(router.pathname) - return shouldRedirect ? : children + return shouldRedirect ? : children } diff --git a/src/components/ExternalLink/index.tsx b/src/components/ExternalLink/index.tsx index 89d3885f..98df548e 100644 --- a/src/components/ExternalLink/index.tsx +++ b/src/components/ExternalLink/index.tsx @@ -1,4 +1,4 @@ -import { Link } from '@mui/material' +import { Button, Link } from '@mui/material' import type { LinkProps } from '@mui/material' import { CSSProperties, forwardRef, ReactElement } from 'react' @@ -8,14 +8,32 @@ const styles: CSSProperties = { marginLeft: '4px', } -export const ExternalLink = forwardRef( - ({ children, icon = true, ...props }, ref): ReactElement => { - return ( - - {children} - {icon && } - - ) - }, -) +export const ExternalLink = forwardRef< + HTMLAnchorElement, + LinkProps & { href: string; icon?: boolean; variant?: string } +>(({ children, icon = true, variant = 'link', href, ...props }, ref): ReactElement => { + return ( + <> + {variant === 'button' ? ( + + ) : ( + + {children} + {icon && } + + )} + + ) +}) ExternalLink.displayName = 'ExternalLink' diff --git a/src/components/FloatingTiles/index.tsx b/src/components/FloatingTiles/index.tsx deleted file mode 100644 index cdada02e..00000000 --- a/src/components/FloatingTiles/index.tsx +++ /dev/null @@ -1,164 +0,0 @@ -import { Box } from '@mui/material' -import styled from '@emotion/styled' -import { useRef, useEffect, useMemo, useState } from 'react' - -import css from './styles.module.css' - -const DIMENSIONS = 1000 -const RADIUS = 40 // Percentage -const MIN_SIZE = 20 -const MAX_SIZE = 50 -const VARIANCE_FACTOR = 4 -const ANIMATION_DURATION = '90s' - -// Uses Box Muller transform to generate a 0,1 gaussian -const randomGaussian = () => { - const u = 1 - Math.random() - const v = Math.random() - return Math.sqrt(-2.0 * Math.log(u)) * Math.cos(2.0 * Math.PI * v) -} - -const randomPointOnCircle = () => { - const angle = Math.random() * Math.PI * 2 - const adjustedRadius = RADIUS + VARIANCE_FACTOR * randomGaussian() - const x = Math.cos(angle) * adjustedRadius + 50 - const y = Math.sin(angle) * adjustedRadius + 50 - return [x, y] -} - -const Orbit = styled(Box)` - width: ${DIMENSIONS}px; - height: ${DIMENSIONS}px; - z-index: -1; - margin: auto; - opacity: 70%; - overflow: visible; - animation: 2s ease-in-out 1 grow, ${ANIMATION_DURATION} linear 2s infinite orbit; - - @keyframes grow { - from { - transform: scale(1); - } - to { - transform: scale(1); - } - } - - @keyframes orbit { - 0% { - transform: rotate(0deg); - } - 50% { - transform: rotate(180deg); - } - 100% { - transform: rotate(360deg); - } - } - - /* Tone down the animation to avoid vestibular motion triggers */ - @media (prefers-reduced-motion) { - animation-name: none; - } -` - -const TileBox = styled(Box)` - animation-iteration-count: infinite; - animation-timing-function: linear; - animation-delay: 2s; - animation-name: anti-orbit; - animation-duration: ${ANIMATION_DURATION}; - position: absolute; - - @keyframes anti-orbit { - 0% { - transform: rotate(0deg); - } - 50% { - transform: rotate(-180deg); - } - 100% { - transform: rotate(-360deg); - } - } - - /* Tone down the animation to avoid vestibular motion triggers */ - @media (prefers-reduced-motion) { - animation-name: none; - } -` - -const Tile = ({ - top, - left, - size, - startTime, -}: { - top: string - left: string - size: string - startTime: number | undefined | null -}) => { - const tileRef = useRef(null) - - useEffect(() => { - const animation = tileRef.current?.getAnimations()[0] - if (animation && startTime) { - animation.startTime = startTime - } - }, [startTime, tileRef]) - - return ( - -
- - ) -} - -export const FloatingTiles = ({ tiles }: { tiles: number }) => { - const orbitRef = useRef(null) - const [animationStartTime, setAnimationStartTime] = useState() - - useEffect(() => { - const timer = setTimeout(() => { - if (!animationStartTime) { - setAnimationStartTime(orbitRef.current?.getAnimations()[0]?.startTime) - } - }, 0) - - return () => { - clearTimeout(timer) - } - }, [animationStartTime, orbitRef]) - - const tilesArr: { - top: string - left: string - size: string - }[] = useMemo(() => { - return Array.apply('', Array(tiles)).map(() => { - const [x, y] = randomPointOnCircle() - - const top = `${x.toFixed(2)}%` - const left = `${y.toFixed(2)}%` - - const size = `${(Math.random() * (MAX_SIZE - MIN_SIZE) + MIN_SIZE).toFixed(2)}px` - - return { - top, - left, - size, - } - }) - }, [tiles]) - - return ( -
- - {tilesArr.map((tile, i) => ( - - ))} - -
- ) -} diff --git a/src/components/FloatingTiles/styles.module.css b/src/components/FloatingTiles/styles.module.css deleted file mode 100644 index 2c39af80..00000000 --- a/src/components/FloatingTiles/styles.module.css +++ /dev/null @@ -1,20 +0,0 @@ -.container { - width: 100%; - height: 100%; - position: fixed; - display: flex; - z-index: -1; -} - -.spacer { - position: absolute; - transition: transform 1s ease-in; - border-radius: 20%; - width: 100%; - height: 100%; - background-color: var(--mui-palette-secondary-main); -} - -[data-theme='dark'] .spacer { - background-color: var(--mui-palette-primary-main); -} diff --git a/src/components/Intro/index.tsx b/src/components/GovernanceAndClaiming/index.tsx similarity index 50% rename from src/components/Intro/index.tsx rename to src/components/GovernanceAndClaiming/index.tsx index 073911dc..67c65e05 100644 --- a/src/components/Intro/index.tsx +++ b/src/components/GovernanceAndClaiming/index.tsx @@ -1,6 +1,5 @@ -import { Grid, Typography, Box, Button, CircularProgress } from '@mui/material' +import { Grid, Typography, Box, Button, CircularProgress, Stack } from '@mui/material' import { useRouter } from 'next/router' -import { formatEther } from 'ethers/lib/utils' import { BigNumber } from 'ethers' import type { ReactElement } from 'react' @@ -10,7 +9,6 @@ import { SelectedDelegate } from '@/components/SelectedDelegate' import { AppRoutes } from '@/config/routes' import { useSafeTokenAllocation } from '@/hooks/useSafeTokenAllocation' import { TotalVotingPower } from '@/components/TotalVotingPower' -import { formatAmount } from '@/utils/formatters' import { useTaggedAllocations } from '@/hooks/useTaggedAllocations' import { useIsWrongChain } from '@/hooks/useIsWrongChain' import SafeToken from '@/public/images/token.svg' @@ -18,14 +16,13 @@ import { getGovernanceAppSafeAppUrl } from '@/utils/safe-apps' import { useChainId } from '@/hooks/useChainId' import { useWallet } from '@/hooks/useWallet' import { isSafe } from '@/utils/wallet' -import { InfoBox } from '@/components/InfoBox' import { useIsDelegationPending } from '@/hooks/usePendingDelegations' -import { Sep5InfoBox } from '@/components/Sep5InfoBox' -import { canRedeemSep5Airdrop } from '@/utils/airdrop' +import ClaimOverview from '@/components/Claim' +import PaperContainer from '@/components/PaperContainer' import css from './styles.module.css' -export const Intro = (): ReactElement => { +export const GovernanceAndClaiming = (): ReactElement => { const router = useRouter() const isWrongChain = useIsWrongChain() const wallet = useWallet() @@ -36,9 +33,6 @@ export const Intro = (): ReactElement => { const { isLoading, data: allocation } = useSafeTokenAllocation() const { total } = useTaggedAllocations() - const canRedeemSep5 = canRedeemSep5Airdrop(allocation) - - const hasAllocation = Number(total.allocation) > 0 const isClaimable = Number(total.claimable) > 0 const isDelegating = useIsDelegationPending() @@ -54,8 +48,6 @@ export const Intro = (): ReactElement => { } } - const onClaim = onClick(AppRoutes.claim) - const onDelegate = onClick(AppRoutes.delegate) const action = ( @@ -72,56 +64,38 @@ export const Intro = (): ReactElement => { ) } return ( - - - - - - - - - - - - {hasAllocation && ( - - - - Claimable now - - {formatAmount(formatEther(total.claimable), 2)} SAFE - - - - Claimable in the future - - {formatAmount(formatEther(total.inVesting), 2)} SAFE - - - )} + + + Claim SAFE tokens and engage in governance + - {canRedeemSep5 && ( - - - - )} + + + {isClaimable && } - {isClaimable && ( - - + + + Delegate your voting power + + + - )} - + + - - - + + + + - - + + Total voting power is + + + + + ) diff --git a/src/components/GovernanceAndClaiming/styles.module.css b/src/components/GovernanceAndClaiming/styles.module.css new file mode 100644 index 00000000..09d716db --- /dev/null +++ b/src/components/GovernanceAndClaiming/styles.module.css @@ -0,0 +1,5 @@ +@media (max-width: 899px) { + .pageTitle { + margin: 16px; + } +} diff --git a/src/components/Header/index.tsx b/src/components/Header/index.tsx index 2e9a546f..47857d5e 100644 --- a/src/components/Header/index.tsx +++ b/src/components/Header/index.tsx @@ -6,6 +6,7 @@ import { AccountCenter } from '@/components/AccountCenter' import { ChainSwitcher } from '@/components/ChainSwitcher' import { useIsSafeApp } from '@/hooks/useIsSafeApp' import { TestChainSwitch } from '@/components/TestChainSwitch' +import { TestDateSelect } from '@/components/TestDateSelect' import css from './styles.module.css' @@ -21,6 +22,7 @@ export const Header = (): ReactElement | null => { +
diff --git a/src/components/Header/styles.module.css b/src/components/Header/styles.module.css index 53ce330d..1c8e7d14 100644 --- a/src/components/Header/styles.module.css +++ b/src/components/Header/styles.module.css @@ -4,6 +4,10 @@ align-items: center; border-radius: 0 !important; border-bottom: 1px solid var(--mui-palette-border-light); + position: absolute; + top: 0; + left: 0; + width: 100%; } .wallet { diff --git a/src/components/InfoAlert/index.tsx b/src/components/InfoAlert/index.tsx index 18434892..1baf2f66 100644 --- a/src/components/InfoAlert/index.tsx +++ b/src/components/InfoAlert/index.tsx @@ -5,7 +5,7 @@ import type { ReactElement } from 'react' export const InfoAlert = ({ children, ...props }: BoxProps): ReactElement => { return ( - + { + return {props.children} +} + +export default MediumPaper diff --git a/src/components/MediumPaper/styles.module.css b/src/components/MediumPaper/styles.module.css new file mode 100644 index 00000000..95c1246a --- /dev/null +++ b/src/components/MediumPaper/styles.module.css @@ -0,0 +1,12 @@ +.mediumPaper { + width: 650px; + position: relative; + margin: auto; +} + +@media (max-width: 600px) { + .mediumPaper { + height: 100%; + width: 100%; + } +} diff --git a/src/components/NavTabs/index.tsx b/src/components/NavTabs/index.tsx new file mode 100644 index 00000000..ae6b9be8 --- /dev/null +++ b/src/components/NavTabs/index.tsx @@ -0,0 +1,72 @@ +import React, { forwardRef, ReactElement } from 'react' +import NextLink, { type LinkProps as NextLinkProps } from 'next/link' +import { Tab, Tabs, Typography, type TabProps } from '@mui/material' +import { useRouter } from 'next/router' +import css from './styles.module.css' +import { useIsSafeApp } from '@/hooks/useIsSafeApp' +import Track from '../Track' + +export type NavItem = { + label: string + icon?: ReactElement + href: string + event: { action: string } +} + +type Props = TabProps & NextLinkProps + +// This is needed in order for the tabs to work properly with Next Link e.g. tabbing with the keyboard +// Based on https://github.com/mui/material-ui/blob/master/examples/nextjs-with-typescript/src/Link.tsx +const NextLinkComposed = forwardRef(function NextComposedLink(props: Props, ref) { + const { href, as, replace, scroll, shallow, prefetch, legacyBehavior = true, locale, ...other } = props + + return ( + + {/* @ts-ignore */} + + + ) +}) + +const NavTabs = ({ tabs }: { tabs: NavItem[] }) => { + const router = useRouter() + const activeTab = Math.max(0, tabs.map((tab) => tab.href).indexOf(router.pathname)) + + const isSafeApp = useIsSafeApp() + + return ( + + {tabs.map((tab, idx) => ( + + + {tab.label} + + } + /> + + ))} + + ) +} + +export default NavTabs diff --git a/src/components/NavTabs/styles.module.css b/src/components/NavTabs/styles.module.css new file mode 100644 index 00000000..604ec8c3 --- /dev/null +++ b/src/components/NavTabs/styles.module.css @@ -0,0 +1,49 @@ +.tabs { + overflow: initial; + align-self: flex-start; + width: 100%; +} + +/* Scroll buttons */ +.tabs :global .MuiTabs-scrollButtons.Mui-disabled { + opacity: 0.3; +} + +.tabs :global .MuiTabScrollButton-root ~ .MuiTabs-scroller p { + padding-bottom: 0; +} + +.tabs :global .MuiTabScrollButton-root:first-of-type { + margin-left: calc(16 * -1); +} + +.tabs :global .MuiTabScrollButton-root:last-of-type { + margin-right: calc(16 * -1); +} + +.tabs :global .MuiTabScrollButton-root ~ .MuiTabs-scroller p { + padding-bottom: 0; +} + +.tab { + opacity: 1; + padding: 0 24; + position: relative; + z-index: 2; +} + +.label { + text-transform: none; + padding-bottom: 6px; +} + +@media (max-width: 599px) { + .tabs span { + flex: 1; + min-width: fit-content; + } + + .tab { + width: 100%; + } +} diff --git a/src/components/OverviewLinks/index.tsx b/src/components/OverviewLinks/index.tsx index e3a883a6..288e67c4 100644 --- a/src/components/OverviewLinks/index.tsx +++ b/src/components/OverviewLinks/index.tsx @@ -1,10 +1,8 @@ -import NextLink from 'next/link' -import { SvgIcon, Grid, Typography, Paper, Box, Link } from '@mui/material' +import { SvgIcon, Typography, Paper, Box, Stack } from '@mui/material' import { useRef } from 'react' import type { ReactElement, SyntheticEvent } from 'react' import Hat from '@/public/images/hat.svg' -import { AppRoutes } from '@/config/routes' import { FORUM_URL, CHAIN_SNAPSHOT_URL } from '@/config/constants' import { ExternalLink } from '@/components/ExternalLink' import { useChainId } from '@/hooks/useChainId' @@ -27,14 +25,12 @@ const SafeDaoCard = () => {
- - What is -
- Safe{`{DAO}`}? + + What is Safe DAO? - + Learn more - +
) @@ -65,18 +61,10 @@ export const OverviewLinks = (): ReactElement => { const snapshotUrl = CHAIN_SNAPSHOT_URL[chainId] return ( - - - - - - - - - - - - - + + + + + ) } diff --git a/src/components/PageLayout/index.tsx b/src/components/PageLayout/index.tsx index 34e4ab60..69620f04 100644 --- a/src/components/PageLayout/index.tsx +++ b/src/components/PageLayout/index.tsx @@ -1,15 +1,23 @@ import Head from 'next/head' -import { Box, Paper } from '@mui/material' -import type { ReactElement, ReactNode } from 'react' +import { Box } from '@mui/material' +import { ReactElement, ReactNode } from 'react' import manifestJson from '@/public/manifest.json' -import { BottomCircle, TopCircle } from '@/components/BackgroundCircles' +import { BackgroundCircles } from '@/components/BackgroundCircles' import { Header } from '@/components/Header' -import { FloatingTiles } from '@/components/FloatingTiles' import css from './styles.module.css' +import NavTabs from '../NavTabs' +import { AppRoutes } from '@/config/routes' +import { useRouter } from 'next/router' +import { NAVIGATION_EVENTS } from '@/analytics/navigation' + +const RoutesWithNavigation = [AppRoutes.activity, AppRoutes.governance] export const PageLayout = ({ children }: { children: ReactNode }): ReactElement => { + const router = useRouter() + const showNavigation = RoutesWithNavigation.includes(router.route) + return ( <> @@ -18,18 +26,30 @@ export const PageLayout = ({ children }: { children: ReactNode }): ReactElement
-
- -
- - - - - - {children} - - - + + + + + {showNavigation && ( + + + + )} + {children} + ) diff --git a/src/components/PageLayout/styles.module.css b/src/components/PageLayout/styles.module.css index c00c63ef..70cecc72 100644 --- a/src/components/PageLayout/styles.module.css +++ b/src/components/PageLayout/styles.module.css @@ -1,21 +1,42 @@ .container { - width: 650px; + width: 100%; + display: flex; + align-items: center; + flex-direction: column; +} + +.navigation { + width: 100%; + padding-left: 15vw; + padding-right: 15vw; + border-bottom: 1px solid #303033; +} + +.pageContent { + width: 70vw; position: relative; margin: auto; } -.tiles { - position: relative; - top: -200px; +@media (max-width: 1600px) { + .pageContent { + width: 90vw; + } + .navigation { + padding-left: 5vw; + padding-right: 5vw; + } } -@media (max-width: 600px) { +@media (max-width: 899px) { .container { - width: 100%; height: 100%; + width: 100%; } - - .tiles { - display: none; + .pageContent { + width: 100%; + } + .navigation { + padding: 0; } } diff --git a/src/components/PaperContainer/index.tsx b/src/components/PaperContainer/index.tsx new file mode 100644 index 00000000..5e9a0db5 --- /dev/null +++ b/src/components/PaperContainer/index.tsx @@ -0,0 +1,12 @@ +import { Paper, PaperProps } from '@mui/material' +import css from './styles.module.css' + +const PaperContainer = (props: PaperProps) => { + return ( + + {props.children} + + ) +} + +export default PaperContainer diff --git a/src/components/PaperContainer/styles.module.css b/src/components/PaperContainer/styles.module.css new file mode 100644 index 00000000..54ee4d98 --- /dev/null +++ b/src/components/PaperContainer/styles.module.css @@ -0,0 +1,6 @@ +.paper { + padding: 24px; + display: flex; + flex-direction: column; + gap: 24px; +} diff --git a/src/components/Redirect/index.tsx b/src/components/Redirect/index.tsx index 616627ec..ff429a72 100644 --- a/src/components/Redirect/index.tsx +++ b/src/components/Redirect/index.tsx @@ -1,16 +1,24 @@ import { useRouter } from 'next/router' -import type { UrlObject } from 'url' import { useIsomorphicLayoutEffect } from '@/hooks/useIsomorphicLayoutEffect' +import { ParsedUrlQueryInput } from 'querystring' -export const Redirect = ({ url, replace }: { url: string | UrlObject; replace?: boolean }): null => { +export const Redirect = ({ + url, + replace, + query, +}: { + url: string + replace?: boolean + query?: ParsedUrlQueryInput +}): null => { const router = useRouter() useIsomorphicLayoutEffect(() => { if (replace) { router.replace(url) } else { - router.push(url) + router.push({ pathname: url, query }) } }, [replace, router, url]) diff --git a/src/components/SelectedDelegate/index.tsx b/src/components/SelectedDelegate/index.tsx index e26e1229..1aeff57e 100644 --- a/src/components/SelectedDelegate/index.tsx +++ b/src/components/SelectedDelegate/index.tsx @@ -86,8 +86,8 @@ export const SelectedDelegate = ({ {hint && ( - - You only delegate your voting power and not the ownership of your Safe Tokens. + + You only delegate your voting power and not the ownership over your tokens. )} diff --git a/src/components/SplashScreen/index.tsx b/src/components/SplashScreen/index.tsx new file mode 100644 index 00000000..4fa9678d --- /dev/null +++ b/src/components/SplashScreen/index.tsx @@ -0,0 +1,163 @@ +import { Typography, Button, Chip, Stack, SvgIcon, Box, CircularProgress, Grid } from '@mui/material' +import { hexValue } from 'ethers/lib/utils' +import { ReactElement, useState } from 'react' + +import { useOnboard } from '@/hooks/useOnboard' + +import { useChainId } from '@/hooks/useChainId' +import { getConnectedWallet, useWallet } from '@/hooks/useWallet' +import { useRouter } from 'next/router' +import { AppRoutes } from '@/config/routes' +import Barcode from '@/public/images/barcode.svg' + +import css from './styles.module.css' +import SafePass from '@/public/images/safe-pass.svg' +import { useIsSafeApp } from '@/hooks/useIsSafeApp' +import Asterix from '@/public/images/asterix.svg' +import { localItem } from '@/services/storage/local' +import { useIsomorphicLayoutEffect } from '@/hooks/useIsomorphicLayoutEffect' +import { isSafe } from '@/utils/wallet' +import { trackSafeAppEvent } from '@/utils/analytics' +import { NAVIGATION_EVENTS } from '@/analytics/navigation' + +const Step = ({ index, title, active }: { index: number; title: string; active: boolean }) => { + return ( + + + {title} + + ) +} + +// Check if new or returning user +const ALREADY_VISITED = 'alreadyVisited' +const alreadyVisitedStorage = localItem(ALREADY_VISITED) + +/** + * This page handles wallet connection and initial data loading. + */ +export const SplashScreen = (): ReactElement => { + const onboard = useOnboard() + const chainId = useChainId() + const router = useRouter() + const isSafeApp = useIsSafeApp() + + const [isConnecting, setIsConnecting] = useState(false) + const [error, setError] = useState() + + const wallet = useWallet() + + const isDisconnected = !isSafeApp && !wallet + + const onConnect = async () => { + if (!onboard) { + return + } + setError(undefined) + setIsConnecting(true) + try { + const wallets = await onboard.connectWallet() + const wallet = getConnectedWallet(wallets) + + // Here we check non-hardware wallets. Hardware wallets will always be on the correct + // chain as onboard is only ever initialised with the current chain config + const isWrongChain = wallet && wallet.chainId !== chainId + if (isWrongChain) { + await onboard.setChain({ wallet: wallet.label, chainId: hexValue(parseInt(chainId)) }) + } + + // When using the standalone app, only allow Safe accounts to be connected + if (wallet && !isSafeApp && !(await isSafe(wallet))) { + await onboard.disconnectWallet({ label: wallet.label }) + setError('Connected wallet must be a Safe') + return + } + onContinue() + } catch (error) { + setError('Wallet connection failed.') + return + } finally { + setIsConnecting(false) + } + } + + const onContinue = async () => { + trackSafeAppEvent(NAVIGATION_EVENTS.OPEN_LOCKING.action, 'opening') + alreadyVisitedStorage.set(true) + const nextPage = router.query.next === AppRoutes.governance ? AppRoutes.governance : AppRoutes.activity + router.push(nextPage) + } + + useIsomorphicLayoutEffect(() => { + const hasAlreadyVisited = alreadyVisitedStorage.get() + if (isSafeApp && hasAlreadyVisited) { + // We are a returning user and already connected + // We only skip this step for the Safe App as we have to connect a wallet in the standalone version + onContinue() + } + }, [isSafeApp]) + + return ( + + + + + + + + + Interact with Safe and get rewards + + Get your pass now! Lock your tokens and be active on Safe to get rewarded. + + {isDisconnected ? ( + + ) : ( + + )} + {error && ( + + {error} + + )} + + + + + + + + How it works + + + + + + + + + + + + ) +} diff --git a/src/components/SplashScreen/styles.module.css b/src/components/SplashScreen/styles.module.css new file mode 100644 index 00000000..f082accc --- /dev/null +++ b/src/components/SplashScreen/styles.module.css @@ -0,0 +1,55 @@ +.milesReceipt { + width: 90%; + display: flex; + overflow: hidden; +} + +.leftReceipt, +.rightReceipt { + background-color: #121312; + border-radius: 20px; + padding: 40px; + height: 537px; + width: 100%; +} + +.leftReceipt { + position: relative; + flex-grow: 1; +} + +.leftReceipt:after { + content: ''; + border-right: 1px dashed white; + position: absolute; + right: 0; + top: 20px; + height: calc(100% - 40px); +} + +.barcode { + position: absolute; + right: 10%; +} + +@media (max-width: 899px) { + .milesReceipt { + width: 100%; + } + + .leftReceipt, + .rightReceipt { + border-radius: 0px; + } + .barcode { + right: 0px; + } + + .leftReceipt:after { + display: none; + } + + .leftReceipt { + border-bottom: 1px dashed white; + } +} diff --git a/src/components/StepHeader/index.tsx b/src/components/StepHeader/index.tsx index cbceafef..61484dc2 100644 --- a/src/components/StepHeader/index.tsx +++ b/src/components/StepHeader/index.tsx @@ -9,7 +9,7 @@ export const StepHeader = ({ title }: { title: ReactNode }): ReactElement => { const router = useRouter() const onClose = () => { - router.push(AppRoutes.index) + router.push(AppRoutes.governance) } return ( diff --git a/src/components/TestChainSwitch/index.tsx b/src/components/TestChainSwitch/index.tsx index 8033de1d..c3be599c 100644 --- a/src/components/TestChainSwitch/index.tsx +++ b/src/components/TestChainSwitch/index.tsx @@ -1,7 +1,7 @@ import { FormControlLabel, Switch } from '@mui/material' import type { ReactElement } from 'react' -import { Chains, IS_PRODUCTION, _DEFAULT_CHAIN_ID } from '@/config/constants' +import { Chains, IS_PRODUCTION } from '@/config/constants' import { useChainId, defaultChainIdStore } from '@/hooks/useChainId' export const TestChainSwitch = (): ReactElement | null => { @@ -9,7 +9,7 @@ export const TestChainSwitch = (): ReactElement | null => { const onToggle = () => { defaultChainIdStore.setStore((prev) => { - return prev === Chains.GOERLI ? Chains.MAINNET : Chains.GOERLI + return prev === Chains.SEPOLIA ? Chains.MAINNET : Chains.SEPOLIA }) } @@ -18,6 +18,9 @@ export const TestChainSwitch = (): ReactElement | null => { } return ( - } label="Use Goerli" /> + } + label="Use Sepolia" + /> ) } diff --git a/src/components/TestDateSelect/index.tsx b/src/components/TestDateSelect/index.tsx new file mode 100644 index 00000000..92af128c --- /dev/null +++ b/src/components/TestDateSelect/index.tsx @@ -0,0 +1,34 @@ +import type { ReactElement } from 'react' + +import { IS_PRODUCTION } from '@/config/constants' +import { DateTimePicker } from '@mui/x-date-pickers/DateTimePicker' +import { LocalizationProvider } from '@mui/x-date-pickers' +import { AdapterDateFns } from '@mui/x-date-pickers/AdapterDateFns' +import { startDateStore, useStartDate } from '@/hooks/useStartDates' + +export const TestDateSelect = (): ReactElement | null => { + const { startTime } = useStartDate() + + const onDateSelect = (date: Date | null) => { + if (date) { + const timestamp = date.getTime() + + startDateStore.setStore(timestamp) + } + } + + if (IS_PRODUCTION) { + return null + } + + return ( + + onDateSelect(value)} + slotProps={{ textField: { size: 'small' } }} + label="start date" + /> + + ) +} diff --git a/src/components/TockenUnlocking/UnlockStats.tsx b/src/components/TockenUnlocking/UnlockStats.tsx new file mode 100644 index 00000000..5826a336 --- /dev/null +++ b/src/components/TockenUnlocking/UnlockStats.tsx @@ -0,0 +1,24 @@ +import { Grid } from '@mui/material' +import { BigNumberish } from 'ethers' + +import { TokenAmount } from '../TokenAmount' + +export const UnlockStats = ({ + currentlyLocked, + unlockedTotal, +}: { + currentlyLocked: BigNumberish + unlockedTotal: BigNumberish +}) => { + return ( + + + + + + + + + + ) +} diff --git a/src/components/TockenUnlocking/UnlockTokenWidget.tsx b/src/components/TockenUnlocking/UnlockTokenWidget.tsx new file mode 100644 index 00000000..455a9a96 --- /dev/null +++ b/src/components/TockenUnlocking/UnlockTokenWidget.tsx @@ -0,0 +1,195 @@ +import SafeToken from '@/public/images/token.svg' +import { getBoostFunction } from '@/utils/boost' +import css from './styles.module.css' +import { Stack, Grid, Typography, TextField, InputAdornment, Button, CircularProgress, Box } from '@mui/material' +import { formatUnits, parseUnits } from 'ethers/lib/utils' +import { BoostGraph } from '../TokenLocking/BoostGraph/BoostGraph' +import { useDebounce } from '@/hooks/useDebounce' +import { createUnlockTx, LockHistory } from '@/utils/lock' +import { useState, useMemo, ChangeEvent, useCallback, useEffect } from 'react' +import { BigNumber, BigNumberish } from 'ethers' +import { useChainId } from '@/hooks/useChainId' +import { getCurrentDays } from '@/utils/date' +import { SEASON2_START } from '@/config/constants' +import { BoostBreakdown } from '../TokenLocking/BoostBreakdown' +import Track from '../Track' +import { LOCK_EVENTS } from '@/analytics/lockEvents' +import { trackSafeAppEvent } from '@/utils/analytics' +import MilesReceipt from '@/components/TokenLocking/MilesReceipt' +import { useTxSender } from '@/hooks/useTxSender' +import { useStartDate } from '@/hooks/useStartDates' + +export const UnlockTokenWidget = ({ + lockHistory, + currentlyLocked, +}: { + lockHistory: LockHistory[] + currentlyLocked: BigNumberish +}) => { + const [receiptOpen, setReceiptOpen] = useState(false) + const [receiptInformation, setReceiptInformation] = useState<{ newFinalBoost: number; amount: string }>({ + amount: '0', + newFinalBoost: 1, + }) + const [unlockAmount, setUnlockAmount] = useState('0') + + const [unlockAmountError, setUnlockAmountError] = useState() + + const [isUnlocking, setIsUnlocking] = useState(false) + + const chainId = useChainId() + const txSender = useTxSender() + + const { startTime } = useStartDate() + const todayInDays = getCurrentDays(startTime) + + const debouncedAmount = useDebounce(unlockAmount, 1000, '0') + const cleanedAmount = useMemo(() => (debouncedAmount.trim() === '' ? '0' : debouncedAmount.trim()), [debouncedAmount]) + + useEffect(() => { + if (debouncedAmount !== '0') { + trackSafeAppEvent(LOCK_EVENTS.CHANGE_UNLOCK_AMOUNT.action, LOCK_EVENTS.CHANGE_UNLOCK_AMOUNT.label) + } + }, [debouncedAmount]) + + const onCloseReceipt = () => { + setUnlockAmount('0') + setReceiptOpen(false) + } + + const currentBoostFunction = useMemo(() => getBoostFunction(todayInDays, 0, lockHistory), [todayInDays, lockHistory]) + const newBoostFunction = useMemo( + () => getBoostFunction(todayInDays, -Number(cleanedAmount), lockHistory), + [cleanedAmount, lockHistory, todayInDays], + ) + + const onChangeUnlockAmount = (event: ChangeEvent) => { + const newValue = event.target.value.replaceAll(',', '.') + const error = validateAmount(newValue || '0') + setUnlockAmount(newValue) + setUnlockAmountError(error) + } + + const validateAmount = useCallback( + (newAmount: string) => { + const numberAmount = Number(newAmount) + if (isNaN(numberAmount)) { + return 'The value must be a number' + } + const parsed = parseUnits(numberAmount.toString(), 18) + if (parsed.gt(currentlyLocked ?? '0')) { + return 'Amount exceeds your locked tokens.' + } + + if (parsed.lte(0)) { + return 'Amount must be greater than zero' + } + }, + [currentlyLocked], + ) + + const onSetToMax = useCallback(() => { + if (!currentlyLocked || BigNumber.from(currentlyLocked).eq(0)) { + return + } + setUnlockAmount(formatUnits(currentlyLocked, 18)) + }, [currentlyLocked]) + + const onUnlock = async () => { + setIsUnlocking(true) + const unlockTx = createUnlockTx(chainId, parseUnits(unlockAmount, 18)) + const newFinalBoost = newBoostFunction({ x: SEASON2_START }) + try { + await txSender?.sendTxs([unlockTx]) + trackSafeAppEvent(LOCK_EVENTS.UNLOCK_SUCCESS.action) + setReceiptInformation({ newFinalBoost, amount: unlockAmount }) + setUnlockAmount('0') + setReceiptOpen(true) + } catch (err) { + console.error(err) + } + setIsUnlocking(false) + } + + const isDisabled = !txSender || Boolean(unlockAmountError) || isUnlocking || cleanedAmount === '0' + + return ( + <> + + + Unlock + + + Unlocking tokens will result in reduced boost. Your unlocked tokens will be available in 24 hours. + + + + + + + + + + Select amount to unlock + { + event.target.select() + }} + InputProps={{ + startAdornment: ( + + + + ), + endAdornment: ( + + + + ), + }} + /> + + + + + + + + + + + + + + + + + ) +} diff --git a/src/components/TockenUnlocking/WithdrawWidget.tsx b/src/components/TockenUnlocking/WithdrawWidget.tsx new file mode 100644 index 00000000..78a91e8d --- /dev/null +++ b/src/components/TockenUnlocking/WithdrawWidget.tsx @@ -0,0 +1,107 @@ +import { LOCK_EVENTS } from '@/analytics/lockEvents' +import { Box, Typography, Grid, Paper, Button, CircularProgress, SvgIcon } from '@mui/material' +import { formatUnits } from 'ethers/lib/utils' +import { Odometer } from '../Odometer' +import Track from '../Track' +import SafeToken from '@/public/images/token.svg' +import ClockIcon from '@/public/images/clock.svg' + +import css from './styles.module.css' +import { BigNumber } from 'ethers' +import { useState } from 'react' +import { trackSafeAppEvent } from '@/utils/analytics' +import { createWithdrawTx } from '@/utils/lock' +import { useTxSender } from '@/hooks/useTxSender' +import { useChainId } from '@/hooks/useChainId' +import { UnlockEvent } from '@/hooks/useLockHistory' +import { formatAmount } from '@/utils/formatters' +import { DAY_IN_MS, formatDate } from '@/utils/date' + +export const WithdrawWidget = ({ + totalWithdrawable, + pendingUnlocks, +}: { + totalWithdrawable: BigNumber + pendingUnlocks: UnlockEvent[] | undefined +}) => { + const [isWithdrawing, setIsWithdrawing] = useState(false) + const txSender = useTxSender() + const chainId = useChainId() + const isTransactionPossible = !!txSender + + const onWithdraw = async () => { + setIsWithdrawing(true) + const withdrawTx = createWithdrawTx(chainId) + try { + await txSender?.sendTxs([withdrawTx]) + trackSafeAppEvent(LOCK_EVENTS.WITHDRAW_SUCCESS.action) + } catch (error) { + console.error(error) + } + + setIsWithdrawing(false) + } + + return ( + <> + + Withdraw + + The unlocked tokens will be available to withdraw in 24 hours. + + + palette.background.default, + color: ({ palette }) => palette.text.primary, + position: 'relative', + }} + > + + + + + Withdrawable + + + + SAFE + + + + + + + + + + + {pendingUnlocks?.map((nextUnlock) => ( + + + + {formatAmount(formatUnits(nextUnlock.amount, 18), 0)} SAFE {' '} + will be withdrawable starting {formatDate(new Date(Date.parse(nextUnlock.executionDate) + DAY_IN_MS))}. + + + ))} + + + + ) +} diff --git a/src/components/TockenUnlocking/index.tsx b/src/components/TockenUnlocking/index.tsx new file mode 100644 index 00000000..47b6dfd6 --- /dev/null +++ b/src/components/TockenUnlocking/index.tsx @@ -0,0 +1,56 @@ +import { Box, Link, Stack, Typography } from '@mui/material' + +import NextLink from 'next/link' +import { AppRoutes } from '@/config/routes' +import { toRelativeLockHistory } from '@/utils/lock' +import PaperContainer from '../PaperContainer' +import { UnlockStats } from './UnlockStats' +import { UnlockTokenWidget } from './UnlockTokenWidget' +import { useLockHistory } from '@/hooks/useLockHistory' +import { ChevronLeft } from '@mui/icons-material' + +import { useMemo } from 'react' +import { useSummarizedLockHistory } from '@/hooks/useSummarizedLockHistory' +import { WithdrawWidget } from './WithdrawWidget' +import { useStartDate } from '@/hooks/useStartDates' +import { NAVIGATION_EVENTS } from '@/analytics/navigation' +import Track from '../Track' + +const TokenUnlocking = () => { + const { startTime } = useStartDate() + const lockHistory = useLockHistory() + + const relativeLockHistory = useMemo(() => toRelativeLockHistory(lockHistory, startTime), [lockHistory, startTime]) + + const { totalLocked, totalUnlocked, totalWithdrawable, pendingUnlocks } = useSummarizedLockHistory(lockHistory) + + return ( + + + + palette.primary.main }} + > + + Back to main + + + + Unlock / Withdraw + + + + + + + + + + + + ) +} + +export default TokenUnlocking diff --git a/src/components/TockenUnlocking/styles.module.css b/src/components/TockenUnlocking/styles.module.css new file mode 100644 index 00000000..77e99c09 --- /dev/null +++ b/src/components/TockenUnlocking/styles.module.css @@ -0,0 +1,52 @@ +.bordered { + border-radius: 6px; + border: var(--mui-palette-border-light) 1px solid; +} + +.badge :global .MuiBadge-badge { + right: 4px; + bottom: 5px; + height: 6px; + min-width: 6px; +} + +.badge :global .MuiBadge-badge { + bottom: 4px; + right: 4px; + background-color: var(--mui-palette-secondary-main); +} + +.maxButton { + border: 0; + background: none; + color: var(--mui-palette-primary-main); + padding: 0; + font-size: 16px; + font-weight: bold; + cursor: pointer; +} + +.gauge { + transition: width 200ms; +} + +.amountDisplay { + line-height: 2em; + font-weight: 700; + display: inline-flex; + gap: 4px; + align-items: center; + z-index: 1; +} + +.nextWithdrawal { + border-radius: 6px; + border: 1px var(--mui-palette-border-main) solid; + color: var(--mui-palette-text-secondary); + padding: 16px; + display: flex; + flex-direction: row; + align-items: center; + gap: 8px; + margin-top: 8px; +} diff --git a/src/components/TokenAmount/index.tsx b/src/components/TokenAmount/index.tsx new file mode 100644 index 00000000..f6872ddc --- /dev/null +++ b/src/components/TokenAmount/index.tsx @@ -0,0 +1,45 @@ +import { Paper, Stack, Typography, Grid, Skeleton, Box } from '@mui/material' +import { formatUnits } from 'ethers/lib/utils' +import { Odometer } from '../Odometer' + +import SafeToken from '@/public/images/token.svg' +import css from './styles.module.css' +import { BigNumberish } from 'ethers' + +export const TokenAmount = ({ loading, amount, label }: { loading: boolean; amount: BigNumberish; label: string }) => { + return ( + palette.background.default, + color: ({ palette }) => palette.text.primary, + position: 'relative', + }} + > + + + + {label} + + + + {loading ? ( + + ) : ( + <> + SAFE + + )} + + + + + + ) +} diff --git a/src/components/TokenAmount/styles.module.css b/src/components/TokenAmount/styles.module.css new file mode 100644 index 00000000..7f98749a --- /dev/null +++ b/src/components/TokenAmount/styles.module.css @@ -0,0 +1,12 @@ +.amountDisplay { + line-height: 2em; + font-weight: 700; + display: inline-flex; + gap: 4px; + align-items: center; + z-index: 1; +} + +.content svg { + margin-bottom: 6px; +} diff --git a/src/components/TokenLocking/ActivityRewardsInfo.tsx b/src/components/TokenLocking/ActivityRewardsInfo.tsx new file mode 100644 index 00000000..f0f0b55b --- /dev/null +++ b/src/components/TokenLocking/ActivityRewardsInfo.tsx @@ -0,0 +1,100 @@ +import { Box, Divider, Link, SvgIcon, Tooltip, Typography } from '@mui/material' +import css from './styles.module.css' +import clsx from 'clsx' +import StarIcon from '@/public/images/star.svg' +import { useOwnRank } from '@/hooks/useLeaderboard' +import Asterix from '@/public/images/asterix.svg' +import { AccordionContainer } from '@/components/AccordionContainer' +import NextLink from 'next/link' + +import PaperContainer from '../PaperContainer' +import { ReactNode } from 'react' +import { AppRoutes } from '@/config/routes' +import { InfoOutlined } from '@mui/icons-material' + +const Step = ({ active, title, description }: { active: boolean; title?: ReactNode; description?: string }) => { + return ( +
+ + {title} + {!active && ( + + Coming soon + + )} + + {description && ( + + {description} + + )} +
+ ) +} + +export const ActivityRewardsInfo = () => { + const ownRankResult = useOwnRank() + const { data: ownRank } = ownRankResult + + return ( + + + + + +
+ {ownRank && ( + <> + + + Your current ranking + + + + + + + #{ownRank.position} + + + )} + + How does it work? + + + + + + + Repeat! + +
+ + + View eligible activities + +
+
+
+ ) +} diff --git a/src/components/TokenLocking/BoostBreakdown.tsx b/src/components/TokenLocking/BoostBreakdown.tsx new file mode 100644 index 00000000..455ba57b --- /dev/null +++ b/src/components/TokenLocking/BoostBreakdown.tsx @@ -0,0 +1,101 @@ +import { floorNumber } from '@/utils/boost' +import { SignalCellularAlt, SignalCellularAlt1Bar, SignalCellularAlt2Bar } from '@mui/icons-material' +import { Stack, Typography, Box } from '@mui/material' +import BoostCounter from '../BoostCounter' +import { BoostMeter } from './BoostMeter' + +import EmptyBreakdown from '@/public/images/empty-breakdown.svg' + +import css from './styles.module.css' + +const BoostStrengthSignal = ({ boost, color }: { boost: number; color: 'primary' | 'warning' | undefined }) => { + const strength = Math.floor((boost - 1) / 0.25) + + if (strength === 0) { + return null + } + + const iconProps = { + fontSize: 'large', + color, + sx: { + position: 'relative', + left: -35, + }, + } as const + + if (strength === 1) { + return + } + if (strength === 2) { + return + } + return +} + +export const BoostBreakdown = ({ + realizedBoost, + currentFinalBoost, + newFinalBoost, + boostPrediction, + isLock, +}: { + realizedBoost: number + currentFinalBoost: number + newFinalBoost: number + boostPrediction?: number + isLock: boolean +}) => { + const isVisibleDifference = Math.abs(floorNumber(currentFinalBoost, 2) - floorNumber(newFinalBoost, 2)) > 0 + + // If everything is 1.0 there are no relevant locks / no amount is put in + const isInitialState = realizedBoost === 1 && currentFinalBoost === 1 && newFinalBoost === 1 + + return ( + + + + + {!isInitialState && ( + <> + + + + )} + + Current boost: {floorNumber(currentFinalBoost, 2)}x + + + {isInitialState ? ( + + + + Start locking tokens to get your points boost. + + + ) : ( + + + + Your boost + + + )} + + + + + + + + ) +} diff --git a/src/components/TokenLocking/BoostGraph/ActionNavigation/ActionNavigation.tsx b/src/components/TokenLocking/BoostGraph/ActionNavigation/ActionNavigation.tsx new file mode 100644 index 00000000..69538737 --- /dev/null +++ b/src/components/TokenLocking/BoostGraph/ActionNavigation/ActionNavigation.tsx @@ -0,0 +1,38 @@ +import { NAVIGATION_EVENTS } from '@/analytics/navigation' +import { AppRoutes } from '@/config/routes' +import { Typography, Stack, Button, Box } from '@mui/material' +import { useRouter } from 'next/router' +import Track from '../../../Track' +import clsx from 'clsx' + +import css from './styles.module.css' + +export const ActionNavigation = ({ disabled }: { disabled: boolean }) => { + const router = useRouter() + + const onNavigate = (route: (typeof AppRoutes)[keyof typeof AppRoutes]) => async () => { + router.push(route) + } + + const onUnlockAndWithdraw = onNavigate(AppRoutes.unlock) + return ( + + + + Want to withdraw your tokens? + + + + + + + ) +} diff --git a/src/components/TokenLocking/BoostGraph/ActionNavigation/styles.module.css b/src/components/TokenLocking/BoostGraph/ActionNavigation/styles.module.css new file mode 100644 index 00000000..f54823e2 --- /dev/null +++ b/src/components/TokenLocking/BoostGraph/ActionNavigation/styles.module.css @@ -0,0 +1,16 @@ +.buttonContainer { + display: flex; + justify-content: space-between; + align-items: center; + padding: 24px; + gap: 8px; + border-radius: 6px; + border: var(--mui-palette-border-light) 1px solid; +} + +@media (max-width: 600px) { + .buttonContainer { + flex-direction: column; + align-items: flex-start; + } +} diff --git a/src/components/TokenLocking/BoostGraph/ArrowDownLabel.tsx b/src/components/TokenLocking/BoostGraph/ArrowDownLabel.tsx new file mode 100644 index 00000000..f162abf7 --- /dev/null +++ b/src/components/TokenLocking/BoostGraph/ArrowDownLabel.tsx @@ -0,0 +1,30 @@ +import { useTheme } from '@mui/material/styles' +import { Background, VictoryLabel, VictoryLabelProps, VictoryLabelStyleObject } from 'victory' + +export const ArrowDownLabel = ({ backgroundColor, ...props }: VictoryLabelProps & { backgroundColor: string }) => { + const theme = useTheme() + if (props.x === undefined || props.y === undefined || props.text === '') { + return null + } + return ( + <> + + } + backgroundStyle={[{ fill: backgroundColor }]} + style={{ ...props.style, fill: theme.palette.background.main } as VictoryLabelStyleObject} + dy={-36} + /> + + ) +} diff --git a/src/components/TokenLocking/BoostGraph/AxisTopLabel.tsx b/src/components/TokenLocking/BoostGraph/AxisTopLabel.tsx new file mode 100644 index 00000000..a615fdcb --- /dev/null +++ b/src/components/TokenLocking/BoostGraph/AxisTopLabel.tsx @@ -0,0 +1,21 @@ +import { formatDay } from '@/utils/date' +import { VictoryLabel, VictoryLabelProps } from 'victory' + +export const AxisTopLabel = ({ startTime, ...props }: VictoryLabelProps & { startTime: number; datum?: number }) => { + const days = props.datum + + if (days === undefined) { + return null + } + + const dateLabel = formatDay(days, startTime) + + const style = props.style && !Array.isArray(props.style) ? props.style : {} + + return ( + <> + + + + ) +} diff --git a/src/components/TokenLocking/BoostGraph/BoostGradients.tsx b/src/components/TokenLocking/BoostGraph/BoostGradients.tsx new file mode 100644 index 00000000..3b9e84a6 --- /dev/null +++ b/src/components/TokenLocking/BoostGraph/BoostGradients.tsx @@ -0,0 +1,48 @@ +import { useTheme } from '@mui/material/styles' + +/** + * Renders svg gradient definitions. + */ +export const BoostGradients = () => { + const theme = useTheme() + return ( + + + + + + + + + + + + + + + + + + + + + ) +} diff --git a/src/components/TokenLocking/BoostGraph/BoostGraph.tsx b/src/components/TokenLocking/BoostGraph/BoostGraph.tsx new file mode 100644 index 00000000..f8c7101b --- /dev/null +++ b/src/components/TokenLocking/BoostGraph/BoostGraph.tsx @@ -0,0 +1,229 @@ +import { SEASON1_START, SEASON2_START } from '@/config/constants' +import { floorNumber, getBoostFunction } from '@/utils/boost' +import { getCurrentDays } from '@/utils/date' +import { formatAmount } from '@/utils/formatters' +import { LockHistory } from '@/utils/lock' +import { useTheme } from '@mui/material/styles' +import { useMemo } from 'react' +import { VictoryAxis, VictoryChart, VictoryLine, VictoryScatter, DomainTuple, ForAxes, VictoryArea } from 'victory' +import { ArrowDownLabel } from './ArrowDownLabel' +import { AxisTopLabel } from './AxisTopLabel' +import { BoostGradients } from './BoostGradients' +import { generatePointsFromHistory } from './helper' +import { ScatterDot } from './ScatterDot' + +import { useVictoryTheme } from './theme' +import { useStartDate } from '@/hooks/useStartDates' + +const DOMAIN: ForAxes = { x: [-5, SEASON2_START + 5], y: [0.8, 2.3] } + +export const BoostGraph = ({ + lockedAmount, + pastLocks, + isLock, +}: { + lockedAmount: number + pastLocks: LockHistory[] + isLock: boolean +}) => { + const theme = useTheme() + const victoryTheme = useVictoryTheme() + const { startTime } = useStartDate() + + const now = useMemo(() => getCurrentDays(startTime), [startTime]) + + const currentBoostFunction = useMemo(() => getBoostFunction(now, 0, pastLocks), [now, pastLocks]) + const newBoostFunction = useMemo(() => getBoostFunction(now, lockedAmount, pastLocks), [lockedAmount, now, pastLocks]) + + const pastLockPoints = useMemo(() => generatePointsFromHistory(pastLocks, now), [pastLocks, now]) + + const format = (value: number) => formatAmount(floorNumber(value, 2), 2) + + const currentBoostDataPoints = useMemo( + () => [ + ...pastLockPoints, + { x: 0, y: currentBoostFunction({ x: 0 }) }, + { x: now, y: currentBoostFunction({ x: now }) }, + { x: SEASON1_START, y: currentBoostFunction({ x: SEASON1_START }) }, + { x: SEASON2_START, y: currentBoostFunction({ x: SEASON2_START }) }, + ], + [currentBoostFunction, now, pastLockPoints], + ) + + const projectedBoostDataPoints = useMemo( + () => [ + ...pastLockPoints, + { x: 0, y: newBoostFunction({ x: 0 }) }, + { x: now, y: newBoostFunction({ x: now }) }, + { x: SEASON1_START, y: newBoostFunction({ x: SEASON1_START }) }, + { x: SEASON2_START, y: newBoostFunction({ x: SEASON2_START }) }, + ], + [newBoostFunction, now, pastLockPoints], + ) + + return ( +
+ + + + + + + + + Number(d).toFixed(1) + 'x'} + theme={victoryTheme} + style={{ + tickLabels: { + padding: 16, + }, + }} + /> + { + if (value === 0) { + return 'Start' + } + if (value === SEASON1_START) { + return 'Early boost end' + } + + return 'Season 1 end' + }} + domain={DOMAIN} + style={{ + grid: { stroke: theme.palette.primary.light, strokeDasharray: 2, strokeDashoffset: 2 }, + ticks: { size: 5 }, + tickLabels: { fontSize: 12, padding: 16, fill: theme.palette.primary.light }, + }} + tickLabelComponent={} + theme={victoryTheme} + /> + { + if (value === now) { + return 'Today' + } + return '' + }} + domain={DOMAIN} + style={{ + grid: { stroke: theme.palette.text.primary, strokeDasharray: 2, strokeDashoffset: 2 }, + ticks: { size: 5 }, + tickLabels: { fontSize: 12, padding: 0, fill: theme.palette.text.primary }, + }} + theme={victoryTheme} + /> + + + } + domain={DOMAIN} + data={pastLockPoints} + theme={victoryTheme} + /> + + } + size={4} + dataComponent={ + + } + domain={DOMAIN} + data={[ + { x: now, y: newBoostFunction({ x: now }) }, + { x: SEASON2_START, y: newBoostFunction({ x: SEASON2_START }) }, + ]} + theme={victoryTheme} + /> + +
+ ) +} diff --git a/src/components/TokenLocking/BoostGraph/ScatterDot.tsx b/src/components/TokenLocking/BoostGraph/ScatterDot.tsx new file mode 100644 index 00000000..1f5c83c7 --- /dev/null +++ b/src/components/TokenLocking/BoostGraph/ScatterDot.tsx @@ -0,0 +1,89 @@ +import { Point, PointProps } from 'victory' +import { useTheme } from '@mui/material/styles' +import { useState } from 'react' +import { ArrowDownLabel } from './ArrowDownLabel' +import { floorNumber } from '@/utils/boost' +import { SEASON2_START } from '@/config/constants' + +/** + * Draws different circles based on the date + * @param props + * @returns + */ +export const ScatterDot = ({ + today, + backgroundColor, + ...props +}: PointProps & { today: number; backgroundColor: string }) => { + const theme = useTheme() + + const [hovered, setHovered] = useState(false) + + // 39, 25, 12 ,6 + if (props.datum.x === today) { + return ( + <> + + + + + setHovered(true)} + onMouseLeave={() => setHovered(false)} + /> + {hovered && props.datum.x !== SEASON2_START && ( + + )} + + ) + } + return ( + <> + + + setHovered(true)} + onMouseLeave={() => setHovered(false)} + /> + {hovered && props.datum.x !== SEASON2_START && ( + + )} + + ) +} diff --git a/src/components/TokenLocking/BoostGraph/graphConstants.ts b/src/components/TokenLocking/BoostGraph/graphConstants.ts new file mode 100644 index 00000000..9c82e977 --- /dev/null +++ b/src/components/TokenLocking/BoostGraph/graphConstants.ts @@ -0,0 +1,3 @@ +export const BIG_SCREEN = { height: 280, padding: 52, width: 524 } + +export const SMALL_SCREEN = { height: 180, padding: 52, width: 476 } diff --git a/src/components/TokenLocking/BoostGraph/helper.ts b/src/components/TokenLocking/BoostGraph/helper.ts new file mode 100644 index 00000000..4efafd2c --- /dev/null +++ b/src/components/TokenLocking/BoostGraph/helper.ts @@ -0,0 +1,15 @@ +import { getBoostFunction } from '@/utils/boost' +import { LockHistory } from '@/utils/lock' + +export const generatePointsFromHistory = (pastLocks: LockHistory[], nowInDays: number): { x: number; y: number }[] => { + const boostFunction = getBoostFunction(nowInDays, 0, pastLocks) + // find each individual day + const days = pastLocks.map((lock) => lock.day).filter((day, index, days) => days.indexOf(day) === index) + + return days + .map((day) => ({ + x: day, + y: boostFunction({ x: day }), + })) + .slice(0, -1) +} diff --git a/src/components/TokenLocking/BoostGraph/theme.ts b/src/components/TokenLocking/BoostGraph/theme.ts new file mode 100644 index 00000000..944cfa3c --- /dev/null +++ b/src/components/TokenLocking/BoostGraph/theme.ts @@ -0,0 +1,194 @@ +import { useMediaQuery } from '@mui/material' +import { useTheme } from '@mui/material/styles' +import { VictoryThemeDefinition } from 'victory' +import { SMALL_SCREEN, BIG_SCREEN } from './graphConstants' + +const colors = ['#DDDEE0', '#525252', '#737373', '#969696', '#bdbdbd', '#d9d9d9', '#f0f0f0'] +const grey = '#A1A3A7' + +// Typography +const sansSerif = "'DM Sans'" +const letterSpacing = 'normal' +const fontSize = '12px' + +// Strokes +const strokeLinecap = 'round' +const strokeLinejoin = 'round' + +// Put it all together... +export const useVictoryTheme = (): VictoryThemeDefinition => { + const muiTheme = useTheme() + + const isSmallScreen = useMediaQuery(muiTheme.breakpoints.down('sm')) + + // Layout + const baseProps = { ...(isSmallScreen ? SMALL_SCREEN : BIG_SCREEN), colorScale: colors } + const fontColor = muiTheme.palette.text.secondary + + // Labels + const baseLabelStyles = { + fontFamily: sansSerif, + fontSize, + letterSpacing, + padding: 8, + backgroundColor: 'black', + fill: fontColor, + stroke: 'transparent', + } + + const centeredLabelStyles = Object.assign({ textAnchor: 'middle' }, baseLabelStyles) + + return { + area: Object.assign( + { + style: { + data: { + fill: fontColor, + }, + labels: baseLabelStyles, + }, + }, + baseProps, + ), + axis: Object.assign( + { + style: { + axis: { + fill: 'transparent', + stroke: 'none', + strokeWidth: 1, + strokeLinecap, + strokeLinejoin, + }, + axisLabel: Object.assign({}, centeredLabelStyles, { + padding: 16, + }), + grid: { + fill: 'none', + stroke: 'none', + }, + ticks: { + fill: 'transparent', + size: 1, + stroke: 'transparent', + }, + tickLabels: baseLabelStyles, + }, + }, + baseProps, + ), + chart: baseProps, + errorbar: Object.assign( + { + borderWidth: 8, + style: { + data: { + fill: 'transparent', + stroke: fontColor, + strokeWidth: 2, + }, + labels: baseLabelStyles, + }, + }, + baseProps, + ), + group: Object.assign( + { + colorScale: colors, + }, + baseProps, + ), + histogram: Object.assign( + { + style: { + data: { + fill: grey, + stroke: fontColor, + strokeWidth: 2, + }, + labels: baseLabelStyles, + }, + }, + baseProps, + ), + legend: { + colorScale: colors, + gutter: 10, + orientation: 'vertical', + titleOrientation: 'top', + style: { + data: { + type: 'circle', + }, + labels: baseLabelStyles, + title: Object.assign({}, baseLabelStyles, { padding: 5 }), + }, + }, + line: Object.assign( + { + style: { + data: { + fill: 'transparent', + stroke: fontColor, + strokeWidth: 2, + }, + labels: baseLabelStyles, + }, + }, + baseProps, + ), + scatter: Object.assign( + { + style: { + labels: baseLabelStyles, + }, + }, + baseProps, + ), + pie: { + style: { + data: { + padding: 10, + stroke: 'transparent', + strokeWidth: 1, + }, + labels: Object.assign({}, baseLabelStyles, { padding: 20 }), + }, + colorScale: colors, + width: 400, + height: 400, + padding: 50, + }, + tooltip: { + style: Object.assign({}, baseLabelStyles, { padding: 0, pointerEvents: 'none' }), + flyoutStyle: { + stroke: fontColor, + strokeWidth: 1, + fill: '#f0f0f0', + pointerEvents: 'none', + }, + flyoutPadding: 5, + cornerRadius: 5, + pointerLength: 10, + }, + voronoi: Object.assign( + { + style: { + data: { + fill: 'transparent', + stroke: 'transparent', + strokeWidth: 0, + }, + labels: Object.assign({}, baseLabelStyles, { padding: 5, pointerEvents: 'none' }), + flyout: { + stroke: fontColor, + strokeWidth: 1, + fill: '#f0f0f0', + pointerEvents: 'none', + }, + }, + }, + baseProps, + ), + } +} diff --git a/src/components/TokenLocking/BoostMeter.tsx b/src/components/TokenLocking/BoostMeter.tsx new file mode 100644 index 00000000..18baf8c5 --- /dev/null +++ b/src/components/TokenLocking/BoostMeter.tsx @@ -0,0 +1,99 @@ +import { floorNumber, getTimeFactor } from '@/utils/boost' +import { getCurrentDays } from '@/utils/date' +import { AccessTime } from '@mui/icons-material' +import { Box, LinearProgress, Stack, Tooltip, Typography } from '@mui/material' +import { ReactElement, useMemo } from 'react' + +import { useTheme } from '@mui/material/styles' +import { useStartDate } from '@/hooks/useStartDates' +import { SEASON1_START } from '@/config/constants' + +export const BoostMeter = ({ + isLock, + isVisibleDifference, + prediction, +}: { + isLock: boolean + isVisibleDifference: boolean + prediction?: number +}) => { + const { startTime } = useStartDate() + + const now = useMemo(() => getCurrentDays(startTime), [startTime]) + + const fullBoostDaysLeft = SEASON1_START - now + 1 + + const currentTimeFactor = getTimeFactor(now) + const value = currentTimeFactor * 100 + + let boostMeterInfo: ReactElement | null = null + + const theme = useTheme() + + if (isLock) { + if (fullBoostDaysLeft > 0) { + boostMeterInfo = ( + + Lock within the next {fullBoostDaysLeft} days + to get the highest boost. + + ) + } else if (isVisibleDifference && prediction) { + boostMeterInfo = ( + + If you lock in 10 days your boost will only be{' '} + {floorNumber(prediction, 2)}x + + ) + } else { + boostMeterInfo = + value > 25 ? ( + The earlier you lock the higher the boost. + ) : ( + Your last chance to get the early boost. + ) + } + } else { + boostMeterInfo = ( + You will stop getting the boost for unlocked tokens. + ) + } + + return ( + + 50 ? 'primary' : 'warning'} + sx={{ + height: '100%', + border: ({ palette }) => `4px solid ${palette.border.light}`, + padding: '2px', + borderRadius: '8px', + backgroundColor: ({ palette }) => palette.border.main, + '& .MuiLinearProgress-bar': { + borderRadius: '6px', + transform: () => { + return `translateY(${100 - value}%) !important` + }, + }, + }} + /> + + + + + + Early boost meter + + {boostMeterInfo} + + + ) +} diff --git a/src/components/TokenLocking/CurrentStats.tsx b/src/components/TokenLocking/CurrentStats.tsx new file mode 100644 index 00000000..025a780b --- /dev/null +++ b/src/components/TokenLocking/CurrentStats.tsx @@ -0,0 +1,25 @@ +import { Grid } from '@mui/material' +import { BigNumberish } from 'ethers' +import { TokenAmount } from '../TokenAmount' + +export const CurrentStats = ({ + loading, + safeBalance, + currentlyLocked, +}: { + safeBalance: BigNumberish + currentlyLocked: BigNumberish + loading: boolean +}) => { + return ( + + + + + + + + + + ) +} diff --git a/src/components/TokenLocking/Leaderboard.tsx b/src/components/TokenLocking/Leaderboard.tsx new file mode 100644 index 00000000..329e20e4 --- /dev/null +++ b/src/components/TokenLocking/Leaderboard.tsx @@ -0,0 +1,281 @@ +import { + Box, + Link, + Paper, + Skeleton, + SvgIcon, + Table, + TableBody, + TableContainer, + TableHead, + TableRow, + Typography, + useMediaQuery, +} from '@mui/material' +import { useTheme } from '@mui/material/styles' + +import { styled } from '@mui/material/styles' +import TableCell, { tableCellClasses } from '@mui/material/TableCell' +import { Identicon } from '../Identicon' +import FirstPlaceIcon from '@/public/images/leaderboard-first-place.svg' +import SecondPlaceIcon from '@/public/images/leaderboard-second-place.svg' +import ThirdPlaceIcon from '@/public/images/leaderboard-third-place.svg' +import TitleStar from '@/public/images/leaderboard-title-star.svg' +import { type LeaderboardEntry, useGlobalLeaderboardPage, useOwnRank } from '@/hooks/useLeaderboard' +import { formatAmount } from '@/utils/formatters' +import { formatEther } from 'ethers/lib/utils' +import { ReactElement, useState } from 'react' +import { useEnsLookup } from '@/hooks/useEnsLookup' +import Track from '../Track' +import { NAVIGATION_EVENTS } from '@/analytics/navigation' +import { useChainId } from '@/hooks/useChainId' +import { CHAIN_EXPLORER_URL } from '@/config/constants' +import { ExternalLink } from '../ExternalLink' + +const PAGE_SIZE = 10 + +const StyledTableCell = styled(TableCell)(({ theme }) => ({ + [`&.${tableCellClasses.head}`]: { + backgroundColor: 'none', + color: theme.palette.common.white, + border: 0, + }, + [`&.${tableCellClasses.body}`]: { + fontSize: 14, + borderTop: `1px ${theme.palette.background.paper} solid`, + borderBottom: `1px ${theme.palette.background.paper} solid`, + }, +})) + +const StyledTableRow = styled(TableRow)(({ theme }) => ({ + // hide last border + '&:hover td': { + backgroundColor: theme.palette.background.main, + }, + '& td:first-child': { + borderRadius: '6px 0 0 6px', + }, + '& td:last-child': { + borderRadius: '0 6px 6px 0', + }, +})) + +const HighlightedTableRow = styled(TableRow)(({ theme }) => ({ + '& td:first-child': { + borderRadius: '6px 0 0 6px', + }, + '& td:last-child': { + borderRadius: '0 6px 6px 0', + }, + '& td': { + backgroundColor: theme.palette.background.light, + background: + 'linear-gradient(var(--mui-palette-background-light), var(--mui-palette-background-light)) padding-box,linear-gradient(to bottom, #5FDDFF 12.5%, #12FF80 88.07%) border-box', + border: '1px solid transparent !important', + }, + '& td:not(:first-child)': { + borderLeft: 'none !important', + }, + '& td:not(:last-child)': { + borderRight: 'none !important', + }, +})) + +const StyledTable = styled(Table)(({ theme }) => ({ + borderCollapse: 'separate', +})) + +export const shortenAddress = (address: string, length = 4): string => { + if (!address) { + return '' + } + + return `${address.slice(0, length + 2)}...${address.slice(-length)}` +} + +const LookupAddress = ({ address }: { address: string }) => { + const name = useEnsLookup(address) + const theme = useTheme() + const isSmallScreen = useMediaQuery(theme.breakpoints.down('md')) + const displayAddress = isSmallScreen ? shortenAddress(address) : address + const chainId = useChainId() + const explorerURL = CHAIN_EXPLORER_URL[chainId] + + return ( + <> + + + {explorerURL ? ( + {name ? name : displayAddress} + ) : ( + {name ? name : displayAddress} + )} + + + ) +} + +const Ranking = ({ position }: { position: number }) => { + return position <= 3 ? ( + + ) : ( + <>{position} + ) +} + +const OwnEntry = ({ entry }: { entry: LeaderboardEntry | undefined }) => { + if (entry) { + return ( + + + + + + + + {formatAmount(formatEther(entry.lockedAmount), 0)} + + ) + } + + return null +} + +const LeaderboardPage = ({ + index, + onLoadMore, + ownEntry, +}: { + index: number + onLoadMore?: () => void + ownEntry: LeaderboardEntry | undefined +}): ReactElement => { + const leaderboardPage = useGlobalLeaderboardPage(PAGE_SIZE, index * PAGE_SIZE) + const rows = leaderboardPage?.results ?? [] + const isLeaderboardEmpty = index === 0 && (!rows || rows.length === 0) + + if (leaderboardPage === undefined) { + return ( + <> + + + + + + + + + + + + + ) + } + if (isLeaderboardEmpty) { + return ( + <> + + + No entries + + + + + ) + } + return ( + <> + {rows.map((row) => { + return row.holder === ownEntry?.holder ? ( + + ) : ( + + + + + + + + {formatAmount(formatEther(row.lockedAmount), 0)} + + ) + })} + {onLoadMore && leaderboardPage?.next && ( + + + + + Show more + + + + + )} + + ) +} + +export const Leaderboard = () => { + const [pages, setPages] = useState(1) + const ownLeaderboardEntry = useOwnRank() + const { data: ownEntry } = ownLeaderboardEntry + + return ( + + + + + + Locking Leaderboard + + + Higher ranking means higher chances to get rewards. + + + + + + + + + + + + Tokens Locked + + + + + {ownEntry && ownEntry?.position > PAGE_SIZE && } + {Array.from(new Array(pages)).map((_, index) => ( + setPages((prev) => prev + 1) : undefined} + ownEntry={ownEntry} + /> + ))} + + + + + ) +} diff --git a/src/components/TokenLocking/LockTokenWidget.tsx b/src/components/TokenLocking/LockTokenWidget.tsx new file mode 100644 index 00000000..3f08fb44 --- /dev/null +++ b/src/components/TokenLocking/LockTokenWidget.tsx @@ -0,0 +1,238 @@ +import { Typography, Stack, Grid, TextField, InputAdornment, Button, Box, CircularProgress } from '@mui/material' +import NorthEastIcon from '@mui/icons-material/NorthEast' +import { formatUnits, parseUnits } from 'ethers/lib/utils' +import SafeToken from '@/public/images/token.svg' + +import { BoostGraph } from './BoostGraph/BoostGraph' + +import css from './styles.module.css' +import { createLockTx, toRelativeLockHistory } from '@/utils/lock' +import { createApproveTx } from '@/utils/safe-token' +import { useState, ChangeEvent, useMemo, useCallback, useEffect } from 'react' +import { BigNumber, BigNumberish } from 'ethers' +import { useChainId } from '@/hooks/useChainId' +import { getBoostFunction } from '@/utils/boost' +import { useLockHistory } from '@/hooks/useLockHistory' +import { useDebounce } from '@/hooks/useDebounce' +import { SAFE_PASS_HELP_ARTICLE_URL, SEASON2_START, UNLIMITED_APPROVAL_AMOUNT } from '@/config/constants' +import { getCurrentDays } from '@/utils/date' +import { BoostBreakdown } from './BoostBreakdown' +import Track from '../Track' +import { LOCK_EVENTS } from '@/analytics/lockEvents' +import { trackSafeAppEvent } from '@/utils/analytics' +import MilesReceipt from '@/components/TokenLocking/MilesReceipt' +import { BaseTransaction, useTxSender } from '@/hooks/useTxSender' +import { useSafeTokenLockingAllowance } from '@/hooks/useSafeTokenBalance' +import { useStartDate } from '@/hooks/useStartDates' +import { NAVIGATION_EVENTS } from '@/analytics/navigation' +import { ExternalLink } from '../ExternalLink' +import { formatAmount } from '@/utils/formatters' + +export const LockTokenWidget = ({ safeBalance }: { safeBalance: BigNumberish | undefined }) => { + const [receiptOpen, setReceiptOpen] = useState(false) + const chainId = useChainId() + const txSender = useTxSender() + const { startTime } = useStartDate() + const todayInDays = getCurrentDays(startTime) + const { data: safeTokenAllowance, isLoading: isAllowanceLoading } = useSafeTokenLockingAllowance() + + const pastLocks = useLockHistory() + + const relativeLockHistory = useMemo(() => toRelativeLockHistory(pastLocks, startTime), [pastLocks, startTime]) + + const [amount, setAmount] = useState('0') + + const [amountError, setAmountError] = useState(undefined) + + const [isLocking, setIsLocking] = useState(false) + + const [receiptInformation, setReceiptInformation] = useState<{ newFinalBoost: number; amount: string }>({ + amount: '0', + newFinalBoost: 1, + }) + + const onCloseReceipt = () => { + setReceiptOpen(false) + } + + const debouncedAmount = useDebounce(amount, 1000, '0') + const cleanedAmount = useMemo(() => (debouncedAmount.trim() === '' ? '0' : debouncedAmount.trim()), [debouncedAmount]) + + useEffect(() => { + if (debouncedAmount !== '0') { + trackSafeAppEvent(LOCK_EVENTS.CHANGE_LOCK_AMOUNT.action, LOCK_EVENTS.CHANGE_LOCK_AMOUNT.label) + } + }, [debouncedAmount]) + + const currentBoostFunction = useMemo( + () => getBoostFunction(todayInDays, 0, relativeLockHistory), + [relativeLockHistory, todayInDays], + ) + const newBoostFunction = useMemo( + () => getBoostFunction(todayInDays, Number(cleanedAmount), relativeLockHistory), + [cleanedAmount, relativeLockHistory, todayInDays], + ) + + const boostIn10DaysFunction = useMemo( + () => getBoostFunction(todayInDays + 10, Number(cleanedAmount), relativeLockHistory), + [cleanedAmount, relativeLockHistory, todayInDays], + ) + + const validateAmount = useCallback( + (newAmount: string) => { + const numberAmount = Number(newAmount) + if (isNaN(numberAmount)) { + return 'The value must be a number' + } + const parsed = parseUnits(numberAmount.toString(), 18) + if (parsed.gt(safeBalance ?? '0')) { + return 'Amount exceeds balance' + } + if (parsed.lte(0)) { + return 'Amount must be greater than zero' + } + }, + [safeBalance], + ) + + const onChangeAmount = useCallback( + (event: ChangeEvent) => { + const newValue = event.target.value.replaceAll(',', '.') + const error = validateAmount(newValue || '0') + + setAmount(newValue) + setAmountError(error) + }, + [validateAmount], + ) + + const onSetToMax = useCallback(() => { + if (!safeBalance) { + return + } + setAmount(formatUnits(safeBalance, 18)) + }, [safeBalance]) + + const isMaxDisabled = BigNumber.from(0).gte(safeBalance ?? 0) + + const onLockTokens = async () => { + if (!txSender) { + throw new Error('Cannot lock tokens without connected wallet') + } + try { + const amountToLockWei = parseUnits(amount, 18) + setIsLocking(true) + let txs: BaseTransaction[] = [] + if (BigNumber.from(safeTokenAllowance).lt(amountToLockWei)) { + // Approval is too low for the locking operation + const approvalAmount = txSender?.isBatchingSupported ? amountToLockWei : UNLIMITED_APPROVAL_AMOUNT + txs.push(createApproveTx(chainId, approvalAmount)) + } + txs.push(createLockTx(chainId, amountToLockWei)) + + if (txSender?.isBatchingSupported) { + await txSender.sendTxs(txs) + } else { + for (let i = 0; i < txs.length; i++) { + await txSender?.sendTxs([txs[i]]) + } + } + const finalBoostAfterLocking = newBoostFunction({ x: SEASON2_START }) + trackSafeAppEvent(LOCK_EVENTS.LOCK_SUCCESS.action) + setReceiptInformation({ newFinalBoost: finalBoostAfterLocking, amount }) + setAmount('0') + setReceiptOpen(true) + } catch (error) { + console.error(error) + } finally { + setIsLocking(false) + } + } + + const isDisabled = isAllowanceLoading || Boolean(amountError) || isLocking || cleanedAmount === '0' + + return ( + <> + + + + + Lock tokens to boost your points + + + + More about the boost + + + + + + + + + + Select amount to lock + { + event.target.select() + }} + InputProps={{ + startAdornment: ( + + + + ), + endAdornment: ( + + + + ), + }} + className={css.input} + /> + + + + + + + + + Balance: {formatAmount(formatUnits(safeBalance ?? '0', 18), 2)} + + + + + + + + + ) +} diff --git a/src/components/TokenLocking/MilesReceipt.tsx b/src/components/TokenLocking/MilesReceipt.tsx new file mode 100644 index 00000000..dd51a517 --- /dev/null +++ b/src/components/TokenLocking/MilesReceipt.tsx @@ -0,0 +1,102 @@ +import { IconButton, Modal, Stack, SvgIcon, Typography } from '@mui/material' +import css from './styles.module.css' +import SafePass from '@/public/images/safe-pass.svg' +import Barcode from '@/public/images/barcode.svg' +import { NorthRounded, SouthRounded } from '@mui/icons-material' +import XIcon from '@mui/icons-material/X' +import CloseIcon from '@mui/icons-material/Close' +import clsx from 'clsx' +import { formatAmount } from '@/utils/formatters' +import { floorNumber } from '@/utils/boost' +import { ExternalLink } from '@/components/ExternalLink' + +const TWEET_CONTENT = 'Just got my @Safe%7BPass%7D to join the smart account revolution!' + +const MilesReceipt = ({ + open, + onClose, + amount, + newFinalBoost, + isUnlock = false, +}: { + open: boolean + onClose: () => void + amount: string + newFinalBoost: number + isUnlock?: boolean +}) => { + const ArrowIcon = isUnlock ? SouthRounded : NorthRounded + + return ( + + <> +
+ + + + + {isUnlock ? 'Unclocking' : 'Locking'} started... + + + You successfully started {isUnlock ? 'unlocking' : 'locking'} your SAFE tokens.
+ Once the transaction is signed and executed{' '} + {isUnlock + ? 'the tokens will be available to withdraw in 24 hours.' + : 'your tokens will be locked and you will get boosted.'} +
+ + {!isUnlock && ( + + + Share on + + + )} +
+ + + Your overview + + + + + + {formatAmount(amount, 0)} + + + Tokens {isUnlock ? 'unlocked' : 'locked'} + + + +
+ + + + {formatAmount(floorNumber(newFinalBoost, 2), 2)}x + + + Your boost + + +
+ + + +
+ +
+ + + ) +} + +export default MilesReceipt diff --git a/src/components/TokenLocking/index.tsx b/src/components/TokenLocking/index.tsx new file mode 100644 index 00000000..4e713349 --- /dev/null +++ b/src/components/TokenLocking/index.tsx @@ -0,0 +1,73 @@ +import { Box, Grid, Stack, Typography } from '@mui/material' +import { useSafeTokenBalance } from '@/hooks/useSafeTokenBalance' +import { Leaderboard } from './Leaderboard' +import { CurrentStats } from './CurrentStats' +import { LockTokenWidget } from './LockTokenWidget' +import { ActivityRewardsInfo } from './ActivityRewardsInfo' +import { ActionNavigation } from './BoostGraph/ActionNavigation/ActionNavigation' +import PaperContainer from '../PaperContainer' +import { useSummarizedLockHistory } from '@/hooks/useSummarizedLockHistory' +import { useLockHistory } from '@/hooks/useLockHistory' + +import css from './styles.module.css' +import { ExternalLink } from '../ExternalLink' +import { SAFE_TERMS_AND_CONDITIONS_URL } from '@/config/constants' + +const TokenLocking = () => { + const { isLoading: safeBalanceLoading, data: safeBalance } = useSafeTokenBalance() + const { totalLocked, totalUnlocked, totalWithdrawable } = useSummarizedLockHistory(useLockHistory()) + + const isUnlockAvailable = totalLocked.gt(0) || totalUnlocked.gt(0) || totalWithdrawable.gt(0) + + return ( + + + {'Get rewards with Safe{Pass}'} + + {'What is Safe{Pass}'} + + + + + + + + + + + + + + + + + + + + + + + + LEGAL DISCLAIMER + + + Please note that residents in{' '} + certain jurisdictions (including the + United States) may not be eligible for the boost and reward. This means that your boost might not be + applied to certain reward types, e.g. token rewards such as Safe. + + + + Terms and Conditions + + + + + ) +} + +export default TokenLocking diff --git a/src/components/TokenLocking/styles.module.css b/src/components/TokenLocking/styles.module.css new file mode 100644 index 00000000..a794f631 --- /dev/null +++ b/src/components/TokenLocking/styles.module.css @@ -0,0 +1,278 @@ +.badge :global .MuiBadge-badge { + right: 4px; + bottom: 5px; + height: 6px; + min-width: 6px; +} + +.boostInfoBox { + display: flex; + flex-direction: column; +} + +.bordered { + border-radius: 6px; + border: var(--mui-palette-border-light) 1px solid; +} + +.amountDisplay { + line-height: 2em; + font-weight: 700; + display: inline-flex; + gap: 4px; + align-items: center; + z-index: 1; +} + +.badge :global .MuiBadge-badge { + bottom: 4px; + right: 4px; + background-color: var(--mui-palette-secondary-main); +} + +.maxButton { + border: 0; + background: none; + color: var(--mui-palette-primary-main); + padding: 0; + font-size: 16px; + font-weight: bold; + cursor: pointer; +} + +.gauge { + transition: width 200ms; +} + +@keyframes highlight { + 0% { + transform: scale(1); + } + 50% { + transform: scale(1.2); + } + 100% { + transform: scale(1); + } +} + +.highlight { + animation: highlight 1s; +} + +.gradientText { + background: linear-gradient(225deg, #5fddff 12.5%, #12ff80 88.07%); + background-clip: text; + color: transparent; +} + +.steps { + z-index: 1; +} + +.step { + position: relative; + padding-left: 40px; + margin-bottom: 40px; + counter-increment: item; +} + +.step:last-child { + margin-bottom: 0; +} + +.step:before { + content: counter(item); + width: 24px; + height: 24px; + display: block; + background: #636669; + border-radius: 50%; + color: #121312; + text-align: center; + position: absolute; + left: 0; +} + +.step:after { + content: ''; + left: 12px; + top: 36px; + position: absolute; + height: calc(100% - 16px); + width: 1px; + background-color: #303033; +} + +.activeStep:before { + background-color: #12ff80; +} + +.activeStep:after { + background-color: #12ff80; +} + +.comingSoon { + background-color: #303033; + border-radius: 4px; + padding: 4px 8px; + position: absolute; + margin-left: 8px; +} + +.stepDate { + background-color: #121312; + padding: 20px; + border-radius: 6px; +} + +.rewards { + position: relative; +} + +.rewardsBackground { + pointer-events: none; +} + +.rewardsBackground:before { + content: ''; + background: radial-gradient(41.34% 41.34% at 50% 50%, #29b6f6 0%, transparent 82.5%); + position: absolute; + width: 500px; + height: 500px; + top: -200px; + left: 100px; + opacity: 0.2; +} + +.rewardsBackground:after { + content: ''; + background: radial-gradient(41.34% 41.34% at 50% 50%, rgba(18, 255, 128, 0.6) 0%, rgba(18, 255, 128, 0) 82.5%); + position: absolute; + width: 700px; + height: 700px; + top: -100px; + left: -250px; + opacity: 0.2; +} + +.rewards:after { + content: ''; + left: 12px; + top: -4px; + position: absolute; + height: calc(100% - 16px); + width: 1px; + background-color: #303033; +} + +.diamondImage { + text-align: center; +} + +.milesReceipt { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 90vw; + max-width: 585px; + display: flex; + overflow: hidden; + background-color: #121312; + border-radius: 20px; + padding: 40px; + width: 100%; +} + +.topReceipt { + border-bottom: 1px dashed white; + padding: 40px; + margin: 0 -40px; +} + +.barcode { + position: absolute; + bottom: 0; + left: 50%; + z-index: 1; + transform: translate(-50%, 50%) rotate(90deg); + pointer-events: none; +} + +.closeButton { + position: absolute; + right: 24px; + top: 24px; + z-index: 1; +} + +@media (max-width: 899px) { + .milesReceipt { + width: 100%; + } + + .leftReceipt, + .rightReceipt { + border-radius: 0px; + } + .barcode, + .closeButton { + right: 0px; + } + + .leftReceipt:after { + display: none; + } + + .leftReceipt { + border-bottom: 1px dashed white; + } + + .pageTitle { + margin: 16px; + } +} + +.gradientBackground { + position: absolute; + width: 1000px; + height: 1000px; + opacity: 0.3; + z-index: 1; + transform: translate(-50%, -50%); + + top: 50%; + left: 50%; + background: radial-gradient(41.34% 41.34% at 50% 50%, rgba(18, 255, 128, 0.6) 0%, rgba(18, 255, 128, 0) 82.5%); + pointer-events: none; +} + +.sidebarGradientBackground { + position: absolute; + width: 400px; + height: 400px; + opacity: 0.3; + z-index: 1; + top: 700px; + left: 50px; + background: radial-gradient(41.34% 41.34% at 50% 50%, rgba(41, 182, 246, 0.5) 0%, rgba(18, 255, 128, 0) 82.5%); + pointer-events: none; +} + +.unlockGradient { + background: radial-gradient(41.34% 41.34% at 50% 50%, #ff8061 0%, rgba(255, 128, 97, 0) 82.5%); +} + +.lockingHeader { + display: flex; + flex-direction: row; + align-items: center; +} + +@media (max-width: 599px) { + .lockingHeader { + flex-direction: column; + align-items: flex-start; + } +} diff --git a/src/components/TotalVotingPower/index.tsx b/src/components/TotalVotingPower/index.tsx index 09e1519b..fa42ec80 100644 --- a/src/components/TotalVotingPower/index.tsx +++ b/src/components/TotalVotingPower/index.tsx @@ -12,9 +12,7 @@ export const TotalVotingPower = (): ReactElement => { return ( <> - Total voting power is - - + {formattedAmount} SAFE diff --git a/src/components/Track/index.tsx b/src/components/Track/index.tsx new file mode 100644 index 00000000..a4973e01 --- /dev/null +++ b/src/components/Track/index.tsx @@ -0,0 +1,51 @@ +import { trackSafeAppEvent } from '@/utils/analytics' +import type { ReactElement } from 'react' +import { Fragment, useEffect, useRef } from 'react' + +type Props = { + children: ReactElement + as?: 'span' | 'div' + action: string + label?: string +} + +const shouldTrack = (el: HTMLDivElement) => { + const disabledChildren = el.querySelectorAll('*[disabled]') + return disabledChildren.length === 0 +} + +const Track = ({ children, as: Wrapper = 'span', ...trackData }: Props): typeof children => { + const el = useRef(null) + + useEffect(() => { + if (!el.current) { + return + } + + const trackEl = el.current + + const handleClick = () => { + if (shouldTrack(trackEl)) { + trackSafeAppEvent(trackData.action, trackData.label) + } + } + + // We cannot use onClick as events in children do not always bubble up + trackEl.addEventListener('click', handleClick) + return () => { + trackEl.removeEventListener('click', handleClick) + } + }, [el, trackData]) + + if (children.type === Fragment) { + throw new Error('Fragments cannot be tracked.') + } + + return ( + + {children} + + ) +} + +export default Track diff --git a/src/components/WalletIcon/index.tsx b/src/components/WalletIcon/index.tsx index faab6a3b..b7d5a023 100644 --- a/src/components/WalletIcon/index.tsx +++ b/src/components/WalletIcon/index.tsx @@ -1,29 +1,15 @@ import { Skeleton } from '@mui/material' -import metamaskIcon from '@web3-onboard/injected-wallets/dist/icons/metamask' -import coinbaseIcon from '@web3-onboard/coinbase/dist/icon' -import keystoneIcon from '@web3-onboard/keystone/dist/icon' import walletConnectIcon from '@web3-onboard/walletconnect/dist/icon' -import trezorIcon from '@web3-onboard/trezor/dist/icon' -import ledgerIcon from '@web3-onboard/ledger/dist/icon' -import tahoIcon from '@web3-onboard/taho/dist/icon' -import { INJECTED_WALLET_KEYS, WALLET_KEYS } from '@/utils/onboard' +import { WALLET_KEYS } from '@/utils/onboard' type Props = { - [k in keyof (typeof WALLET_KEYS & typeof INJECTED_WALLET_KEYS)]: string + [k in keyof typeof WALLET_KEYS]: string } const WALLET_ICONS: Props = { - [INJECTED_WALLET_KEYS.METAMASK]: metamaskIcon, - [WALLET_KEYS.COINBASE]: coinbaseIcon, - [WALLET_KEYS.INJECTED]: metamaskIcon, - [WALLET_KEYS.KEYSTONE]: keystoneIcon, - [WALLET_KEYS.WALLETCONNECT]: walletConnectIcon, - [WALLET_KEYS.WALLETCONNECT_V2]: walletConnectIcon, - [WALLET_KEYS.TREZOR]: trezorIcon, - [WALLET_KEYS.LEDGER]: ledgerIcon, - [WALLET_KEYS.TAHO]: tahoIcon, -} + WALLETCONNECT: walletConnectIcon, +} as const export const WalletIcon = ({ provider }: { provider: string }) => { const icon = WALLET_ICONS[provider.toUpperCase() as keyof typeof WALLET_ICONS] diff --git a/src/components/WhatIsBoost/index.tsx b/src/components/WhatIsBoost/index.tsx new file mode 100644 index 00000000..b08f29cf --- /dev/null +++ b/src/components/WhatIsBoost/index.tsx @@ -0,0 +1,98 @@ +import { Link, Stack, SvgIcon, Typography } from '@mui/material' +import { AppRoutes } from '@/config/routes' +import NextLink from 'next/link' +import { ChevronLeft, SignalCellularAlt, SignalCellularAlt2Bar } from '@mui/icons-material' +import PaperContainer from '@/components/PaperContainer' +import TokenBoost from '@/public/images/token-boost.png' +import Timefactor from '@/public/images/timefactor.png' +import SafeLogo from '@/public/images/safe-logo-round.svg' +import ClockIcon from '@/public/images/clock-alt.svg' +import Image from 'next/image' +import css from './styles.module.css' +import { NAVIGATION_EVENTS } from '@/analytics/navigation' +import Track from '../Track' + +const WhatIsBoost = () => { + return ( + + + palette.primary.main }} + > + + Back to main + + + + What is Boost and how does it work? + + + + You can multiply your Miles by locking SAFE. The more SAFE you lock and the longer you keep it locked, the + higher your multiplier. + + + Expressed as a mathematical formula, the total boost can be calculated as follows: + + +
+ + +
+ Total boost = token boost * timefactor boost + 1 +
+ + + The total boost consists of two components: the token boost and the timefactor boost. Let’s break these down + further: + + + + + Token boost + + + + This component depends on the amount of tokens you lock. You will be placed in one of 6 different tiers + depending on how many tokens you lock. How the exact formula for the token boost works depends on the tier you + are in. + + + Token boost diagram + + + palette.primary.main }} /> + Timefactor boost + + + + This component ensures that the earlier you lock, the higher your boost will be. Depending on when you lock, + you will fall into a different tier that results in a different formula (similar to the above). However, all + the formulae are time dependent. This means that every day you decide not to lock you will lose your potential + boost compared to if you would have done so that day. You can see the formulae for the different tiers below. + + + + We differentiate between a realised and expected final boost in our app. Realised boost is the boost you have + already secured and which cannot be taken away by unlocking. This has no projection over time. The expected + final boost is the boost you will get at the start of the distribution of rewards, assuming that you didn’t + unlock or withdraw your tokens before. + + + Timefactor diagram + + +
+ + +
+ Note that only the final boost at the end of the season will be multiplied by all gained points. +
+
+
+ ) +} + +export default WhatIsBoost diff --git a/src/components/WhatIsBoost/styles.module.css b/src/components/WhatIsBoost/styles.module.css new file mode 100644 index 00000000..9ea38fea --- /dev/null +++ b/src/components/WhatIsBoost/styles.module.css @@ -0,0 +1,18 @@ +.info { + background: #121312; + padding: 24px; + border-radius: 6px; + font-weight: bold; + display: flex; + align-items: center; + gap: 16px; +} + +.signalWrapper { + position: relative; +} + +.signalWrapper svg:last-child { + position: absolute; + left: 0; +} \ No newline at end of file diff --git a/src/config/constants.ts b/src/config/constants.ts index ca93caf0..40bd72ca 100644 --- a/src/config/constants.ts +++ b/src/config/constants.ts @@ -1,3 +1,5 @@ +import { BigNumber } from 'ethers' + // General export const IS_PRODUCTION = process.env.NEXT_PUBLIC_IS_PRODUCTION === 'true' export const INFURA_TOKEN = process.env.NEXT_PUBLIC_INFURA_TOKEN || '' @@ -15,8 +17,8 @@ export const TREZOR_EMAIL = 'support@safe.global' // Deployments export const DEPLOYMENT_URL = IS_PRODUCTION - ? 'https://governance.safe.global' - : 'https://safe-dao-governance.dev.5afe.dev/' + ? 'https://community.safe.global' + : 'https://safe-dao-governance.dev.5afe.dev' export const GATEWAY_URL = IS_PRODUCTION ? 'https://safe-client.safe.global' : 'https://safe-client.staging.5afe.dev' @@ -25,23 +27,33 @@ export const SAFE_URL = IS_PRODUCTION ? 'https://app.safe.global' : 'https://saf // Chains export const Chains = { MAINNET: '1', - GOERLI: '5', + SEPOLIA: '11155111', } // Strictly type configuration for each chain above type ChainConfig = Record<(typeof Chains)[keyof typeof Chains], T> -export const _DEFAULT_CHAIN_ID = IS_PRODUCTION ? Chains.MAINNET : Chains.GOERLI +export const _DEFAULT_CHAIN_ID = IS_PRODUCTION ? Chains.MAINNET : Chains.SEPOLIA export const CHAIN_SHORT_NAME: ChainConfig = { [Chains.MAINNET]: 'eth', - [Chains.GOERLI]: 'gor', + [Chains.SEPOLIA]: 'sep', +} + +export const CHAIN_START_TIMESTAMPS: ChainConfig = { + [Chains.MAINNET]: Date.parse('Tue Apr 23 2024 12:00:00 GMT+0000'), + [Chains.SEPOLIA]: Date.parse('Tue Mar 01 2024 12:00:00 GMT+0000'), } // Token export const CHAIN_SAFE_TOKEN_ADDRESS: ChainConfig = { [Chains.MAINNET]: '0x5afe3855358e112b5647b952709e6165e1c1eeee', - [Chains.GOERLI]: '0x61fD3b6d656F39395e32f46E2050953376c3f5Ff', + [Chains.SEPOLIA]: '0xd16d9C09d13E9Cf77615771eADC5d51a1Ae92a26', +} + +export const CHAIN_SAFE_LOCKING_ADDRESS: ChainConfig = { + [Chains.MAINNET]: '0x0a7CB434f96f65972D46A5c1A64a9654dC9959b2', + [Chains.SEPOLIA]: '0xb161ccb96b9b817F9bDf0048F212725128779DE9', } // Claiming @@ -49,6 +61,11 @@ const CLAIMING_DATA_URL = IS_PRODUCTION ? 'https://safe-claiming-app-data.safe.global' : 'https://safe-claiming-app-data.staging.5afe.dev' +export const CGW_BASE_URL = { + [Chains.MAINNET]: 'https://safe-client.safe.global', + [Chains.SEPOLIA]: 'https://safe-client.staging.5afe.dev', +} + export const GUARDIANS_URL = `${CLAIMING_DATA_URL}/guardians/guardians.json` export const GUARDIANS_IMAGE_URL = `${CLAIMING_DATA_URL}/guardians/images` export const VESTING_URL = `${CLAIMING_DATA_URL}/allocations` @@ -66,7 +83,7 @@ export const AIRDROP_TAGS = { // Delegation export const CHAIN_DELEGATE_ID: ChainConfig = { [Chains.MAINNET]: 'safe.eth', - [Chains.GOERLI]: 'tutis.eth', + [Chains.SEPOLIA]: 'panzerschrank.eth', } export const DELEGATE_REGISTRY_ADDRESS = '0x469788fe6e9e9681c6ebf3bf78e7fd26fc015446' @@ -77,10 +94,24 @@ export const GOVERNANCE_URL = 'https://forum.gnosis-safe.io/t/how-to-safedao-gov export const CHAIN_SNAPSHOT_URL: ChainConfig = { [Chains.MAINNET]: `https://snapshot.org/#/${CHAIN_DELEGATE_ID[Chains.MAINNET]}`, - [Chains.GOERLI]: `https://snapshot.org/#/${CHAIN_DELEGATE_ID[Chains.GOERLI]}`, + [Chains.SEPOLIA]: `https://snapshot.org/#/${CHAIN_DELEGATE_ID[Chains.SEPOLIA]}`, +} + +export const CHAIN_EXPLORER_URL: ChainConfig = { + [Chains.MAINNET]: 'https://etherscan.io/address/', + [Chains.SEPOLIA]: 'https://sepolia.etherscan.io/address/', } export const SEP5_PROPOSAL_URL = 'https://snapshot.org/#/safe.eth/proposal/0xb4765551b4814b592d02ce67de05527ac1d2b88a8c814c4346ecc0c947c9b941' +export const SAFE_PASS_LANDING_PAGE = 'https://safe.global/pass' +export const SAFE_PASS_HELP_ARTICLE_URL = 'https://help.safe.global/en/articles/157043-what-is-safe-pass' +export const SAFE_TERMS_AND_CONDITIONS_URL = 'https://help.safe.global/en/articles/157469-terms-and-conditions' + export const DISCORD_URL = 'https://chat.safe.global' + +export const UNLIMITED_APPROVAL_AMOUNT = BigNumber.from(2).pow(256).sub(1) + +export const SEASON2_START = 160 +export const SEASON1_START = 27 diff --git a/src/config/routes.ts b/src/config/routes.ts index 65118f01..f74a7259 100644 --- a/src/config/routes.ts +++ b/src/config/routes.ts @@ -1,8 +1,12 @@ export const AppRoutes = { '404': '/404', widgets: '/widgets', - safedao: '/safedao', + unlock: '/unlock', + splash: '/splash', index: '/', + governance: '/governance', delegate: '/delegate', claim: '/claim', + activity: '/activity', + activityProgram: '/activity-program', } diff --git a/src/hooks/__tests__/useAmounts.test.ts b/src/hooks/__tests__/useAmounts.test.ts index 62d75274..21e7c849 100644 --- a/src/hooks/__tests__/useAmounts.test.ts +++ b/src/hooks/__tests__/useAmounts.test.ts @@ -24,7 +24,7 @@ const createMockVesting = ( amount: parseEther(amount.toString()).toString(), curve: 0, durationWeeks, - chainId: 4, + chainId: 11155111, contract: hexZeroPad('0x2', 20), tag: 'user', startDate: Math.floor(fakeNow.getTime() / 1000) - DESYNC_BUFFER + ONE_WEEK * vestingStartDiffInWeeks, diff --git a/src/hooks/__tests__/useContractDelegate.test.ts b/src/hooks/__tests__/useContractDelegate.test.ts index d78a84aa..e56d6d62 100644 --- a/src/hooks/__tests__/useContractDelegate.test.ts +++ b/src/hooks/__tests__/useContractDelegate.test.ts @@ -21,7 +21,7 @@ describe('_getContractDelegate()', () => { () => ({ _isSigner: true, - getChainId: jest.fn(() => Promise.resolve(5)), + getChainId: jest.fn(() => Promise.resolve(11155111)), getAddress: jest.fn(() => Promise.resolve(SAFE_ADDRESS)), call: mockCall, } as unknown as JsonRpcSigner), @@ -42,7 +42,7 @@ describe('_getContractDelegate()', () => { }) it('ignore the ZERO_ADDRESS as delegate', async () => { - const delegateIDInBytes = formatBytes32String(CHAIN_DELEGATE_ID['5']) + const delegateIDInBytes = formatBytes32String(CHAIN_DELEGATE_ID['11155111']) mockCall.mockImplementation((transaction) => { expect(transaction.to?.toString().toLowerCase()).toEqual(DELEGATE_REGISTRY_ADDRESS.toLowerCase()) @@ -54,14 +54,14 @@ describe('_getContractDelegate()', () => { return Promise.resolve(hexZeroPad(ZERO_ADDRESS, 32)) }) - const result = await _getContractDelegate('5', SAFE_ADDRESS, web3Provider) + const result = await _getContractDelegate('11155111', SAFE_ADDRESS, web3Provider) expect(mockCall).toBeCalledTimes(1) expect(result).toBe(null) }) it('should encode the correct data and fetch the delegate on-chain once', async () => { - const delegateIDInBytes = formatBytes32String(CHAIN_DELEGATE_ID['5']) + const delegateIDInBytes = formatBytes32String(CHAIN_DELEGATE_ID['11155111']) const delegateAddress = hexZeroPad('0x1', 20) @@ -75,7 +75,7 @@ describe('_getContractDelegate()', () => { return Promise.resolve(hexZeroPad(delegateAddress, 32)) }) - const result = await _getContractDelegate('5', SAFE_ADDRESS, web3Provider) + const result = await _getContractDelegate('11155111', SAFE_ADDRESS, web3Provider) expect(mockCall).toBeCalledTimes(1) expect(result).toEqual({ address: delegateAddress, ens: 'test.eth' }) diff --git a/src/hooks/__tests__/useEnsResolution.test.ts b/src/hooks/__tests__/useEnsResolution.test.ts index 83f8d64e..34d5e714 100644 --- a/src/hooks/__tests__/useEnsResolution.test.ts +++ b/src/hooks/__tests__/useEnsResolution.test.ts @@ -1,7 +1,7 @@ import { act } from '@testing-library/react' import { waitFor } from '@testing-library/react' import { JsonRpcProvider } from '@ethersproject/providers' -import * as SafeAppsSdk from '@gnosis.pm/safe-apps-react-sdk' +import * as SafeAppsSdk from '@safe-global/safe-apps-react-sdk' import { renderHook } from '@/tests/test-utils' import * as useWeb3 from '@/hooks/useWeb3' @@ -10,10 +10,10 @@ import * as useWallet from '@/hooks/useWallet' import { useEnsResolution } from '@/hooks/useEnsResolution' import type { ChainInfo } from '@safe-global/safe-gateway-typescript-sdk' -jest.mock('@gnosis.pm/safe-apps-react-sdk', () => { +jest.mock('@safe-global/safe-apps-react-sdk', () => { return { __esModule: true, - ...jest.requireActual('@gnosis.pm/safe-apps-react-sdk'), + ...jest.requireActual('@safe-global/safe-apps-react-sdk'), } }) @@ -68,12 +68,12 @@ describe('useEnsResolution()', () => { }) it('should accept EIP-3770 addresses with correct chain prefix', async () => { - const prefixedAddress = 'gor:0x1000000000000000000000000000000000000000' + const prefixedAddress = 'sep:0x1000000000000000000000000000000000000000' web3Provider.resolveName = jest.fn() jest.spyOn(useWeb3, 'useWeb3').mockImplementation(() => web3Provider) - jest.spyOn(useChain, 'useChain').mockImplementation(() => ({ chainId: '5', shortName: 'gor' } as ChainInfo)) + jest.spyOn(useChain, 'useChain').mockImplementation(() => ({ chainId: '11155111', shortName: 'sep' } as ChainInfo)) jest.useFakeTimers() @@ -95,14 +95,14 @@ describe('useEnsResolution()', () => { web3Provider.resolveName = jest.fn() jest.spyOn(useWeb3, 'useWeb3').mockImplementation(() => web3Provider) - jest.spyOn(useChain, 'useChain').mockImplementation(() => ({ chainId: '5', shortName: 'gor' } as ChainInfo)) + jest.spyOn(useChain, 'useChain').mockImplementation(() => ({ chainId: '11155111', shortName: 'sep' } as ChainInfo)) jest.useFakeTimers() const { result } = renderHook(() => useEnsResolution(prefixedAddress)) expect(result.current[0]).toBeUndefined() - expect(result.current[1]).toEqual('The chain prefix does not match that of the current chain (gor)') + expect(result.current[1]).toEqual('The chain prefix does not match that of the current chain (sep)') expect(result.current[2]).toBeFalsy() expect(web3Provider.resolveName).not.toHaveBeenCalled() diff --git a/src/hooks/__tests__/useIsTokenPaused.test.ts b/src/hooks/__tests__/useIsTokenPaused.test.ts index c790c7ae..59c49663 100644 --- a/src/hooks/__tests__/useIsTokenPaused.test.ts +++ b/src/hooks/__tests__/useIsTokenPaused.test.ts @@ -18,7 +18,7 @@ describe('_getIsTokenPaused', () => { it('should return true on error', async () => { mockCall.mockImplementation(() => Promise.reject()) - const result = await _getIsTokenPaused('5', web3Provider) + const result = await _getIsTokenPaused('11155111', web3Provider) expect(result).toBeTruthy() expect(mockCall).toBeCalledTimes(1) @@ -27,7 +27,7 @@ describe('_getIsTokenPaused', () => { it('should return true if token is paused', async () => { mockCall.mockImplementation(async () => Promise.resolve(safeTokenInterface.encodeFunctionResult('paused', [true]))) - const result = await _getIsTokenPaused('5', web3Provider) + const result = await _getIsTokenPaused('11155111', web3Provider) expect(result).toBeTruthy() expect(mockCall).toBeCalledTimes(1) @@ -36,14 +36,14 @@ describe('_getIsTokenPaused', () => { it('should return false if token is unpaused', async () => { mockCall.mockImplementation(async () => Promise.resolve(safeTokenInterface.encodeFunctionResult('paused', [false]))) - const result = await _getIsTokenPaused('5', web3Provider) + const result = await _getIsTokenPaused('11155111', web3Provider) expect(result).toBeFalsy() expect(mockCall).toBeCalledTimes(1) }) it('returns null if no provider is defined', async () => { - const result = await _getIsTokenPaused('5', undefined) + const result = await _getIsTokenPaused('11155111', undefined) expect(result).toBe(null) }) diff --git a/src/hooks/__tests__/useSafeTokenAllocation.test.ts b/src/hooks/__tests__/useSafeTokenAllocation.test.ts index a15a5609..3cee242d 100644 --- a/src/hooks/__tests__/useSafeTokenAllocation.test.ts +++ b/src/hooks/__tests__/useSafeTokenAllocation.test.ts @@ -235,28 +235,46 @@ describe('useSafeTokenAllocation', () => { it('return 0 if no balances / vestings exist', async () => { mockCall.mockImplementation((transaction: Deferrable) => { - const sigHash = keccak256(toUtf8Bytes('balanceOf(address)')).slice(0, 10) - if (typeof transaction.data === 'string' && transaction.data.startsWith(sigHash)) { + const balanceOfSigHash = keccak256(toUtf8Bytes('balanceOf(address)')).slice(0, 10) + const getUserTokenBalanceSigHash = keccak256(toUtf8Bytes('getUserTokenBalance(address)')).slice(0, 10) + if ( + typeof transaction.data === 'string' && + (transaction.data.startsWith(balanceOfSigHash) || transaction.data.startsWith(getUserTokenBalanceSigHash)) + ) { return Promise.resolve('0x0') } return Promise.resolve('0x') }) - const result = await _getVotingPower({ chainId: '5', address: SAFE_ADDRESS, web3: web3Provider, vestingData: [] }) + const result = await _getVotingPower({ + chainId: '11155111', + address: SAFE_ADDRESS, + web3: web3Provider, + vestingData: [], + }) expect(result?.toNumber()).toEqual(0) }) - it('return balance if no vestings exists', async () => { + it('return total balance of tokens held and tokens in locking contract if no vestings exists', async () => { mockCall.mockImplementation((transaction: Deferrable) => { - const sigHash = keccak256(toUtf8Bytes('balanceOf(address)')).slice(0, 10) - if (typeof transaction.data === 'string' && transaction.data.startsWith(sigHash)) { + const balanceOfSigHash = keccak256(toUtf8Bytes('balanceOf(address)')).slice(0, 10) + const getUserTokenBalanceSigHash = keccak256(toUtf8Bytes('getUserTokenBalance(address)')).slice(0, 10) + if (typeof transaction.data === 'string' && transaction.data.startsWith(balanceOfSigHash)) { + return Promise.resolve(parseEther('100').toHexString()) + } + if (typeof transaction.data === 'string' && transaction.data.startsWith(getUserTokenBalanceSigHash)) { return Promise.resolve(parseEther('100').toHexString()) } return Promise.resolve('0x') }) - const result = await _getVotingPower({ chainId: '5', address: SAFE_ADDRESS, web3: web3Provider, vestingData: [] }) - expect(result?.eq(parseEther('100'))).toBeTruthy() + const result = await _getVotingPower({ + chainId: '11155111', + address: SAFE_ADDRESS, + web3: web3Provider, + vestingData: [], + }) + expect(result?.eq(parseEther('200'))).toBeTruthy() }) it('include unredeemed allocations if deadline has not passed', async () => { @@ -280,16 +298,20 @@ describe('useSafeTokenAllocation', () => { mockCall.mockImplementation((transaction: Deferrable) => { const balanceOfSigHash = keccak256(toUtf8Bytes('balanceOf(address)')).slice(0, 10) + const getUserTokenBalanceSigHash = keccak256(toUtf8Bytes('getUserTokenBalance(address)')).slice(0, 10) if (typeof transaction.data === 'string' && transaction.data.startsWith(balanceOfSigHash)) { return Promise.resolve(parseEther('0').toHexString()) } + if (typeof transaction.data === 'string' && transaction.data.startsWith(getUserTokenBalanceSigHash)) { + return Promise.resolve(parseEther('0').toHexString()) + } return Promise.resolve('0x') }) const result = await _getVotingPower({ - chainId: '5', + chainId: '11155111', address: SAFE_ADDRESS, web3: web3Provider, vestingData: mockVestings, @@ -318,16 +340,20 @@ describe('useSafeTokenAllocation', () => { mockCall.mockImplementation((transaction: Deferrable) => { const balanceOfSigHash = keccak256(toUtf8Bytes('balanceOf(address)')).slice(0, 10) + const getUserTokenBalanceSigHash = keccak256(toUtf8Bytes('getUserTokenBalance(address)')).slice(0, 10) if (typeof transaction.data === 'string' && transaction.data.startsWith(balanceOfSigHash)) { return Promise.resolve(BigNumber.from('2000').toHexString()) } + if (typeof transaction.data === 'string' && transaction.data.startsWith(getUserTokenBalanceSigHash)) { + return Promise.resolve(BigNumber.from('0').toHexString()) + } return Promise.resolve('0x') }) const result = await _getVotingPower({ - chainId: '5', + chainId: '11155111', address: SAFE_ADDRESS, web3: web3Provider, vestingData: mockAllocation, @@ -356,16 +382,20 @@ describe('useSafeTokenAllocation', () => { mockCall.mockImplementation((transaction: Deferrable) => { const balanceOfSigHash = keccak256(toUtf8Bytes('balanceOf(address)')).slice(0, 10) + const getUserTokenBalanceSigHash = keccak256(toUtf8Bytes('getUserTokenBalance(address)')).slice(0, 10) if (typeof transaction.data === 'string' && transaction.data.startsWith(balanceOfSigHash)) { return Promise.resolve(BigNumber.from('0').toHexString()) } + if (typeof transaction.data === 'string' && transaction.data.startsWith(getUserTokenBalanceSigHash)) { + return Promise.resolve(BigNumber.from('0').toHexString()) + } return Promise.resolve('0x') }) const result = await _getVotingPower({ - chainId: '5', + chainId: '11155111', address: SAFE_ADDRESS, web3: web3Provider, vestingData: mockAllocation, diff --git a/src/hooks/__tests__/useSummarizedLockHistory.test.ts b/src/hooks/__tests__/useSummarizedLockHistory.test.ts new file mode 100644 index 00000000..d0206fa1 --- /dev/null +++ b/src/hooks/__tests__/useSummarizedLockHistory.test.ts @@ -0,0 +1,122 @@ +import { renderHook } from '@/tests/test-utils' +import { hexZeroPad } from 'ethers/lib/utils' +import { WithdrawEvent, type LockEvent, type UnlockEvent } from '../useLockHistory' +import { useSummarizedLockHistory } from '../useSummarizedLockHistory' + +const holder = hexZeroPad('0x1234', 20) + +const FAKE_YESTERDAY = '2024-03-19T11:00:00.000Z' +const FAKE_TODAY = '2024-03-20T12:00:00.000Z' +const FAKE_1H_AGO = '2024-03-20T11:00:00.000Z' + +const createLock = (amount: string, executionDate: string = FAKE_TODAY.toString()): LockEvent => ({ + executionDate, + transactionHash: '0x93ba0541fe594f935807b559cfe21ae6d20d6785647579a2c7517f8aa9d38076', + holder, + amount, + logIndex: '1', + eventType: 'LOCKED', +}) + +const createUnlock = ( + amount: string, + unlockIndex: string, + executionDate: string = FAKE_TODAY.toString(), +): UnlockEvent => ({ + executionDate, + transactionHash: '0x93ba0541fe594f935807b559cfe21ae6d20d6785647579a2c7517f8aa9d38076', + holder, + amount, + logIndex: '1', + eventType: 'UNLOCKED', + unlockIndex, +}) + +const createWithdrawal = ( + amount: string, + unlockIndex: string, + executionDate: string = FAKE_TODAY.toString(), +): WithdrawEvent => ({ + executionDate, + transactionHash: '0x93ba0541fe594f935807b559cfe21ae6d20d6785647579a2c7517f8aa9d38076', + holder, + amount, + logIndex: '1', + eventType: 'WITHDRAWN', + unlockIndex, +}) + +describe('useSummarizedLockHistory', () => { + beforeAll(() => { + jest.useFakeTimers().setSystemTime(Date.parse(FAKE_TODAY)) + }) + + afterAll(() => { + jest.useRealTimers() + }) + it('should return zeros for empty history', () => { + const { result } = renderHook(() => useSummarizedLockHistory([])) + + expect(result.current.totalLocked.eq(0)).toBeTruthy() + expect(result.current.totalUnlocked.eq(0)).toBeTruthy() + expect(result.current.totalWithdrawable.eq(0)).toBeTruthy() + expect(result.current.pendingUnlocks).toEqual([]) + }) + + it('should add lock events', () => { + const { result } = renderHook(() => + useSummarizedLockHistory([createLock('100'), createLock('200'), createLock('400')]), + ) + + expect(result.current.totalLocked.eq(700)).toBeTruthy() + expect(result.current.totalUnlocked.eq(0)).toBeTruthy() + expect(result.current.totalWithdrawable.eq(0)).toBeTruthy() + expect(result.current.pendingUnlocks).toEqual([]) + }) + + it('should summarize lock and unlock events', () => { + const newestUnlock = createUnlock('200', '1') + const expectedNextUnlock = createUnlock('400', '2', FAKE_1H_AGO) + const { result } = renderHook(() => + useSummarizedLockHistory([createLock('1000'), newestUnlock, expectedNextUnlock]), + ) + + expect(result.current.totalLocked.eq(400)).toBeTruthy() + expect(result.current.totalUnlocked.eq(600)).toBeTruthy() + expect(result.current.totalWithdrawable.eq(0)).toBeTruthy() + expect(result.current.pendingUnlocks).toEqual([expectedNextUnlock, newestUnlock]) + }) + + it('should show withdrawable amount 24h after unlock', () => { + const expectedNextUnlock = createUnlock('100', '2', FAKE_TODAY.toString()) + const { result } = renderHook(() => + useSummarizedLockHistory([ + createLock('1000', FAKE_YESTERDAY.toString()), + createUnlock('200', '1', FAKE_YESTERDAY.toString()), + expectedNextUnlock, + ]), + ) + + expect(result.current.totalLocked.eq(700)).toBeTruthy() + expect(result.current.totalUnlocked.eq(300)).toBeTruthy() + expect(result.current.totalWithdrawable.eq(200)).toBeTruthy() + expect(result.current.pendingUnlocks).toEqual([expectedNextUnlock]) + }) + + it('should substract already withdrawn amounts from withdrawable amount', () => { + const expectedNextUnlock = createUnlock('100', '2', FAKE_TODAY.toString()) + const { result } = renderHook(() => + useSummarizedLockHistory([ + createLock('1000', FAKE_YESTERDAY.toString()), + createUnlock('200', '1', FAKE_YESTERDAY.toString()), + createWithdrawal('200', '1', FAKE_TODAY.toString()), + createUnlock('100', '2', FAKE_TODAY.toString()), + ]), + ) + + expect(result.current.totalLocked.eq(700)).toBeTruthy() + expect(result.current.totalUnlocked.eq(100)).toBeTruthy() + expect(result.current.totalWithdrawable.eq(0)).toBeTruthy() + expect(result.current.pendingUnlocks).toEqual([expectedNextUnlock]) + }) +}) diff --git a/src/hooks/useAddress.ts b/src/hooks/useAddress.ts index b98841b5..5c2b16a5 100644 --- a/src/hooks/useAddress.ts +++ b/src/hooks/useAddress.ts @@ -1,4 +1,4 @@ -import { useSafeAppsSDK } from '@gnosis.pm/safe-apps-react-sdk' +import { useSafeAppsSDK } from '@safe-global/safe-apps-react-sdk' import { useIsSafeApp } from '@/hooks/useIsSafeApp' import { useWallet } from '@/hooks/useWallet' diff --git a/src/hooks/useChainId.ts b/src/hooks/useChainId.ts index 37bf7885..2bb69091 100644 --- a/src/hooks/useChainId.ts +++ b/src/hooks/useChainId.ts @@ -1,7 +1,7 @@ import { ExternalStore } from '@/services/ExternalStore' import { Chains, IS_PRODUCTION, _DEFAULT_CHAIN_ID } from '@/config/constants' import { useIsSafeApp } from '@/hooks/useIsSafeApp' -import { useSafeAppsSDK } from '@gnosis.pm/safe-apps-react-sdk' +import { useSafeAppsSDK } from '@safe-global/safe-apps-react-sdk' export const defaultChainIdStore = new ExternalStore(_DEFAULT_CHAIN_ID) diff --git a/src/hooks/useDebounce.ts b/src/hooks/useDebounce.ts new file mode 100644 index 00000000..b7b4c54d --- /dev/null +++ b/src/hooks/useDebounce.ts @@ -0,0 +1,16 @@ +import { useEffect, useState } from 'react' + +export const useDebounce = (value: T, timeout: number, initialValue: T) => { + const [result, setResult] = useState(initialValue) + + useEffect(() => { + const update = (value: T) => { + setResult(value) + } + + const updateTimeout = setTimeout(() => update(value), timeout) + return () => clearTimeout(updateTimeout) + }, [value, timeout]) + + return result +} diff --git a/src/hooks/useEnsLookup.ts b/src/hooks/useEnsLookup.ts new file mode 100644 index 00000000..4b964f08 --- /dev/null +++ b/src/hooks/useEnsLookup.ts @@ -0,0 +1,15 @@ +import { useWeb3 } from './useWeb3' +import useSWRImmutable from 'swr/immutable' + +export const useEnsLookup = (address: string | undefined): string | undefined => { + const web3 = useWeb3() + + const lookupResult = useSWRImmutable(web3 && address ? `lookup-address-${address}` : null, () => { + if (address) { + return web3?.lookupAddress(address) + } + return undefined + }) + + return lookupResult.data || undefined +} diff --git a/src/hooks/useEnsResolution.ts b/src/hooks/useEnsResolution.ts index 72824d22..59de8a51 100644 --- a/src/hooks/useEnsResolution.ts +++ b/src/hooks/useEnsResolution.ts @@ -1,4 +1,4 @@ -import { useSafeAppsSDK } from '@gnosis.pm/safe-apps-react-sdk' +import { useSafeAppsSDK } from '@safe-global/safe-apps-react-sdk' import { getAddress, isAddress } from 'ethers/lib/utils' import { useEffect, useState } from 'react' diff --git a/src/hooks/useGatewayBaseUrl.ts b/src/hooks/useGatewayBaseUrl.ts new file mode 100644 index 00000000..51bdcca0 --- /dev/null +++ b/src/hooks/useGatewayBaseUrl.ts @@ -0,0 +1,8 @@ +import { CGW_BASE_URL } from '@/config/constants' +import { useChainId } from '@/hooks/useChainId' + +export const useGatewayBaseUrl = (): string => { + const chainId = useChainId() + + return CGW_BASE_URL[chainId] +} diff --git a/src/hooks/useIsDarkMode.ts b/src/hooks/useIsDarkMode.ts deleted file mode 100644 index e1d10f69..00000000 --- a/src/hooks/useIsDarkMode.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { useTheme } from '@mui/material/styles' - -export const useIsDarkMode = (): boolean => { - const { palette } = useTheme() - return palette.mode === 'dark' -} diff --git a/src/hooks/useIsSafeApp.ts b/src/hooks/useIsSafeApp.ts index 9f33fa61..9fcc3590 100644 --- a/src/hooks/useIsSafeApp.ts +++ b/src/hooks/useIsSafeApp.ts @@ -1,14 +1,13 @@ -import { useState } from 'react' - -import { useIsomorphicLayoutEffect } from '@/hooks/useIsomorphicLayoutEffect' +import { useSafeAppsSDK } from '@safe-global/safe-apps-react-sdk' +import { useEffect, useState } from 'react' export const useIsSafeApp = (): boolean => { - const [isSafeApp, setIsSafeApp] = useState(false) - - useIsomorphicLayoutEffect(() => { - const isIframe = typeof window !== 'undefined' && window.self !== window.top - setIsSafeApp(isIframe) - }, [setIsSafeApp]) + const { connected } = useSafeAppsSDK() + const [isSafeApp, setIsSafeApp] = useState(connected) + useEffect(() => { + const isApp = connected || (typeof window !== 'undefined' && window.self !== window.top) + setIsSafeApp(isApp) + }, [connected]) return isSafeApp } diff --git a/src/hooks/useLeaderboard.ts b/src/hooks/useLeaderboard.ts new file mode 100644 index 00000000..857c1085 --- /dev/null +++ b/src/hooks/useLeaderboard.ts @@ -0,0 +1,72 @@ +import { useAddress } from './useAddress' +import useSWR from 'swr' +import { POLLING_INTERVAL } from '@/config/constants' +import { toCursorParam } from '@/utils/gateway' +import { useGatewayBaseUrl } from './useGatewayBaseUrl' + +export type LeaderboardEntry = { + holder: string + position: number + lockedAmount: string + unlockedAmount: string + withdrawnAmount: string +} + +type LeaderboardPage = { + count: number + next: string | null + previous: string | null + results: LeaderboardEntry[] +} + +export const useOwnRank = () => { + const address = useAddress() + const gatewayBaseUrl = useGatewayBaseUrl() + + return useSWR( + address ? `${gatewayBaseUrl}/v1/locking/leaderboard/rank/${address}` : null, + async (url: string | null) => { + if (!url) { + return undefined + } + return await fetch(url).then((resp) => { + if (resp.ok) { + return resp.json() as Promise + } else { + throw new Error('Error fetching own ranking.') + } + }) + }, + { refreshInterval: POLLING_INTERVAL }, + ) +} + +export const useGlobalLeaderboardPage = (limit: number, offset?: number) => { + const gatewayBaseUrl = useGatewayBaseUrl() + + const getKey = (limit: number, offset?: number) => { + if (!offset) { + // Load first page + return `${gatewayBaseUrl}/v1/locking/leaderboard?${toCursorParam(limit)}` + } + + // Load next page + return `${gatewayBaseUrl}/v1/locking/leaderboard?${toCursorParam(limit, offset)}` + } + + const { data } = useSWR( + getKey(limit, offset), + async (url: string) => { + return await fetch(url).then((resp) => { + if (resp.ok) { + return resp.json() as Promise + } else { + throw new Error('Error fetching leaderboard.') + } + }) + }, + { refreshInterval: POLLING_INTERVAL }, + ) + + return data +} diff --git a/src/hooks/useLockHistory.ts b/src/hooks/useLockHistory.ts new file mode 100644 index 00000000..7351c075 --- /dev/null +++ b/src/hooks/useLockHistory.ts @@ -0,0 +1,100 @@ +import { useAddress } from './useAddress' +import useSWRInfinite from 'swr/infinite' +import { useMemo } from 'react' +import { useGatewayBaseUrl } from './useGatewayBaseUrl' +import { POLLING_INTERVAL } from '@/config/constants' +import { toCursorParam } from '@/utils/gateway' + +export type LockEvent = { + eventType: 'LOCKED' + executionDate: string + transactionHash: string + holder: string + amount: string + logIndex: string +} + +export type UnlockEvent = { + eventType: 'UNLOCKED' + executionDate: string + transactionHash: string + holder: string + amount: string + logIndex: string + unlockIndex: string +} + +export type WithdrawEvent = { + eventType: 'WITHDRAWN' + executionDate: string + transactionHash: string + holder: string + amount: string + logIndex: string + unlockIndex: string +} + +export type LockingHistoryEntry = LockEvent | UnlockEvent | WithdrawEvent + +type LockingHistoryEventPage = { + count: number + next: string | null + previous: string | null + results: LockingHistoryEntry[] +} + +const LIMIT = 100 + +export const useLockHistory = () => { + const address = useAddress() + const gatewayBaseUrl = useGatewayBaseUrl() + + const getKey = useMemo( + () => (pageIndex: number, previousPageData: LockingHistoryEventPage) => { + if (!address) { + // We cannot fetch data while the address is resolving + return null + } + if (!previousPageData) { + // Load first page + return `${gatewayBaseUrl}/v1/locking/${address}/history?${toCursorParam(LIMIT)}` + } + if (previousPageData && !previousPageData.next) return null // reached the end + + // Load next page + return previousPageData.next + }, + [address, gatewayBaseUrl], + ) + + const { data, size, setSize } = useSWRInfinite( + getKey, + async (url: string) => { + return await fetch(url).then((resp) => { + if (resp.ok) { + return resp.json() as Promise + } else { + throw new Error('Error fetching lock history.') + } + }) + }, + { + refreshInterval: POLLING_INTERVAL, + }, + ) + + // We need to load everything + if (data && data.length > 0) { + const totalPages = Math.ceil(data[0].count / LIMIT) + if (totalPages > size) { + setSize(Math.ceil(data[0].count / LIMIT)) + } + } + + return useMemo(() => { + if (data === undefined) { + return [] + } + return data.flatMap((entry) => entry.results) + }, [data]) +} diff --git a/src/hooks/useSafeTokenAllocation.ts b/src/hooks/useSafeTokenAllocation.ts index e8add827..0cfbf480 100644 --- a/src/hooks/useSafeTokenAllocation.ts +++ b/src/hooks/useSafeTokenAllocation.ts @@ -12,10 +12,10 @@ import { sameAddress } from '@/utils/addresses' import { useWeb3 } from '@/hooks/useWeb3' import { isDashboard } from '@/utils/routes' import { Allocation, useAllocations } from '@/hooks/useAllocations' -import { CHAIN_SAFE_TOKEN_ADDRESS } from '@/config/constants' -import { getSafeTokenInterface } from '@/services/contracts/SafeToken' import { useChainId } from '@/hooks/useChainId' import { useAddress } from '@/hooks/useAddress' +import { fetchTokenBalance } from '@/utils/safe-token' +import { fetchLockingContractBalance } from '@/utils/lock' export type Vesting = Allocation & { isExpired: boolean @@ -86,33 +86,20 @@ export const _getVestingData = async (web3: JsonRpcProvider, allocations: Alloca return getValidVestingAllocation(vestingData) } -const tokenInterface = getSafeTokenInterface() - -const fetchTokenBalance = async (chainId: string, safeAddress: string, provider: JsonRpcProvider): Promise => { - const safeTokenAddress = CHAIN_SAFE_TOKEN_ADDRESS[chainId] - - if (!safeTokenAddress) { - return '0' - } - - try { - return await provider.call({ - to: safeTokenAddress, - data: tokenInterface.encodeFunctionData('balanceOf', [safeAddress]), - }) - } catch (err) { - throw Error(`Error fetching Safe Token balance: ${err}`) - } -} - -const computeVotingPower = (validVestingData: Vesting[], balance: string): BigNumber => { +const computeVotingPower = ( + validVestingData: Vesting[], + balance: string, + lockingContractBalance: string, +): BigNumber => { const tokensInVesting = validVestingData.reduce( (acc, data) => acc.add(data.amount).sub(data.amountClaimed), BigNumber.from(0), ) + const totalBalance = BigNumber.from(balance).add(BigNumber.from(lockingContractBalance)) + // add balance - return tokensInVesting.add(BigNumber.from(balance)) + return tokensInVesting.add(totalBalance) } export const _getVotingPower = async ({ @@ -127,8 +114,9 @@ export const _getVotingPower = async ({ vestingData: Vesting[] }): Promise => { const balance = await fetchTokenBalance(chainId, address, web3) + const lockingContractBalance = await fetchLockingContractBalance(chainId, address, web3) - return computeVotingPower(vestingData, balance) + return computeVotingPower(vestingData, balance, lockingContractBalance) } const getSafeTokenAllocation = async ({ diff --git a/src/hooks/useSafeTokenBalance.ts b/src/hooks/useSafeTokenBalance.ts new file mode 100644 index 00000000..b95769ee --- /dev/null +++ b/src/hooks/useSafeTokenBalance.ts @@ -0,0 +1,47 @@ +import { useAddress } from './useAddress' +import { useChainId } from './useChainId' +import { useWeb3 } from './useWeb3' +import useSWR from 'swr' +import { fetchTokenBalance, fetchTokenLockingAllowance } from '@/utils/safe-token' +import { BigNumber } from 'ethers' +import { POLLING_INTERVAL } from '@/config/constants' + +export const useSafeTokenBalance = () => { + const QUERY_KEY = 'safe-token-balance' + const web3 = useWeb3() + const chainId = useChainId() + const address = useAddress() + + return useSWR( + web3 && address ? QUERY_KEY : null, + () => { + if (!address || !web3) { + return '0' + } + return fetchTokenBalance(chainId, address, web3) + }, + { + refreshInterval: POLLING_INTERVAL, + }, + ) +} + +export const useSafeTokenLockingAllowance = () => { + const QUERY_KEY = 'safe-token-locking-allowance' + const web3 = useWeb3() + const chainId = useChainId() + const address = useAddress() + + return useSWR(web3 && address ? QUERY_KEY : null, () => { + if (!address || !web3) { + return '0' + } + return fetchTokenLockingAllowance(chainId, address, web3) + }) +} + +export type SingleUnlock = { + unlockAmount: BigNumber + unlockedAt: BigNumber + isUnlocked: boolean +} diff --git a/src/hooks/useStartDates.ts b/src/hooks/useStartDates.ts new file mode 100644 index 00000000..b6eb859b --- /dev/null +++ b/src/hooks/useStartDates.ts @@ -0,0 +1,15 @@ +import { CHAIN_START_TIMESTAMPS } from '@/config/constants' +import { IS_PRODUCTION, Chains } from '@/config/constants' + +import { useChainId } from '@/hooks/useChainId' +import { ExternalStore } from '@/services/ExternalStore' + +export const startDateStore = new ExternalStore(CHAIN_START_TIMESTAMPS[Chains.SEPOLIA]) + +export const useStartDate = () => { + const chainId = useChainId() + + const startTime = IS_PRODUCTION ? CHAIN_START_TIMESTAMPS[chainId] : startDateStore.useStore()! + + return { startTime } +} diff --git a/src/hooks/useSummarizedLockHistory.ts b/src/hooks/useSummarizedLockHistory.ts new file mode 100644 index 00000000..3f76abd9 --- /dev/null +++ b/src/hooks/useSummarizedLockHistory.ts @@ -0,0 +1,73 @@ +import { isGreaterThan24HoursDiff } from '@/utils/date' +import { isUnlockEvent, isWithdrawEvent } from '@/utils/lock' +import { BigNumber } from 'ethers' +import { useMemo } from 'react' +import { UnlockEvent, useLockHistory, WithdrawEvent } from './useLockHistory' + +export const useSummarizedLockHistory = ( + lockHistory: ReturnType, +): { + totalLocked: BigNumber + totalUnlocked: BigNumber + totalWithdrawable: BigNumber + pendingUnlocks: UnlockEvent[] | undefined +} => { + const totalLocked = useMemo( + () => + lockHistory.reduce((prev, event) => { + switch (event.eventType) { + case 'LOCKED': + return prev.add(event.amount) + + case 'UNLOCKED': + return prev.sub(event.amount) + + case 'WITHDRAWN': + return prev + } + }, BigNumber.from(0)), + [lockHistory], + ) + const totalUnlocked = useMemo( + () => + lockHistory.reduce((prev, event) => { + switch (event.eventType) { + case 'LOCKED': + return prev + case 'UNLOCKED': + return prev.add(event.amount) + case 'WITHDRAWN': + return prev.sub(event.amount) + } + }, BigNumber.from(0)), + [lockHistory], + ) + const { totalWithdrawable, pendingUnlocks } = useMemo(() => { + const unlocks = lockHistory.filter((event) => isUnlockEvent(event)).map((event) => event as UnlockEvent) + const withdrawnIds = lockHistory + .filter((event) => isWithdrawEvent(event)) + .map((event) => event as WithdrawEvent) + .map((withdraw) => withdraw.unlockIndex) + // Unlocks that have not been withdrawn and are older than 24h + const withdrawableUnlocks = unlocks.filter( + (unlock) => + !withdrawnIds.includes(unlock.unlockIndex) && + isGreaterThan24HoursDiff(Date.parse(unlock.executionDate), Date.now()), + ) + const totalWithdrawable = withdrawableUnlocks.reduce((prev, event) => prev.add(event.amount), BigNumber.from(0)) + const pendingUnlocks = unlocks + .filter( + (unlock) => + !withdrawnIds.includes(unlock.unlockIndex) && + !isGreaterThan24HoursDiff(Date.parse(unlock.executionDate), Date.now()), + ) + .reverse() + + return { + totalWithdrawable, + pendingUnlocks, + } + }, [lockHistory]) + + return { totalLocked, totalUnlocked, totalWithdrawable, pendingUnlocks } +} diff --git a/src/hooks/useTxSender.ts b/src/hooks/useTxSender.ts new file mode 100644 index 00000000..c8584a27 --- /dev/null +++ b/src/hooks/useTxSender.ts @@ -0,0 +1,42 @@ +import { useSafeAppsSDK } from '@safe-global/safe-apps-react-sdk' +import { useIsSafeApp } from './useIsSafeApp' +import { useWallet } from './useWallet' + +import SafeAppsSDK from '@safe-global/safe-apps-sdk' + +export type BaseTransaction = Parameters[0]['txs'][number] + +export type TxSender = { + isBatchingSupported: boolean + sendTxs: (txs: BaseTransaction[]) => Promise +} + +export const useTxSender = (): TxSender | undefined => { + const wallet = useWallet() + const isSafeApp = useIsSafeApp() + + const { sdk } = useSafeAppsSDK() + + if (isSafeApp && sdk) { + return { + isBatchingSupported: true, + sendTxs: (txs: BaseTransaction[]) => { + return sdk.txs.send({ txs }) + }, + } + } + + if (wallet) { + return { + isBatchingSupported: false, + sendTxs: (txs: BaseTransaction[]) => { + // No batched txs allowed + if (txs.length !== 1) { + throw new Error('Batched txs are only supported when opened as Safe App') + } + const tx = txs[0] + return wallet.provider.request({ method: 'eth_sendTransaction', params: [{ ...tx }] }) + }, + } + } +} diff --git a/src/hooks/useWallet.ts b/src/hooks/useWallet.ts index 54b57833..32a664fa 100644 --- a/src/hooks/useWallet.ts +++ b/src/hooks/useWallet.ts @@ -4,7 +4,6 @@ import type { EIP1193Provider, WalletState } from '@web3-onboard/core' import { useOnboard } from '@/hooks/useOnboard' import { localItem } from '@/services/storage/local' -import { isWalletUnlocked } from '@/utils/wallet' export type ConnectedWallet = { label: string @@ -99,12 +98,8 @@ export const useInitWallet = () => { return } - isWalletUnlocked(label).then((isUnlocked) => { - if (isUnlocked) { - onboard.connectWallet({ - autoSelect: { label, disableModals: true }, - }) - } + onboard.connectWallet({ + autoSelect: { label, disableModals: true }, }) }, [onboard]) } diff --git a/src/hooks/useWeb3.ts b/src/hooks/useWeb3.ts index 63e4855b..aa94b011 100644 --- a/src/hooks/useWeb3.ts +++ b/src/hooks/useWeb3.ts @@ -1,5 +1,5 @@ import { useEffect } from 'react' -import { useSafeAppsSDK } from '@gnosis.pm/safe-apps-react-sdk' +import { useSafeAppsSDK } from '@safe-global/safe-apps-react-sdk' import type { JsonRpcProvider } from '@ethersproject/providers' import { useWallet } from '@/hooks/useWallet' diff --git a/src/styles/colors-dark.ts b/src/styles/colors-dark.ts index aaacd0f1..3ea7e651 100644 --- a/src/styles/colors-dark.ts +++ b/src/styles/colors-dark.ts @@ -1,7 +1,7 @@ const darkPalette = { text: { primary: '#FFFFFF', - secondary: '#636669', + secondary: '#A1A3A7', disabled: '#636669', }, primary: { diff --git a/src/styles/theme.ts b/src/styles/theme.ts index fe6607f8..64d2c8ce 100644 --- a/src/styles/theme.ts +++ b/src/styles/theme.ts @@ -224,8 +224,13 @@ const initTheme = (darkMode: boolean) => { styleOverrides: { root: ({ theme }) => ({ fontWeight: 700, + fontSize: '14px', + letterSpacing: '0.46px', + color: theme.palette.text.primary, + textDecorationColor: 'inherit', '&:hover': { color: theme.palette.primary.light, + textDecorationColor: 'inherit', }, }), }, diff --git a/src/tests/test-utils.tsx b/src/tests/test-utils.tsx index 4740d18b..91e6fe5c 100644 --- a/src/tests/test-utils.tsx +++ b/src/tests/test-utils.tsx @@ -1,4 +1,4 @@ -import SafeProvider from '@gnosis.pm/safe-apps-react-sdk' +import SafeProvider from '@safe-global/safe-apps-react-sdk' import { render, renderHook } from '@testing-library/react' import type { RenderHookOptions } from '@testing-library/react' diff --git a/src/utils/__tests__/boost.test.ts b/src/utils/__tests__/boost.test.ts new file mode 100644 index 00000000..6ac7e264 --- /dev/null +++ b/src/utils/__tests__/boost.test.ts @@ -0,0 +1,247 @@ +import { SEASON1_START, SEASON2_START } from '@/config/constants' +import { floorNumber, getBoostFunction, getTimeFactor, getTokenBoost } from '../boost' +import { LockHistory } from '../lock' + +describe('boost', () => { + describe('floorNumber', () => { + it('should floor numbers without decimals', () => { + expect(floorNumber(2.0, 2)).toEqual(2) + }) + + it('should floor numbers above .5 decimals', () => { + expect(floorNumber(2.449, 1)).toEqual(2.4) + }) + + it('should floor numbers with .5 decimals', () => { + expect(floorNumber(2.45, 1)).toEqual(2.4) + }) + + it('should floor numbers above .5 decimals', () => { + expect(floorNumber(2.49, 1)).toEqual(2.4) + }) + }) + + describe('getTokenBoost', () => { + it('should return 0 for NaN amount', () => { + expect(getTokenBoost(Number('*'))).toBe(0) + expect(getTokenBoost(Number('100-10'))).toBe(0) + expect(getTokenBoost(Number('100**10'))).toBe(0) + }) + it('should return 0 for amounts below 100', () => { + expect(getTokenBoost(0)).toBe(0) + expect(getTokenBoost(1)).toBe(0) + expect(getTokenBoost(50)).toBe(0) + expect(getTokenBoost(99)).toBe(0) + expect(getTokenBoost(100)).toBe(0) + }) + + it('should use the correct function for amounts between 100 and 1000', () => { + expect(getTokenBoost(101)).toBe(101 * 0.000277778 - 0.0277778) + expect(getTokenBoost(500)).toBe(500 * 0.000277778 - 0.0277778) + expect(getTokenBoost(1000)).toBeCloseTo(0.25) + }) + + it('should use the correct function for amounts between 1000 and 10000', () => { + expect(getTokenBoost(1001)).toBe(1001 * 0.0000277778 + 0.222222) + expect(getTokenBoost(5000)).toBe(5000 * 0.0000277778 + 0.222222) + expect(getTokenBoost(10000)).toBe(0.5) + }) + + it('should use the correct function for amounts between 10001 and 100000', () => { + expect(getTokenBoost(10001)).toBe(10001 * 5.55556 * 10 ** -6 + 0.444444) + expect(getTokenBoost(50000)).toBe(50000 * 5.55556 * 10 ** -6 + 0.444444) + expect(getTokenBoost(100000)).toBe(1) + }) + + it('should be 1 for numbers above 100_000', () => { + expect(getTokenBoost(100001)).toBe(1) + }) + }) + + describe('getTimeFactor', () => { + it('should return 1 for negative days', () => { + expect(getTimeFactor(-1)).toBe(1) + }) + + it('should return 1 on first day', () => { + expect(getTimeFactor(0)).toBe(1) + }) + + it('should return 1 on first 28 days', () => { + expect(getTimeFactor(27)).toBe(1) + }) + + it('should use the correct function after 28 days', () => { + expect(getTimeFactor(30)).toBeCloseTo(1 - 3 / 133) + }) + + it('it should be 0 after the season', () => { + expect(getTimeFactor(200)).toBe(0) + }) + }) + + describe('getBoostFunction', () => { + describe('without prior locks on day 0', () => { + it('should always return 1.0 for amount NaN', () => { + const boostFunction = getBoostFunction(0, Number.NaN, []) + expect(boostFunction({ x: -1 })).toBe(1) + expect(boostFunction({ x: 0 })).toBe(1) + expect(boostFunction({ x: SEASON1_START })).toBe(1) + expect(boostFunction({ x: SEASON2_START })).toBe(1) + expect(boostFunction({ x: 1000 })).toBe(1) + }) + it('should always return 1.0 for amount 0', () => { + const boostFunction = getBoostFunction(0, 0, []) + expect(boostFunction({ x: -1 })).toBe(1) + expect(boostFunction({ x: 0 })).toBe(1) + expect(boostFunction({ x: SEASON1_START })).toBe(1) + expect(boostFunction({ x: SEASON2_START })).toBe(1) + expect(boostFunction({ x: 1000 })).toBe(1) + }) + + it('should compute boost with 1000 tokens locked', () => { + const boostFunction = getBoostFunction(0, 1000, []) + expect(boostFunction({ x: -1 })).toBeCloseTo(1) + expect(boostFunction({ x: 0 })).toBeCloseTo(1.25) + expect(boostFunction({ x: SEASON1_START })).toBeCloseTo(1.25) + expect(boostFunction({ x: SEASON2_START })).toBeCloseTo(1.25) + expect(boostFunction({ x: 1000 })).toBeCloseTo(1.25) + }) + + it('should compute boost with 10000 tokens locked', () => { + const boostFunction = getBoostFunction(0, 10000, []) + expect(boostFunction({ x: -1 })).toBeCloseTo(1) + expect(boostFunction({ x: 0 })).toBeCloseTo(1.5) + expect(boostFunction({ x: SEASON1_START })).toBeCloseTo(1.5) + expect(boostFunction({ x: SEASON2_START })).toBeCloseTo(1.5) + expect(boostFunction({ x: 1000 })).toBeCloseTo(1.5) + }) + + it('should compute boost with 1000000 tokens locked', () => { + const boostFunction = getBoostFunction(0, 1000000, []) + expect(boostFunction({ x: -1 })).toBe(1) + expect(boostFunction({ x: 0 })).toBe(2) + expect(boostFunction({ x: SEASON1_START })).toBe(2) + expect(boostFunction({ x: SEASON2_START })).toBe(2) + expect(boostFunction({ x: 1000 })).toBe(2) + }) + }) + + it('should compute for 100_000 tokens locked on day 0', () => { + const boostFunction = getBoostFunction(0, 100_000, []) + expect(boostFunction({ x: -1 })).toBe(1) + expect(boostFunction({ x: 0 })).toBeCloseTo(2) + expect(boostFunction({ x: 39 })).toBeCloseTo(2) + expect(boostFunction({ x: 40 })).toBeCloseTo(2) + expect(boostFunction({ x: 1000 })).toBeCloseTo(2) + }) + + it('should compute for 100_000 tokens locked on day 45', () => { + const boostFunction = getBoostFunction(45, 100_000, []) + expect(boostFunction({ x: -1 })).toBe(1) + expect(boostFunction({ x: 0 })).toBeCloseTo(1) + expect(boostFunction({ x: 44 })).toBeCloseTo(1) + // 1.0 * 0.86 + 1 = 1.86 + expect(boostFunction({ x: 45 })).toBeCloseTo(1.86) + expect(boostFunction({ x: 1000 })).toBeCloseTo(1.86) + }) + + it('should keep boost unchanged if NaN is the locked amount', () => { + const priorLock: LockHistory = { + day: 0, + amount: 1000, + } + const boostFunction = getBoostFunction(40, Number.NaN, [priorLock]) + + expect(boostFunction({ x: -1 })).toBe(1) + expect(boostFunction({ x: 0 })).toBeCloseTo(1.25) + expect(boostFunction({ x: 40 })).toBeCloseTo(1.25) + expect(boostFunction({ x: 1000 })).toBeCloseTo(1.25) + }) + + it('should compute for 1000 tokens on day one and 1000 after 40 days', () => { + const priorLock: LockHistory = { + day: 0, + amount: 1000, + } + const boostFunction = getBoostFunction(40, 1000, [priorLock]) + + expect(boostFunction({ x: -1 })).toBe(1) + expect(boostFunction({ x: 0 })).toBeCloseTo(1.25) + expect(boostFunction({ x: 39 })).toBeCloseTo(1.25) + // + expect(boostFunction({ x: 40 })).toBeCloseTo(1.275) + expect(boostFunction({ x: 1000 })).toBeCloseTo(1.275) + }) + + it('should compute for 1000 tokens on day one and 1000 after 40 days and another 1000 after 80 days', () => { + const priorLocks: LockHistory[] = [ + { + day: 0, + amount: 1000, + }, + { + day: 40, + amount: 1000, + }, + { + day: 80, + amount: 1000, + }, + ] + const boostFunction = getBoostFunction(100, 0, priorLocks) + + expect(boostFunction({ x: -1 })).toBe(1) + expect(boostFunction({ x: 0 })).toBeCloseTo(1.25) + expect(boostFunction({ x: 39 })).toBeCloseTo(1.25) + expect(boostFunction({ x: 40 })).toBeCloseTo(1.275) + expect(boostFunction({ x: 79 })).toBeCloseTo(1.275) + expect(boostFunction({ x: 80 })).toBeCloseTo(1.292) + expect(boostFunction({ x: 1000 })).toBeCloseTo(1.292) + }) + + it('should drop to 1 if everything gets unlocked', () => { + const priorLocks: LockHistory[] = [ + { + day: 0, + amount: 10000, + }, + ] + const boostFunction = getBoostFunction(SEASON1_START, -10000, priorLocks) + + expect(boostFunction({ x: -1 })).toBe(1) + expect(boostFunction({ x: 0 })).toBeCloseTo(1.5) + expect(boostFunction({ x: SEASON1_START })).toBe(1) + expect(boostFunction({ x: SEASON2_START })).toBe(1) + expect(boostFunction({ x: 1000 })).toBe(1) + }) + + it('should compute for 2000 tokens on day one, unlocking 1000 after 40 days and locking another 1000 after 80 days', () => { + const priorLocks: LockHistory[] = [ + { + day: 0, + amount: 2000, + }, + { + day: 39, + amount: -1000, + }, + { + day: 79, + amount: 1000, + }, + ] + const boostFunction = getBoostFunction(100, 0, priorLocks) + + expect(boostFunction({ x: -1 })).toBe(1) + expect(boostFunction({ x: 0 })).toBeCloseTo(1.277) + expect(boostFunction({ x: 38 })).toBeCloseTo(1.277) + // 0.25 * 0.91 + 1 + expect(boostFunction({ x: 39 })).toBeCloseTo(1.2275) + expect(boostFunction({ x: 78 })).toBeCloseTo(1.2275) + // 1.2275 + (1.277 - 1.25) * (0.609) + expect(boostFunction({ x: 79 })).toBeCloseTo(1.2439) + expect(boostFunction({ x: 1000 })).toBeCloseTo(1.2439) + }) + }) +}) diff --git a/src/utils/__tests__/claim.test.ts b/src/utils/__tests__/claim.test.ts index c50c6a70..44bb9886 100644 --- a/src/utils/__tests__/claim.test.ts +++ b/src/utils/__tests__/claim.test.ts @@ -20,7 +20,7 @@ describe('createClaimTxs', () => { account: safeAddress, amount: parseEther('100').toString(), amountClaimed: '0', - chainId: 5, + chainId: 11155111, contract: mockUserAirdropAddress, curve: 0, durationWeeks: 416, @@ -76,7 +76,7 @@ describe('createClaimTxs', () => { account: safeAddress, amount: parseEther('200').toString(), amountClaimed: '0', - chainId: 5, + chainId: 11155111, contract: mockSep5AirdropAddress, curve: 0, durationWeeks: 416, @@ -132,7 +132,7 @@ describe('createClaimTxs', () => { account: safeAddress, amount: parseEther('100').toString(), amountClaimed: '0', - chainId: 5, + chainId: 11155111, contract: mockUserAirdropAddress, curve: 0, durationWeeks: 416, @@ -147,7 +147,7 @@ describe('createClaimTxs', () => { account: safeAddress, amount: parseEther('50').toString(), amountClaimed: '0', - chainId: 5, + chainId: 11155111, contract: mockSep5AirdropAddress, curve: 0, durationWeeks: 416, @@ -188,7 +188,7 @@ describe('createClaimTxs', () => { account: safeAddress, amount: parseEther('100').toString(), amountClaimed: '0', - chainId: 5, + chainId: 11155111, contract: mockInvestorVestingAddress, curve: 0, durationWeeks: 416, @@ -223,7 +223,7 @@ describe('createClaimTxs', () => { account: safeAddress, amount: parseEther('100').toString(), amountClaimed: '0', - chainId: 5, + chainId: 11155111, contract: mockInvestorVestingAddress, curve: 0, durationWeeks: 416, diff --git a/src/utils/__tests__/date.test.ts b/src/utils/__tests__/date.test.ts new file mode 100644 index 00000000..199d40cd --- /dev/null +++ b/src/utils/__tests__/date.test.ts @@ -0,0 +1,90 @@ +import { Chains, CHAIN_START_TIMESTAMPS } from '@/config/constants' +import { formatDay, timeRemaining } from '../date' + +describe('date', () => { + let now = 0 + beforeAll(() => { + jest.useFakeTimers().setSystemTime(new Date('2024-01-01')) + now = Date.now() / 1000 + }) + describe('timeRemaining', () => { + it('should return zeroes for 0 millis', () => { + expect(timeRemaining(now)).toEqual({ + days: 0, + hours: 0, + minutes: 0, + seconds: 0, + }) + }) + + it('should return zeroes for negative millis', () => { + expect(timeRemaining(now - 1)).toEqual({ + days: 0, + hours: 0, + minutes: 0, + seconds: 0, + }) + }) + + it('should compute days', () => { + expect(timeRemaining(now + 60 * 60 * 24 * 3)).toEqual({ + days: 3, + hours: 0, + minutes: 0, + seconds: 0, + }) + }) + + it('should compute hours', () => { + expect(timeRemaining(now + 60 * 60 * 4)).toEqual({ + days: 0, + hours: 4, + minutes: 0, + seconds: 0, + }) + }) + it('should compute minutes', () => { + expect(timeRemaining(now + 60 * 5)).toEqual({ + days: 0, + hours: 0, + minutes: 5, + seconds: 0, + }) + }) + it('should compute seconds', () => { + expect(timeRemaining(now + 6)).toEqual({ + days: 0, + hours: 0, + minutes: 0, + seconds: 6, + }) + }) + + it('should compute mixed amounds', () => { + expect(timeRemaining(now + 6 + 60 * 5 + 60 * 60 * 4 + 60 * 60 * 24 * 3)).toEqual({ + days: 3, + hours: 4, + minutes: 5, + seconds: 6, + }) + }) + }) + + describe('formatDay', () => { + it('should work for negative numbers', () => { + expect(formatDay(-1, CHAIN_START_TIMESTAMPS[Chains.MAINNET])).toEqual('April 22') + }) + + it('should work for zero', () => { + expect(formatDay(0, CHAIN_START_TIMESTAMPS[Chains.MAINNET])).toEqual('April 23') + }) + + it('should work for positive numbers', () => { + expect(formatDay(7, CHAIN_START_TIMESTAMPS[Chains.MAINNET])).toEqual('April 30') + }) + + it('should work for future months', () => { + expect(formatDay(30, CHAIN_START_TIMESTAMPS[Chains.MAINNET])).toEqual('May 23') + }) + }) +}) diff --git a/src/utils/__tests__/lock.test.ts b/src/utils/__tests__/lock.test.ts new file mode 100644 index 00000000..fc980721 --- /dev/null +++ b/src/utils/__tests__/lock.test.ts @@ -0,0 +1,39 @@ +import { Chains, CHAIN_START_TIMESTAMPS } from '@/config/constants' +import { toRelativeLockHistory } from '../lock' + +describe('toRelativeLockHistory', () => { + it('should sort by execution timestamp', () => { + const relativeHistory = toRelativeLockHistory( + [ + { + executionDate: '2024-04-03T14:49:24.000Z', + transactionHash: '0xe3be658eb5ab72b0ed05f58c0987328dfeaa1b23b23111fb3325314dad83c9f6', + holder: '0xC4934eA242Bf292CA59A2aF85ED116506F8f2d03', + amount: '500000000000000000000000', + logIndex: '88', + unlockIndex: '0', + eventType: 'UNLOCKED', + }, + { + executionDate: '2024-03-22T08:37:12.000Z', + transactionHash: '0x2fde285a44b821d8482047cc516b71c0a510ae198553ce98c8973d5a5119f91b', + holder: '0xC4934eA242Bf292CA59A2aF85ED116506F8f2d03', + amount: '1000000000000000000000000', + logIndex: '193', + eventType: 'LOCKED', + }, + ], + CHAIN_START_TIMESTAMPS[Chains.SEPOLIA], + ) + + expect(relativeHistory).toHaveLength(2) + expect(relativeHistory[0]).toEqual({ + day: 20, + amount: 1000000, + }) + expect(relativeHistory[1]).toEqual({ + day: 33, + amount: -500000, + }) + }) +}) diff --git a/src/utils/analytics.ts b/src/utils/analytics.ts new file mode 100644 index 00000000..7bcfdbb9 --- /dev/null +++ b/src/utils/analytics.ts @@ -0,0 +1,13 @@ +const SAFE_APPS_ANALYTICS_CATEGORY = 'safe-apps-analytics' + +export const trackSafeAppEvent = (action: string, label?: string) => { + window.parent.postMessage( + { + category: SAFE_APPS_ANALYTICS_CATEGORY, + action, + label, + safeAppName: 'Safe{Explorers}', + }, + '*', + ) +} diff --git a/src/utils/boost.ts b/src/utils/boost.ts new file mode 100644 index 00000000..bbad1775 --- /dev/null +++ b/src/utils/boost.ts @@ -0,0 +1,68 @@ +import { LockHistory } from './lock' + +export const floorNumber = (num: number, digits: number) => { + const decimal = Math.pow(10, digits) + return Math.floor(num * decimal) / decimal +} + +export const getTokenBoost = (amountLocked: number) => { + if (isNaN(amountLocked) || amountLocked <= 100) { + return 0 + } + if (amountLocked <= 1_000) { + return amountLocked * 0.000277778 - 0.0277778 + } + if (amountLocked <= 10_000) { + return amountLocked * 0.0000277778 + 0.222222 + } + if (amountLocked < 100_000) { + return amountLocked * 5.55556 * 10 ** -6 + 0.444444 + } + return 1 +} + +export const getTimeFactor = (days: number) => { + if (days <= 27) { + return 1 + } + + return Math.max(0, 1 - (days - 27) / 133) +} + +/** + * + * @param now today in days since begin of program + * @param amountDiff user entered amount in the app + * @param history lock history + * @returns + */ +export const getBoostFunction = + (now: number, amountDiff: number, history: LockHistory[]) => + (d: { x: number }): number => { + // Add new boost to history + const newHistory: LockHistory[] = [...history, { amount: isNaN(amountDiff) ? 0 : amountDiff, day: now }] + + // Filter out all entries that were made after the current day (x) + const filteredHistory = newHistory.filter((entry) => entry.day <= d.x) + let currentBoost = 1 + let lockedAmount = 0 + for (let i = 0; i < filteredHistory.length; i++) { + const currentEvent = filteredHistory[i] + const prevLockedAmount = lockedAmount + lockedAmount = lockedAmount + currentEvent.amount + + if (currentEvent.amount >= 0) { + // For the first lock we need to only consider the time factor of today so we divide by 1 + // handle lock event + const boostGain = getTokenBoost(lockedAmount) - getTokenBoost(prevLockedAmount) + const timeFactorLock = getTimeFactor(currentEvent.day) + currentBoost = currentBoost + boostGain * timeFactorLock + } else { + // handle unlock + currentBoost = getTokenBoost(lockedAmount) * getTimeFactor(currentEvent.day) + 1 + } + } + + // Compute and add the boost for each interval + 1 + return currentBoost + } diff --git a/src/utils/claim.ts b/src/utils/claim.ts index 092c6f2e..591189e8 100644 --- a/src/utils/claim.ts +++ b/src/utils/claim.ts @@ -1,10 +1,10 @@ import { BigNumber } from 'ethers' -import type { BaseTransaction } from '@gnosis.pm/safe-apps-sdk/dist/src/types/sdk.d' import { getVestingTypes } from '@/utils/vesting' import { getAirdropInterface } from '@/services/contracts/Airdrop' import { splitAirdropAmounts } from '@/utils/airdrop' import type { Vesting } from '@/hooks/useSafeTokenAllocation' +import { BaseTransaction } from '@/hooks/useTxSender' const airdropInterface = getAirdropInterface() diff --git a/src/utils/date.ts b/src/utils/date.ts new file mode 100644 index 00000000..2448ff3c --- /dev/null +++ b/src/utils/date.ts @@ -0,0 +1,67 @@ +const DAY = 60 * 60 * 24 +const HOUR = 60 * 60 +const MINUTE = 60 + +export const timeRemaining = (timestampInSeconds: number) => { + let remainingSeconds = Math.max(0, timestampInSeconds - Date.now() / 1000) + const days = Math.floor(remainingSeconds / DAY) + remainingSeconds = remainingSeconds % DAY + const hours = Math.floor(remainingSeconds / HOUR) + remainingSeconds = remainingSeconds % HOUR + const minutes = Math.floor(remainingSeconds / MINUTE) + const seconds = remainingSeconds % MINUTE + + return { + days, + hours, + minutes, + seconds, + } +} + +const MONTH_LABEL: Record = { + 0: 'January', + 1: 'February', + 2: 'March', + 3: 'April', + 4: 'May', + 5: 'June', + 6: 'July', + 7: 'August', + 8: 'September', + 9: 'October', + 10: 'November', + 11: 'December', +} + +export const formatDay = (days: number, start: number) => { + const date = new Date(start + days * DAY * 1000) + const month = date.getMonth() + const day = date.getDate() + + return `${MONTH_LABEL[month]} ${day}` +} + +export const DAY_IN_MS = 1000 * 60 * 60 * 24 + +export const isGreaterThan24HoursDiff = (timestamp1: number, timestamp2: number) => { + return Math.abs(timestamp1 - timestamp2) > DAY_IN_MS +} + +export const toDaysSinceStart = (timestamp: number, start: number) => { + return Math.floor((timestamp - start) / DAY_IN_MS) +} + +export const getCurrentDays = (startTime: number) => toDaysSinceStart(Date.now(), startTime) + +export const formatDate = (date: Date) => { + const options: Intl.DateTimeFormatOptions = { + year: 'numeric', + month: 'long', + day: 'numeric', + hour: 'numeric', + minute: 'numeric', + } + + return `${date.toLocaleString(undefined, options)}h` +} diff --git a/src/utils/gateway.ts b/src/utils/gateway.ts index 7ee54f15..4be377ed 100644 --- a/src/utils/gateway.ts +++ b/src/utils/gateway.ts @@ -16,3 +16,7 @@ export const getHashedExplorerUrl = ( return replaceTemplate(blockExplorerUriTemplate[param], { [param]: hash }) } + +export const toCursorParam = (limit: number, offset?: number) => { + return `cursor=limit%3D${limit}${offset ? `%26offset%3D${offset}` : ``}` +} diff --git a/src/utils/lock.ts b/src/utils/lock.ts new file mode 100644 index 00000000..21325cbf --- /dev/null +++ b/src/utils/lock.ts @@ -0,0 +1,96 @@ +import { CHAIN_SAFE_LOCKING_ADDRESS } from '@/config/constants' +import { BigNumber } from 'ethers' +import type { JsonRpcProvider } from '@ethersproject/providers' +import { formatUnits, Interface } from 'ethers/lib/utils' +import { LockingHistoryEntry, UnlockEvent, useLockHistory, WithdrawEvent } from '@/hooks/useLockHistory' +import { toDaysSinceStart } from './date' + +const safeLockingInterface = new Interface([ + 'function lock(uint96)', + 'function unlock(uint96)', + 'function getUser(address)', + 'function getUserTokenBalance(address)', + 'function getUnlock(address,uint32)', + 'function withdraw(uint32)', +]) + +export const isUnlockEvent = (event: LockingHistoryEntry): event is UnlockEvent => event.eventType === 'UNLOCKED' +export const isWithdrawEvent = (event: LockingHistoryEntry): event is WithdrawEvent => event.eventType === 'WITHDRAWN' + +export type LockHistory = { + day: number + // can be negative for unlocks + amount: number +} + +/** + * Return the relative lock history sorted by execution time + * + * @param data lockHistory + * @param startTime startTime as timestamp + * @returns sorted history of lock amount differences + */ +export const toRelativeLockHistory = (data: ReturnType, startTime: number): LockHistory[] => { + const sortedHistory = [...data].sort((d1, d2) => Date.parse(d1.executionDate) - Date.parse(d2.executionDate)) + return sortedHistory + .filter((entry) => entry.eventType !== 'WITHDRAWN') + .map((entry) => ({ + day: toDaysSinceStart(Date.parse(entry.executionDate), startTime), + amount: Number(formatUnits(BigNumber.from(entry.amount), 18)) * (entry.eventType === 'LOCKED' ? 1 : -1), + })) +} + +export const createLockTx = (chainId: string, amount: BigNumber) => { + return { + to: CHAIN_SAFE_LOCKING_ADDRESS[chainId], + data: safeLockingInterface.encodeFunctionData('lock', [amount]), + value: '0', + operation: 0, + } +} + +export const createUnlockTx = (chainId: string, amount: BigNumber) => { + return { + to: CHAIN_SAFE_LOCKING_ADDRESS[chainId], + data: safeLockingInterface.encodeFunctionData('unlock', [amount]), + value: '0', + operation: 0, + } +} + +/** + * Calls the `withdraw` function with 0 `maxUnlocks`. + * This will withdraw all available unlocks. + */ +export const createWithdrawTx = (chainId: string) => { + return { + to: CHAIN_SAFE_LOCKING_ADDRESS[chainId], + data: safeLockingInterface.encodeFunctionData('withdraw', [0]), + value: '0', + } +} + +/** + * Returns total tokens in the locking contract including locked and unlocked amounts. + * + * @param chainId + * @param safeAddress + * @param provider + * @returns total token balance + */ +export const fetchLockingContractBalance = async (chainId: string, safeAddress: string, provider: JsonRpcProvider) => { + const lockingAddress = CHAIN_SAFE_LOCKING_ADDRESS[chainId] + + if (!lockingAddress) { + return '0' + } + + try { + return await provider.call({ + to: lockingAddress, + data: safeLockingInterface.encodeFunctionData('getUserTokenBalance', [safeAddress]), + }) + } catch (err) { + throw Error(`Error fetching Safe Token balance in locking contract: ${err}`) + } +} diff --git a/src/utils/onboard.ts b/src/utils/onboard.ts index 2c795584..5747db3a 100644 --- a/src/utils/onboard.ts +++ b/src/utils/onboard.ts @@ -1,61 +1,19 @@ -import coinbaseModule from '@web3-onboard/coinbase' -import injectedWalletModule, { ProviderLabel } from '@web3-onboard/injected-wallets' -import keystoneModule from '@web3-onboard/keystone' -import ledgerModule from '@web3-onboard/ledger/dist/index' -import trezorModule from '@web3-onboard/trezor' import walletConnect from '@web3-onboard/walletconnect' -import tahoModule from '@web3-onboard/taho' -import type { RecommendedInjectedWallets, WalletInit, WalletModule } from '@web3-onboard/common/dist/types.d' +import type { WalletInit } from '@web3-onboard/common/dist/types.d' import { hexValue } from '@ethersproject/bytes' import Onboard from '@web3-onboard/core' import type { ChainInfo } from '@safe-global/safe-gateway-typescript-sdk' import manifestJson from '@/public/manifest.json' import { getRpcServiceUrl } from '@/utils/web3' -import { TREZOR_APP_URL, TREZOR_EMAIL, WC_BRIDGE, WC_PROJECT_ID } from '@/config/constants' +import { WC_PROJECT_ID } from '@/config/constants' export const enum WALLET_KEYS { - COINBASE = 'COINBASE', - INJECTED = 'INJECTED', - KEYSTONE = 'KEYSTONE', - LEDGER = 'LEDGER', - TAHO = 'TAHO', - TREZOR = 'TREZOR', - WALLETCONNECT = 'WALLETCONNECT', - WALLETCONNECT_V2 = 'WALLETCONNECT_V2', -} - -export const enum INJECTED_WALLET_KEYS { - METAMASK = 'METAMASK', + WALLETCONNECT = 'WalletConnect', } const CGW_NAMES: { [key in WALLET_KEYS]: string } = { - [WALLET_KEYS.COINBASE]: 'coinbase', - [WALLET_KEYS.INJECTED]: 'detectedwallet', - [WALLET_KEYS.KEYSTONE]: 'keystone', - [WALLET_KEYS.LEDGER]: 'ledger', - [WALLET_KEYS.TAHO]: 'tally', - [WALLET_KEYS.TREZOR]: 'trezor', - [WALLET_KEYS.WALLETCONNECT]: 'walletConnect', - [WALLET_KEYS.WALLETCONNECT_V2]: 'walletConnect_v2', -} - -const prefersDarkMode = (): boolean => { - return window?.matchMedia('(prefers-color-scheme: dark)')?.matches -} - -// We need to modify the module name as onboard dedupes modules with the same label and the WC v1 and v2 modules have the same -// @see https://github.com/blocknative/web3-onboard/blob/d399e0b76daf7b363d6a74b100b2c96ccb14536c/packages/core/src/store/actions.ts#L419 -// TODO: When removing this, also remove the associated CSS in `onboard.css` -export const WALLET_CONNECT_V1_MODULE_NAME = 'WalletConnect v1' -const walletConnectV1 = (): WalletInit => { - return (helpers) => { - const walletConnectModule = walletConnect({ version: 1, bridge: WC_BRIDGE })(helpers) as WalletModule - - walletConnectModule.label = WALLET_CONNECT_V1_MODULE_NAME - - return walletConnectModule - } + [WALLET_KEYS.WALLETCONNECT]: 'WalletConnect', } const walletConnectV2 = (chain: ChainInfo): WalletInit => { @@ -66,31 +24,20 @@ const walletConnectV2 = (chain: ChainInfo): WalletInit => { themeVariables: { '--wcm-z-index': '1302', }, - themeMode: prefersDarkMode() ? 'dark' : 'light', + themeMode: 'dark', }, requiredChains: [parseInt(chain.chainId)], }) } const WALLET_MODULES: { [key in WALLET_KEYS]: (chain: ChainInfo) => WalletInit } = { - [WALLET_KEYS.COINBASE]: () => coinbaseModule({ darkMode: prefersDarkMode() }), - [WALLET_KEYS.INJECTED]: () => injectedWalletModule(), - [WALLET_KEYS.KEYSTONE]: () => keystoneModule(), - [WALLET_KEYS.LEDGER]: () => ledgerModule(), - [WALLET_KEYS.TAHO]: () => tahoModule(), - [WALLET_KEYS.TREZOR]: () => trezorModule({ appUrl: TREZOR_APP_URL, email: TREZOR_EMAIL }), - [WALLET_KEYS.WALLETCONNECT]: () => walletConnectV1(), - [WALLET_KEYS.WALLETCONNECT_V2]: (chain) => walletConnectV2(chain), + [WALLET_KEYS.WALLETCONNECT]: (chain) => walletConnectV2(chain), } const getAllWallets = (chain: ChainInfo): WalletInit[] => { return Object.values(WALLET_MODULES).map((module) => module(chain)) } -const getRecommendedInjectedWallets = (): RecommendedInjectedWallets[] => { - return [{ name: ProviderLabel.MetaMask, url: 'https://metamask.io' }] -} - export const createOnboard = (chainConfigs: ChainInfo[], currentChain: ChainInfo) => { const chains = chainConfigs.map((cfg) => ({ id: hexValue(parseInt(cfg.chainId)), @@ -113,7 +60,6 @@ export const createOnboard = (chainConfigs: ChainInfo[], currentChain: ChainInfo name: manifestJson.name, icon: '/images/app-logo.svg', description: `Please select a wallet to connect to ${manifestJson.name}`, - recommendedInjectedWallets: getRecommendedInjectedWallets(), }, connect: { removeWhereIsMyWalletWarning: true, diff --git a/src/utils/safe-apps.ts b/src/utils/safe-apps.ts index bdc0e7ac..e570ddd0 100644 --- a/src/utils/safe-apps.ts +++ b/src/utils/safe-apps.ts @@ -5,7 +5,7 @@ export const getGovernanceAppSafeAppUrl = (chainId: string, address: string): st const shortName = CHAIN_SHORT_NAME[chainId] url.searchParams.append('safe', `${shortName}:${address}`) - url.searchParams.append('appUrl', DEPLOYMENT_URL) + url.searchParams.append('appUrl', `${DEPLOYMENT_URL}/governance`) return url.toString() } diff --git a/src/utils/safe-token.ts b/src/utils/safe-token.ts new file mode 100644 index 00000000..d6715ae9 --- /dev/null +++ b/src/utils/safe-token.ts @@ -0,0 +1,59 @@ +import { CHAIN_SAFE_LOCKING_ADDRESS, CHAIN_SAFE_TOKEN_ADDRESS } from '@/config/constants' +import { getSafeTokenInterface } from '@/services/contracts/SafeToken' +import type { JsonRpcProvider } from '@ethersproject/providers' +import { BigNumber } from 'ethers' + +const tokenInterface = getSafeTokenInterface() + +export const fetchTokenBalance = async ( + chainId: string, + safeAddress: string, + provider: JsonRpcProvider, +): Promise => { + const safeTokenAddress = CHAIN_SAFE_TOKEN_ADDRESS[chainId] + + if (!safeTokenAddress) { + return '0' + } + + try { + return await provider.call({ + to: safeTokenAddress, + data: tokenInterface.encodeFunctionData('balanceOf', [safeAddress]), + }) + } catch (err) { + throw Error(`Error fetching Safe Token balance: ${err}`) + } +} + +export const fetchTokenLockingAllowance = async ( + chainId: string, + safeAddress: string, + provider: JsonRpcProvider, +): Promise => { + const safeTokenAddress = CHAIN_SAFE_TOKEN_ADDRESS[chainId] + const safeLockingAddress = CHAIN_SAFE_LOCKING_ADDRESS[chainId] + + if (!safeTokenAddress) { + return '0' + } + + try { + return await provider.call({ + to: safeTokenAddress, + data: tokenInterface.encodeFunctionData('allowance', [safeAddress, safeLockingAddress]), + }) + } catch (err) { + throw Error(`Error fetching Safe Token balance: ${err}`) + } +} + +export const createApproveTx = (chainId: string, amount: BigNumber) => { + const safeTokenAddress = CHAIN_SAFE_TOKEN_ADDRESS[chainId] + const safeLockingAddress = CHAIN_SAFE_LOCKING_ADDRESS[chainId] + return { + to: safeTokenAddress, + value: '0', + data: tokenInterface.encodeFunctionData('approve', [safeLockingAddress, amount]), + } +} diff --git a/src/utils/wallet.ts b/src/utils/wallet.ts index 2f13b6b8..2717ef34 100644 --- a/src/utils/wallet.ts +++ b/src/utils/wallet.ts @@ -1,34 +1,7 @@ -import { ProviderLabel } from '@web3-onboard/injected-wallets' import { getSafeInfo } from '@safe-global/safe-gateway-typescript-sdk' -import { local } from '@/services/storage/local' -import { WALLET_CONNECT_V1_MODULE_NAME } from '@/utils/onboard' import type { ConnectedWallet } from '@/hooks/useWallet' -export const WalletNames = { - METAMASK: ProviderLabel.MetaMask, - WALLET_CONNECT: WALLET_CONNECT_V1_MODULE_NAME, -} - -export const isWalletUnlocked = async (walletName: string): Promise => { - if (typeof window === 'undefined') { - return false - } - - // Only MetaMask exposes a method to check if the wallet is unlocked - if (walletName === WalletNames.METAMASK) { - return window.ethereum?._metamask?.isUnlocked?.() || false - } - - // Wallet connect creates a localStorage entry when connected and removes it when disconnected - if (walletName === WalletNames.WALLET_CONNECT) { - const WC_SESSION_KEY = 'walletconnect' - return local.getItem(WC_SESSION_KEY) !== null - } - - return false -} - export const isSafe = async (wallet: ConnectedWallet): Promise => { try { return !!(await getSafeInfo(wallet.chainId, wallet.address)) diff --git a/src/utils/web3.ts b/src/utils/web3.ts index ba0a3bfa..c365804c 100644 --- a/src/utils/web3.ts +++ b/src/utils/web3.ts @@ -1,7 +1,7 @@ import { RPC_AUTHENTICATION } from '@safe-global/safe-gateway-typescript-sdk' import { Web3Provider } from '@ethersproject/providers' -import { SafeAppProvider } from '@gnosis.pm/safe-apps-provider' -import type { useSafeAppsSDK } from '@gnosis.pm/safe-apps-react-sdk' +import { SafeAppProvider } from '@safe-global/safe-apps-provider' +import type { useSafeAppsSDK } from '@safe-global/safe-apps-react-sdk' import type { EIP1193Provider } from '@web3-onboard/core' import type { RpcUri } from '@safe-global/safe-gateway-typescript-sdk' diff --git a/yarn.lock b/yarn.lock index cd05a15b..76de154c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7,6 +7,11 @@ resolved "https://registry.yarnpkg.com/@adobe/css-tools/-/css-tools-4.0.2.tgz#bd7d13543a186c3ff3eabb44bec2b22e8fb18ee0" integrity sha512-Fx6tYjk2wKUgLi8uMANZr8GNZx05u44ArIJldn9VxLvolzlJVgHbTUCbwhMd6bcYky178+WUSxPHO3DAtGLWpw== +"@adraffy/ens-normalize@1.10.0": + version "1.10.0" + resolved "https://registry.yarnpkg.com/@adraffy/ens-normalize/-/ens-normalize-1.10.0.tgz#d2a39395c587e092d77cbbc80acf956a54f38bf7" + integrity sha512-nA9XHtlAkYfJxY7bce8DcN7eKxWWCWkU+1GR9d+U6MbNpfwQp8TI7vqOsBsMcHoT4mBu2kypKoSKnghEzOOq5Q== + "@ampproject/remapping@^2.1.0": version "2.2.0" resolved "https://registry.yarnpkg.com/@ampproject/remapping/-/remapping-2.2.0.tgz#56c133824780de3174aed5ab6834f3026790154d" @@ -997,6 +1002,13 @@ dependencies: regenerator-runtime "^0.13.11" +"@babel/runtime@^7.23.9", "@babel/runtime@^7.24.0": + version "7.24.4" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.24.4.tgz#de795accd698007a66ba44add6cc86542aff1edd" + integrity sha512-dkxf7+hn8mFBwKjs9bvBlArzLVxVbS8usaPUDd5p2a9JCL9tB8OaOVN1isD4+Xyk4ns89/xeOmbQvgdK7IIVdA== + dependencies: + regenerator-runtime "^0.14.0" + "@babel/template@^7.18.10", "@babel/template@^7.20.7", "@babel/template@^7.3.3": version "7.20.7" resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.20.7.tgz#a15090c2839a83b02aa996c0b4994005841fd5a8" @@ -1088,6 +1100,17 @@ "@emotion/weak-memoize" "^0.3.0" stylis "4.1.3" +"@emotion/cache@^11.11.0": + version "11.11.0" + resolved "https://registry.yarnpkg.com/@emotion/cache/-/cache-11.11.0.tgz#809b33ee6b1cb1a625fef7a45bc568ccd9b8f3ff" + integrity sha512-P34z9ssTCBi3e9EI1ZsWpNHcfY1r09ZO0rZbRO2ob3ZQMnFI35jB536qoXbkdesr5EUhYi22anuEJuyxifaqAQ== + dependencies: + "@emotion/memoize" "^0.8.1" + "@emotion/sheet" "^1.2.2" + "@emotion/utils" "^1.2.1" + "@emotion/weak-memoize" "^0.3.1" + stylis "4.2.0" + "@emotion/hash@^0.9.0": version "0.9.0" resolved "https://registry.yarnpkg.com/@emotion/hash/-/hash-0.9.0.tgz#c5153d50401ee3c027a57a177bc269b16d889cb7" @@ -1105,6 +1128,11 @@ resolved "https://registry.yarnpkg.com/@emotion/memoize/-/memoize-0.8.0.tgz#f580f9beb67176fa57aae70b08ed510e1b18980f" integrity sha512-G/YwXTkv7Den9mXDO7AhLWkE3q+I92B+VqAE+dYG4NGPaHZGvt3G8Q0p9vmE+sq7rTGphUbAvmQ9YpbfMQGGlA== +"@emotion/memoize@^0.8.1": + version "0.8.1" + resolved "https://registry.yarnpkg.com/@emotion/memoize/-/memoize-0.8.1.tgz#c1ddb040429c6d21d38cc945fe75c818cfb68e17" + integrity sha512-W2P2c/VRW1/1tLox0mVUalvnWXxavmv/Oum2aPsRcoDJuob75FC3Y8FbpfLwUegRcxINtGUMPq0tFCvYNTBXNA== + "@emotion/react@^11.10.5": version "11.10.5" resolved "https://registry.yarnpkg.com/@emotion/react/-/react-11.10.5.tgz#95fff612a5de1efa9c0d535384d3cfa115fe175d" @@ -1145,6 +1173,11 @@ resolved "https://registry.yarnpkg.com/@emotion/sheet/-/sheet-1.2.1.tgz#0767e0305230e894897cadb6c8df2c51e61a6c2c" integrity sha512-zxRBwl93sHMsOj4zs+OslQKg/uhF38MB+OMKoCrVuS0nyTkqnau+BM3WGEoOptg9Oz45T/aIGs1qbVAsEFo3nA== +"@emotion/sheet@^1.2.2": + version "1.2.2" + resolved "https://registry.yarnpkg.com/@emotion/sheet/-/sheet-1.2.2.tgz#d58e788ee27267a14342303e1abb3d508b6d0fec" + integrity sha512-0QBtGvaqtWi+nx6doRwDdBIzhNdZrXUppvTM4dtZZWEGTXL/XE/yJxLMGlDT1Gt+UHH5IX1n+jkXyytE/av7OA== + "@emotion/styled@^11.10.5": version "11.10.5" resolved "https://registry.yarnpkg.com/@emotion/styled/-/styled-11.10.5.tgz#1fe7bf941b0909802cb826457e362444e7e96a79" @@ -1172,11 +1205,21 @@ resolved "https://registry.yarnpkg.com/@emotion/utils/-/utils-1.2.0.tgz#9716eaccbc6b5ded2ea5a90d65562609aab0f561" integrity sha512-sn3WH53Kzpw8oQ5mgMmIzzyAaH2ZqFEbozVVBSYp538E06OSE6ytOp7pRAjNQR+Q/orwqdQYJSe2m3hCOeznkw== +"@emotion/utils@^1.2.1": + version "1.2.1" + resolved "https://registry.yarnpkg.com/@emotion/utils/-/utils-1.2.1.tgz#bbab58465738d31ae4cb3dbb6fc00a5991f755e4" + integrity sha512-Y2tGf3I+XVnajdItskUCn6LX+VUDmP6lTL4fcqsXAv43dnlbZiuW4MWQW38rW/BVWSE7Q/7+XQocmpnRYILUmg== + "@emotion/weak-memoize@^0.3.0": version "0.3.0" resolved "https://registry.yarnpkg.com/@emotion/weak-memoize/-/weak-memoize-0.3.0.tgz#ea89004119dc42db2e1dba0f97d553f7372f6fcb" integrity sha512-AHPmaAx+RYfZz0eYu6Gviiagpmiyw98ySSlQvCUhVGDRtDFe4DBS0x1bSjdF3gqUDYOczB+yYvBTtEylYSdRhg== +"@emotion/weak-memoize@^0.3.1": + version "0.3.1" + resolved "https://registry.yarnpkg.com/@emotion/weak-memoize/-/weak-memoize-0.3.1.tgz#d0fce5d07b0620caa282b5131c297bb60f9d87e6" + integrity sha512-EsBwpc7hBUJWAsNPBmJy4hxWx12v6bshQsldrVmjxJoc3isbxhOrF2IcCpaXxfvq03NwkI7sbsOLXbYuqF/8Ww== + "@eslint/eslintrc@^1.4.1": version "1.4.1" resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-1.4.1.tgz#af58772019a2d271b7e2d4c23ff4ddcba3ccfb3e" @@ -1964,6 +2007,33 @@ "@ethersproject/properties" "^5.7.0" "@ethersproject/strings" "^5.7.0" +"@floating-ui/core@^1.0.0": + version "1.6.0" + resolved "https://registry.yarnpkg.com/@floating-ui/core/-/core-1.6.0.tgz#fa41b87812a16bf123122bf945946bae3fdf7fc1" + integrity sha512-PcF++MykgmTj3CIyOQbKA/hDzOAiqI3mhuoN44WRCopIs1sgoDoU4oty4Jtqaj/y3oDU6fnVSm4QG0a3t5i0+g== + dependencies: + "@floating-ui/utils" "^0.2.1" + +"@floating-ui/dom@^1.6.1": + version "1.6.3" + resolved "https://registry.yarnpkg.com/@floating-ui/dom/-/dom-1.6.3.tgz#954e46c1dd3ad48e49db9ada7218b0985cee75ef" + integrity sha512-RnDthu3mzPlQ31Ss/BTwQ1zjzIhr3lk1gZB1OC56h/1vEtaXkESrOqL5fQVMfXpwGtRwX+YsZBdyHtJMQnkArw== + dependencies: + "@floating-ui/core" "^1.0.0" + "@floating-ui/utils" "^0.2.0" + +"@floating-ui/react-dom@^2.0.8": + version "2.0.8" + resolved "https://registry.yarnpkg.com/@floating-ui/react-dom/-/react-dom-2.0.8.tgz#afc24f9756d1b433e1fe0d047c24bd4d9cefaa5d" + integrity sha512-HOdqOt3R3OGeTKidaLvJKcgg75S6tibQ3Tif4eyd91QnIJWr0NLvoXFpJA/j8HqkFSL68GDca9AuyWEHlhyClw== + dependencies: + "@floating-ui/dom" "^1.6.1" + +"@floating-ui/utils@^0.2.0", "@floating-ui/utils@^0.2.1": + version "0.2.1" + resolved "https://registry.yarnpkg.com/@floating-ui/utils/-/utils-0.2.1.tgz#16308cea045f0fc777b6ff20a9f25474dd8293d2" + integrity sha512-9TANp6GPoMtYzQdt54kfAyMmz1+osLlXdg2ENroU7zzrtflTLrrC/lgrIfaSe+Wu0b89GKccT7vxXA0MoAIO+Q== + "@formatjs/ecma402-abstract@1.11.4": version "1.11.4" resolved "https://registry.yarnpkg.com/@formatjs/ecma402-abstract/-/ecma402-abstract-1.11.4.tgz#b962dfc4ae84361f9f08fbce411b4e4340930eda" @@ -2003,36 +2073,6 @@ dependencies: tslib "^2.1.0" -"@gnosis.pm/safe-apps-provider@^0.15.1": - version "0.15.1" - resolved "https://registry.yarnpkg.com/@gnosis.pm/safe-apps-provider/-/safe-apps-provider-0.15.1.tgz#1d070a7de87311190d09bb8e467137428c475d63" - integrity sha512-jVU0jpXf30HFdLHSHkmscIm04BYSwfoddjcHI4uPolP+u4F+tHRgfah1xo4XNRSCDqs4GTq38d7YAipGTj0CLA== - dependencies: - "@gnosis.pm/safe-apps-sdk" "7.8.0" - events "^3.3.0" - -"@gnosis.pm/safe-apps-react-sdk@^4.6.2": - version "4.6.2" - resolved "https://registry.yarnpkg.com/@gnosis.pm/safe-apps-react-sdk/-/safe-apps-react-sdk-4.6.2.tgz#205908311c685b378338f82da261f06f10336532" - integrity sha512-93r9aDw9HzxC/xMmvnW4j5XswKxaAAFizCCZhA+xfQ6EBxH0nnBUmDq3kbxAWofvNgUKaSVgY/iHMvwkDBrllQ== - dependencies: - "@gnosis.pm/safe-apps-sdk" "7.8.0" - -"@gnosis.pm/safe-apps-sdk@7.8.0": - version "7.8.0" - resolved "https://registry.yarnpkg.com/@gnosis.pm/safe-apps-sdk/-/safe-apps-sdk-7.8.0.tgz#295ab9d563b94208e3042495cdba787fa035c71e" - integrity sha512-kO8fJi1ebiKN9qH1NdDToVBuDQQ0U9NkL467U+84LtNTx5PzUJIu6O7tb4nZD24e/OItinf5W8GDWhhfZeiqOA== - dependencies: - "@gnosis.pm/safe-react-gateway-sdk" "^3.1.3" - ethers "^5.6.8" - -"@gnosis.pm/safe-react-gateway-sdk@^3.1.3": - version "3.5.2" - resolved "https://registry.yarnpkg.com/@gnosis.pm/safe-react-gateway-sdk/-/safe-react-gateway-sdk-3.5.2.tgz#088b0d5db2eb7524ff4141795ecf948adc40c97c" - integrity sha512-6P2uJMnhHcJeErd/t13ChH6sda+vUIOqcrcUDKyWCNXpcmMniPcZzkQxZ8cYz186gQFbslsHSjQ6twnh4yhXUw== - dependencies: - cross-fetch "^3.1.5" - "@hapi/hoek@^9.0.0": version "9.3.0" resolved "https://registry.yarnpkg.com/@hapi/hoek/-/hoek-9.3.0.tgz#8368869dcb735be2e7f5cb7647de78e167a251fb" @@ -2630,17 +2670,30 @@ prop-types "^15.8.1" react-is "^18.2.0" +"@mui/base@^5.0.0-beta.40": + version "5.0.0-beta.40" + resolved "https://registry.yarnpkg.com/@mui/base/-/base-5.0.0-beta.40.tgz#1f8a782f1fbf3f84a961e954c8176b187de3dae2" + integrity sha512-I/lGHztkCzvwlXpjD2+SNmvNQvB4227xBXhISPjEaJUXGImOQ9f3D2Yj/T3KasSI/h0MLWy74X0J6clhPmsRbQ== + dependencies: + "@babel/runtime" "^7.23.9" + "@floating-ui/react-dom" "^2.0.8" + "@mui/types" "^7.2.14" + "@mui/utils" "^5.15.14" + "@popperjs/core" "^2.11.8" + clsx "^2.1.0" + prop-types "^15.8.1" + "@mui/core-downloads-tracker@^5.11.6": version "5.11.6" resolved "https://registry.yarnpkg.com/@mui/core-downloads-tracker/-/core-downloads-tracker-5.11.6.tgz#79a60c0d95a08859cccd62a8d9a5336ef477a840" integrity sha512-lbD3qdafBOf2dlqKhOcVRxaPAujX+9UlPC6v8iMugMeAXe0TCgU3QbGXY3zrJsu6ex64WYDpH4y1+WOOBmWMuA== -"@mui/icons-material@^5.11.0": - version "5.11.0" - resolved "https://registry.yarnpkg.com/@mui/icons-material/-/icons-material-5.11.0.tgz#9ea6949278b2266d2683866069cd43009eaf6464" - integrity sha512-I2LaOKqO8a0xcLGtIozC9xoXjZAto5G5gh0FYUMAlbsIHNHIjn4Xrw9rvjY20vZonyiGrZNMAlAXYkY6JvhF6A== +"@mui/icons-material@^5.15.15": + version "5.15.15" + resolved "https://registry.yarnpkg.com/@mui/icons-material/-/icons-material-5.15.15.tgz#84ce08225a531d9f5dc5132009d91164b456a0ae" + integrity sha512-kkeU/pe+hABcYDH6Uqy8RmIsr2S/y5bP2rp+Gat4CcRjCcVne6KudS1NrZQhUCRysrTDCAhcbcf9gt+/+pGO2g== dependencies: - "@babel/runtime" "^7.20.6" + "@babel/runtime" "^7.23.9" "@mui/material@^5.11.5": version "5.11.6" @@ -2669,6 +2722,15 @@ "@mui/utils" "^5.11.2" prop-types "^15.8.1" +"@mui/private-theming@^5.15.14": + version "5.15.14" + resolved "https://registry.yarnpkg.com/@mui/private-theming/-/private-theming-5.15.14.tgz#edd9a82948ed01586a01c842eb89f0e3f68970ee" + integrity sha512-UH0EiZckOWcxiXLX3Jbb0K7rC8mxTr9L9l6QhOZxYc4r8FHUkefltV9VDGLrzCaWh30SQiJvAEd7djX3XXY6Xw== + dependencies: + "@babel/runtime" "^7.23.9" + "@mui/utils" "^5.15.14" + prop-types "^15.8.1" + "@mui/styled-engine@^5.11.0": version "5.11.0" resolved "https://registry.yarnpkg.com/@mui/styled-engine/-/styled-engine-5.11.0.tgz#79afb30c612c7807c4b77602cf258526d3997c7b" @@ -2679,6 +2741,16 @@ csstype "^3.1.1" prop-types "^15.8.1" +"@mui/styled-engine@^5.15.14": + version "5.15.14" + resolved "https://registry.yarnpkg.com/@mui/styled-engine/-/styled-engine-5.15.14.tgz#168b154c4327fa4ccc1933a498331d53f61c0de2" + integrity sha512-RILkuVD8gY6PvjZjqnWhz8fu68dVkqhM5+jYWfB5yhlSQKg+2rHkmEwm75XIeAqI3qwOndK6zELK5H6Zxn4NHw== + dependencies: + "@babel/runtime" "^7.23.9" + "@emotion/cache" "^11.11.0" + csstype "^3.1.3" + prop-types "^15.8.1" + "@mui/system@^5.11.5": version "5.11.5" resolved "https://registry.yarnpkg.com/@mui/system/-/system-5.11.5.tgz#c880199634708c866063396f88d3fdd4c1dfcb48" @@ -2693,6 +2765,25 @@ csstype "^3.1.1" prop-types "^15.8.1" +"@mui/system@^5.15.14": + version "5.15.15" + resolved "https://registry.yarnpkg.com/@mui/system/-/system-5.15.15.tgz#658771b200ce3c4a0f28e58169f02e5e718d1c53" + integrity sha512-aulox6N1dnu5PABsfxVGOZffDVmlxPOVgj56HrUnJE8MCSh8lOvvkd47cebIVQQYAjpwieXQXiDPj5pwM40jTQ== + dependencies: + "@babel/runtime" "^7.23.9" + "@mui/private-theming" "^5.15.14" + "@mui/styled-engine" "^5.15.14" + "@mui/types" "^7.2.14" + "@mui/utils" "^5.15.14" + clsx "^2.1.0" + csstype "^3.1.3" + prop-types "^15.8.1" + +"@mui/types@^7.2.14": + version "7.2.14" + resolved "https://registry.yarnpkg.com/@mui/types/-/types-7.2.14.tgz#8a02ac129b70f3d82f2f9b76ded2c8d48e3fc8c9" + integrity sha512-MZsBZ4q4HfzBsywtXgM1Ksj6HDThtiwmOKUXH1pKYISI9gAVXCNHNpo7TlGoGrBaYWZTdNoirIN7JsQcQUjmQQ== + "@mui/types@^7.2.3": version "7.2.3" resolved "https://registry.yarnpkg.com/@mui/types/-/types-7.2.3.tgz#06faae1c0e2f3a31c86af6f28b3a4a42143670b9" @@ -2709,6 +2800,30 @@ prop-types "^15.8.1" react-is "^18.2.0" +"@mui/utils@^5.15.14": + version "5.15.14" + resolved "https://registry.yarnpkg.com/@mui/utils/-/utils-5.15.14.tgz#e414d7efd5db00bfdc875273a40c0a89112ade3a" + integrity sha512-0lF/7Hh/ezDv5X7Pry6enMsbYyGKjADzvHyo3Qrc/SSlTsQ1VkbDMbH0m2t3OR5iIVLwMoxwM7yGd+6FCMtTFA== + dependencies: + "@babel/runtime" "^7.23.9" + "@types/prop-types" "^15.7.11" + prop-types "^15.8.1" + react-is "^18.2.0" + +"@mui/x-date-pickers@^7.1.1": + version "7.1.1" + resolved "https://registry.yarnpkg.com/@mui/x-date-pickers/-/x-date-pickers-7.1.1.tgz#13523f3d1cc9df89def9a6f90b19ae2d8d5d13ea" + integrity sha512-doSaoNfYR4nAXSN2mz5MwktYUmPt37jZ8/t5QrPgFtEFc3KWZoBps0YEcno5qUynY1ISpOjvnVr18zqszzG+RA== + dependencies: + "@babel/runtime" "^7.24.0" + "@mui/base" "^5.0.0-beta.40" + "@mui/system" "^5.15.14" + "@mui/utils" "^5.15.14" + "@types/react-transition-group" "^4.4.10" + clsx "^2.1.0" + prop-types "^15.8.1" + react-transition-group "^4.4.5" + "@next/env@13.1.2": version "13.1.2" resolved "https://registry.yarnpkg.com/@next/env/-/env-13.1.2.tgz#4f13e3e9d44bb17fdc1d4543827459097035f10f" @@ -2799,6 +2914,13 @@ jsbi "^3.1.5" sha.js "^2.4.11" +"@noble/curves@1.2.0", "@noble/curves@~1.2.0": + version "1.2.0" + resolved "https://registry.yarnpkg.com/@noble/curves/-/curves-1.2.0.tgz#92d7e12e4e49b23105a2555c6984d41733d65c35" + integrity sha512-oYclrNgRaM9SsBUBVbb8M6DTV7ZHRTKugureoYEncY5c65HOmRzvSiTE3y5CYaPYJA/GVkrhXEoF0M3Ya9PMnw== + dependencies: + "@noble/hashes" "1.3.2" + "@noble/ed25519@^1.7.0": version "1.7.1" resolved "https://registry.yarnpkg.com/@noble/ed25519/-/ed25519-1.7.1.tgz#6899660f6fbb97798a6fbd227227c4589a454724" @@ -2809,6 +2931,11 @@ resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-1.1.2.tgz#e9e035b9b166ca0af657a7848eb2718f0f22f183" integrity sha512-KYRCASVTv6aeUi1tsF8/vpyR7zpfs3FUzy2Jqm+MU+LmUKhQ0y2FpfwqkCcxSg2ua4GALJd8k2R76WxwZGbQpA== +"@noble/hashes@1.3.2": + version "1.3.2" + resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-1.3.2.tgz#6f26dbc8fbc7205873ce3cee2f690eba0d421b39" + integrity sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ== + "@noble/hashes@^1.1.2", "@noble/hashes@~1.1.1": version "1.1.5" resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-1.1.5.tgz#1a0377f3b9020efe2fae03290bd2a12140c95c11" @@ -2819,6 +2946,11 @@ resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-1.3.1.tgz#8831ef002114670c603c458ab8b11328406953a9" integrity sha512-EbqwksQwz9xDRGfDST86whPBgM65E0OH/pCgqW0GBVzO22bNE+NuIbeTb714+IfSjU3aRk47EUvXIb5bTsenKA== +"@noble/hashes@~1.3.0", "@noble/hashes@~1.3.2": + version "1.3.3" + resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-1.3.3.tgz#39908da56a4adc270147bb07968bf3b16cfe1699" + integrity sha512-V7/fPHgl+jsVPXqqeOzT8egNj2iBIVt+ECeMMG8TdcnTikP3oaBtUVqpT/gYCR68aEBJSF+XbYUxStjbFMqIIA== + "@noble/secp256k1@1.6.3", "@noble/secp256k1@~1.6.0": version "1.6.3" resolved "https://registry.yarnpkg.com/@noble/secp256k1/-/secp256k1-1.6.3.tgz#7eed12d9f4404b416999d0c87686836c4c5c9b94" @@ -3065,6 +3197,11 @@ resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.11.6.tgz#cee20bd55e68a1720bdab363ecf0c821ded4cd45" integrity sha512-50/17A98tWUfQ176raKiOGXuYpLyyVMkxxG6oylzL3BPOlA6ADGdK7EYunSa4I064xerltq9TGXs8HmOk5E+vw== +"@popperjs/core@^2.11.8": + version "2.11.8" + resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.11.8.tgz#6b79032e760a0899cd4204710beede972a3a185f" + integrity sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A== + "@protobufjs/aspromise@^1.1.1", "@protobufjs/aspromise@^1.1.2": version "1.1.2" resolved "https://registry.yarnpkg.com/@protobufjs/aspromise/-/aspromise-1.1.2.tgz#9b8b0cc663d669a7d8f6f5d0893a14d348f30fbf" @@ -3123,6 +3260,34 @@ resolved "https://registry.yarnpkg.com/@rushstack/eslint-patch/-/eslint-patch-1.2.0.tgz#8be36a1f66f3265389e90b5f9c9962146758f728" integrity sha512-sXo/qW2/pAcmT43VoRKOJbDOfV3cYpq3szSVfIThQXNt+E4DfKj361vaAt3c88U5tPUxzEswam7GW48PJqtKAg== +"@safe-global/safe-apps-provider@^0.18.2": + version "0.18.2" + resolved "https://registry.yarnpkg.com/@safe-global/safe-apps-provider/-/safe-apps-provider-0.18.2.tgz#336f3f4bb6ebbad9354e6551687491efc73991bc" + integrity sha512-yHHAcppwE7aIUWEeZiYAClQzZCdP5l0Kbd0CBlhKAsTcqZnx4Gh3G3G3frY5LlWcGzp9qmQ5jv+J1GBpaZLDgw== + dependencies: + "@safe-global/safe-apps-sdk" "^9.0.0" + events "^3.3.0" + +"@safe-global/safe-apps-react-sdk@^4.7.1": + version "4.7.1" + resolved "https://registry.yarnpkg.com/@safe-global/safe-apps-react-sdk/-/safe-apps-react-sdk-4.7.1.tgz#3df442496edee9778ef7217ee78a031cac0e1701" + integrity sha512-sbVroIUxYy9XOzN7769wCTR2ytk6LMp8ECmFlc+d23/7EHOuX5Yw8Si+tf5SYbYhS9ygdSsMcVRQeNvKOhZnEw== + dependencies: + "@safe-global/safe-apps-sdk" "^9.0.0" + +"@safe-global/safe-apps-sdk@^9.0.0": + version "9.0.0" + resolved "https://registry.yarnpkg.com/@safe-global/safe-apps-sdk/-/safe-apps-sdk-9.0.0.tgz#56635663f5a73773c5929d9c45ffea2b75dab69b" + integrity sha512-fEqmQBU3JqTjORSl3XYrcaxdxkUqeeM39qsQjqCzzTHioN8DEfg3JCLq6EBoXzcKTVOYi8SPzLV7KJccdDw+4w== + dependencies: + "@safe-global/safe-gateway-typescript-sdk" "^3.5.3" + viem "^1.6.0" + +"@safe-global/safe-gateway-typescript-sdk@^3.5.3": + version "3.19.0" + resolved "https://registry.yarnpkg.com/@safe-global/safe-gateway-typescript-sdk/-/safe-gateway-typescript-sdk-3.19.0.tgz#18637c205c83bfc0a6be5fddbf202d6bb4927302" + integrity sha512-TRlP05KY6t3wjLJ74FiirWlEt3xTclnUQM2YdYto1jx5G1o0meMnugIUZXhzm7Bs3rDEDNhz/aDf2KMSZtoCFg== + "@safe-global/safe-gateway-typescript-sdk@^3.5.6": version "3.7.0" resolved "https://registry.yarnpkg.com/@safe-global/safe-gateway-typescript-sdk/-/safe-gateway-typescript-sdk-3.7.0.tgz#2af52f1bc73759b1b6a549fed598781c8c5fce72" @@ -3135,6 +3300,11 @@ resolved "https://registry.yarnpkg.com/@scure/base/-/base-1.1.1.tgz#ebb651ee52ff84f420097055f4bf46cfba403938" integrity sha512-ZxOhsSyxYwLJj3pLZCefNitxsj093tb2vq90mp2txoYeBqbcjDjqFhyM8eUjq/uFm6zJ+mUuqxlS2FkuSY1MTA== +"@scure/base@~1.1.2": + version "1.1.6" + resolved "https://registry.yarnpkg.com/@scure/base/-/base-1.1.6.tgz#8ce5d304b436e4c84f896e0550c83e4d88cb917d" + integrity sha512-ok9AWwhcgYuGG3Zfhyqg+zwl+Wn5uE+dwC0NV/2qQkx4dABbb/bx96vWu8NSj+BNjjSjno+JRYRjle1jV08k3g== + "@scure/bip32@1.1.0": version "1.1.0" resolved "https://registry.yarnpkg.com/@scure/bip32/-/bip32-1.1.0.tgz#dea45875e7fbc720c2b4560325f1cf5d2246d95b" @@ -3144,6 +3314,15 @@ "@noble/secp256k1" "~1.6.0" "@scure/base" "~1.1.0" +"@scure/bip32@1.3.2": + version "1.3.2" + resolved "https://registry.yarnpkg.com/@scure/bip32/-/bip32-1.3.2.tgz#90e78c027d5e30f0b22c1f8d50ff12f3fb7559f8" + integrity sha512-N1ZhksgwD3OBlwTv3R6KFEcPojl/W4ElJOeCZdi+vuI5QmTFwLq3OFf2zd2ROpKvxFdgZ6hUpb0dx9bVNEwYCA== + dependencies: + "@noble/curves" "~1.2.0" + "@noble/hashes" "~1.3.2" + "@scure/base" "~1.1.2" + "@scure/bip39@1.1.0": version "1.1.0" resolved "https://registry.yarnpkg.com/@scure/bip39/-/bip39-1.1.0.tgz#92f11d095bae025f166bef3defcc5bf4945d419a" @@ -3152,6 +3331,14 @@ "@noble/hashes" "~1.1.1" "@scure/base" "~1.1.0" +"@scure/bip39@1.2.1": + version "1.2.1" + resolved "https://registry.yarnpkg.com/@scure/bip39/-/bip39-1.2.1.tgz#5cee8978656b272a917b7871c981e0541ad6ac2a" + integrity sha512-Z3/Fsz1yr904dduJD0NpiyRHhRYHdcnyh73FZWiV+/qhWi83wNJ3NWolYqCEN+ZWsUz2TWwajJggcRE9r1zUYg== + dependencies: + "@noble/hashes" "~1.3.0" + "@scure/base" "~1.1.0" + "@sentry/core@5.30.0": version "5.30.0" resolved "https://registry.yarnpkg.com/@sentry/core/-/core-5.30.0.tgz#6b203664f69e75106ee8b5a2fe1d717379b331f3" @@ -3782,6 +3969,57 @@ dependencies: "@types/node" "*" +"@types/d3-array@^3.0.3": + version "3.2.1" + resolved "https://registry.yarnpkg.com/@types/d3-array/-/d3-array-3.2.1.tgz#1f6658e3d2006c4fceac53fde464166859f8b8c5" + integrity sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg== + +"@types/d3-color@*": + version "3.1.3" + resolved "https://registry.yarnpkg.com/@types/d3-color/-/d3-color-3.1.3.tgz#368c961a18de721da8200e80bf3943fb53136af2" + integrity sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A== + +"@types/d3-ease@^3.0.0": + version "3.0.2" + resolved "https://registry.yarnpkg.com/@types/d3-ease/-/d3-ease-3.0.2.tgz#e28db1bfbfa617076f7770dd1d9a48eaa3b6c51b" + integrity sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA== + +"@types/d3-interpolate@^3.0.1": + version "3.0.4" + resolved "https://registry.yarnpkg.com/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz#412b90e84870285f2ff8a846c6eb60344f12a41c" + integrity sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA== + dependencies: + "@types/d3-color" "*" + +"@types/d3-path@*": + version "3.1.0" + resolved "https://registry.yarnpkg.com/@types/d3-path/-/d3-path-3.1.0.tgz#2b907adce762a78e98828f0b438eaca339ae410a" + integrity sha512-P2dlU/q51fkOc/Gfl3Ul9kicV7l+ra934qBFXCFhrZMOL6du1TM0pm1ThYvENukyOn5h9v+yMJ9Fn5JK4QozrQ== + +"@types/d3-scale@^4.0.2": + version "4.0.8" + resolved "https://registry.yarnpkg.com/@types/d3-scale/-/d3-scale-4.0.8.tgz#d409b5f9dcf63074464bf8ddfb8ee5a1f95945bb" + integrity sha512-gkK1VVTr5iNiYJ7vWDI+yUFFlszhNMtVeneJ6lUTKPjprsvLLI9/tgEGiXJOnlINJA8FyA88gfnQsHbybVZrYQ== + dependencies: + "@types/d3-time" "*" + +"@types/d3-shape@^3.1.0": + version "3.1.6" + resolved "https://registry.yarnpkg.com/@types/d3-shape/-/d3-shape-3.1.6.tgz#65d40d5a548f0a023821773e39012805e6e31a72" + integrity sha512-5KKk5aKGu2I+O6SONMYSNflgiP0WfZIQvVUMan50wHsLG1G94JlxEVnCpQARfTtzytuY0p/9PXXZb3I7giofIA== + dependencies: + "@types/d3-path" "*" + +"@types/d3-time@*", "@types/d3-time@^3.0.0": + version "3.0.3" + resolved "https://registry.yarnpkg.com/@types/d3-time/-/d3-time-3.0.3.tgz#3c186bbd9d12b9d84253b6be6487ca56b54f88be" + integrity sha512-2p6olUZ4w3s+07q3Tm2dbiMZy5pCDfYwtLXXHUnVzXgQlZ/OyPtUz6OL382BkOuGlLXqfT+wqv8Fw2v8/0geBw== + +"@types/d3-timer@^3.0.0": + version "3.0.2" + resolved "https://registry.yarnpkg.com/@types/d3-timer/-/d3-timer-3.0.2.tgz#70bbda77dc23aa727413e22e214afa3f0e852f70" + integrity sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw== + "@types/graceful-fs@^4.1.3": version "4.1.6" resolved "https://registry.yarnpkg.com/@types/graceful-fs/-/graceful-fs-4.1.6.tgz#e14b2576a1c25026b7f02ede1de3b84c3a1efeae" @@ -3887,6 +4125,11 @@ resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.5.tgz#5f19d2b85a98e9558036f6a3cacc8819420f05cf" integrity sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w== +"@types/prop-types@^15.7.11": + version "15.7.12" + resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.12.tgz#12bb1e2be27293c1406acb6af1c3f3a1481d98c6" + integrity sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q== + "@types/react-dom@18.0.10", "@types/react-dom@^18.0.0": version "18.0.10" resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-18.0.10.tgz#3b66dec56aa0f16a6cc26da9e9ca96c35c0b4352" @@ -3901,6 +4144,13 @@ dependencies: "@types/react" "*" +"@types/react-transition-group@^4.4.10": + version "4.4.10" + resolved "https://registry.yarnpkg.com/@types/react-transition-group/-/react-transition-group-4.4.10.tgz#6ee71127bdab1f18f11ad8fb3322c6da27c327ac" + integrity sha512-hT/+s0VQs2ojCX823m60m5f0sL5idt9SO6Tj6Dg+rdphGPIeJbJ6CxvBYkgkGKrYeDjvIpKTR38UzmtHJOGW3Q== + dependencies: + "@types/react" "*" + "@types/react-transition-group@^4.4.5": version "4.4.5" resolved "https://registry.yarnpkg.com/@types/react-transition-group/-/react-transition-group-4.4.5.tgz#aae20dcf773c5aa275d5b9f7cdbca638abc5e416" @@ -4605,6 +4855,11 @@ abab@^2.0.6: resolved "https://registry.yarnpkg.com/abab/-/abab-2.0.6.tgz#41b80f2c871d19686216b82309231cfd3cb3d291" integrity sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA== +abitype@0.9.8: + version "0.9.8" + resolved "https://registry.yarnpkg.com/abitype/-/abitype-0.9.8.tgz#1f120b6b717459deafd213dfbf3a3dd1bf10ae8c" + integrity sha512-puLifILdm+8sjyss4S+fsUN09obiT1g2YW6CtcQF+QDzxR0euzgEB29MZujC6zMk2a6SVmtttq1fc6+YFA7WYQ== + abort-controller@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/abort-controller/-/abort-controller-3.0.0.tgz#eaf54d53b62bae4138e809ca225c8439a6efb392" @@ -5546,6 +5801,11 @@ clsx@^1.1.0, clsx@^1.2.1: resolved "https://registry.yarnpkg.com/clsx/-/clsx-1.2.1.tgz#0ddc4a20a549b59c93a4116bb26f5294ca17dc12" integrity sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg== +clsx@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/clsx/-/clsx-2.1.0.tgz#e851283bcb5c80ee7608db18487433f7b23f77cb" + integrity sha512-m3iNNWpd9rl3jvvcBnu70ylMdrXt8Vlq4HYadnU5fwcOtvkSQWPmj7amUcDT2qYI7risszBjI5AUIUox9D16pg== + co@^4.6.0: version "4.6.0" resolved "https://registry.yarnpkg.com/co/-/co-4.6.0.tgz#6ea6bdf3d853ae54ccb8e47bfa0bf3f9031fb184" @@ -5803,6 +6063,87 @@ csstype@^3.0.2, csstype@^3.1.1: resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.1.1.tgz#841b532c45c758ee546a11d5bd7b7b473c8c30b9" integrity sha512-DJR/VvkAvSZW9bTouZue2sSxDwdTN92uHjqeKVm+0dAqdfNykRzQ95tay8aXMBAAPpUiq4Qcug2L7neoRh2Egw== +csstype@^3.1.3: + version "3.1.3" + resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.1.3.tgz#d80ff294d114fb0e6ac500fbf85b60137d7eff81" + integrity sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw== + +"d3-array@2 - 3", "d3-array@2.10.0 - 3", d3-array@^3.1.6: + version "3.2.4" + resolved "https://registry.yarnpkg.com/d3-array/-/d3-array-3.2.4.tgz#15fec33b237f97ac5d7c986dc77da273a8ed0bb5" + integrity sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg== + dependencies: + internmap "1 - 2" + +"d3-color@1 - 3": + version "3.1.0" + resolved "https://registry.yarnpkg.com/d3-color/-/d3-color-3.1.0.tgz#395b2833dfac71507f12ac2f7af23bf819de24e2" + integrity sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA== + +d3-ease@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/d3-ease/-/d3-ease-3.0.1.tgz#9658ac38a2140d59d346160f1f6c30fda0bd12f4" + integrity sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w== + +"d3-format@1 - 3": + version "3.1.0" + resolved "https://registry.yarnpkg.com/d3-format/-/d3-format-3.1.0.tgz#9260e23a28ea5cb109e93b21a06e24e2ebd55641" + integrity sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA== + +"d3-interpolate@1.2.0 - 3", d3-interpolate@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/d3-interpolate/-/d3-interpolate-3.0.1.tgz#3c47aa5b32c5b3dfb56ef3fd4342078a632b400d" + integrity sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g== + dependencies: + d3-color "1 - 3" + +d3-path@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/d3-path/-/d3-path-3.1.0.tgz#22df939032fb5a71ae8b1800d61ddb7851c42526" + integrity sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ== + +d3-scale@^4.0.2: + version "4.0.2" + resolved "https://registry.yarnpkg.com/d3-scale/-/d3-scale-4.0.2.tgz#82b38e8e8ff7080764f8dcec77bd4be393689396" + integrity sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ== + dependencies: + d3-array "2.10.0 - 3" + d3-format "1 - 3" + d3-interpolate "1.2.0 - 3" + d3-time "2.1.1 - 3" + d3-time-format "2 - 4" + +d3-shape@^3.1.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/d3-shape/-/d3-shape-3.2.0.tgz#a1a839cbd9ba45f28674c69d7f855bcf91dfc6a5" + integrity sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA== + dependencies: + d3-path "^3.1.0" + +"d3-time-format@2 - 4": + version "4.1.0" + resolved "https://registry.yarnpkg.com/d3-time-format/-/d3-time-format-4.1.0.tgz#7ab5257a5041d11ecb4fe70a5c7d16a195bb408a" + integrity sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg== + dependencies: + d3-time "1 - 3" + +"d3-time@1 - 3", "d3-time@2.1.1 - 3", d3-time@^3.0.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/d3-time/-/d3-time-3.1.0.tgz#9310db56e992e3c0175e1ef385e545e48a9bb5c7" + integrity sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q== + dependencies: + d3-array "2 - 3" + +d3-timer@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/d3-timer/-/d3-timer-3.0.1.tgz#6284d2a2708285b1abb7e201eda4380af35e63b0" + integrity sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA== + +d3-voronoi@^1.1.4: + version "1.1.4" + resolved "https://registry.yarnpkg.com/d3-voronoi/-/d3-voronoi-1.1.4.tgz#dd3c78d7653d2bb359284ae478645d95944c8297" + integrity sha512-dArJ32hchFsrQ8uMiTBLq256MpnZjeuBtdHpaDlYuQyjU0CVzCJl/BVW+SkszaAeH95D/8gxqAhgx0ouAWAfRg== + d@1, d@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/d/-/d-1.0.1.tgz#8698095372d58dbee346ffd0c7093f99f8f9eb5a" @@ -5830,6 +6171,11 @@ date-fns@^2.29.3: resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-2.29.3.tgz#27402d2fc67eb442b511b70bbdf98e6411cd68a8" integrity sha512-dDCnyH2WnnKusqvZZ6+jA1O51Ibt8ZMRNkDZdyAyK4YfbDwa/cEmuztzG5pk6hqlp9aSBPYcjOlktquahGwGeA== +dayjs@^1.11.10: + version "1.11.10" + resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.11.10.tgz#68acea85317a6e164457d6d6947564029a6a16a0" + integrity sha512-vjAczensTgRcqDERK0SR2XMwsF/tSvnvlv6VcF2GIhg6Sx4yOIt/irsr1RDJsKiIyBzJDpCoXiWWq28MqH2cnQ== + debug@4, debug@4.3.4, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.3, debug@^4.3.4: version "4.3.4" resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.4.tgz#1319f6579357f2338d3337d2cdd4914bb5dcc865" @@ -5920,6 +6266,18 @@ define-properties@^1.1.3, define-properties@^1.1.4: has-property-descriptors "^1.0.0" object-keys "^1.1.1" +delaunator@^4.0.0: + version "4.0.1" + resolved "https://registry.yarnpkg.com/delaunator/-/delaunator-4.0.1.tgz#3d779687f57919a7a418f8ab947d3bddb6846957" + integrity sha512-WNPWi1IRKZfCt/qIDMfERkDp93+iZEmOxN2yy4Jg+Xhv8SLk2UTqqbe1sfiipn0and9QrE914/ihdx82Y/Giag== + +delaunay-find@0.0.6: + version "0.0.6" + resolved "https://registry.yarnpkg.com/delaunay-find/-/delaunay-find-0.0.6.tgz#2ed017a79410013717fa7d9422e082c2502d4ae3" + integrity sha512-1+almjfrnR7ZamBk0q3Nhg6lqSe6Le4vL0WJDSMx4IDbQwTpUTXPjxC00lqLBT8MYsJpPCbI16sIkw9cPsbi7Q== + dependencies: + delaunator "^4.0.0" + delay@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/delay/-/delay-5.0.0.tgz#137045ef1b96e5071060dd5be60bf9334436bd1d" @@ -6470,6 +6828,18 @@ eslint-plugin-react@^7.31.7: semver "^6.3.0" string.prototype.matchall "^4.0.8" +eslint-plugin-unused-imports@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/eslint-plugin-unused-imports/-/eslint-plugin-unused-imports-3.1.0.tgz#db015b569d3774e17a482388c95c17bd303bc602" + integrity sha512-9l1YFCzXKkw1qtAru1RWUtG2EVDZY0a0eChKXcL+EZ5jitG7qxdctu4RnvhOJHv4xfmUf7h+JJPINlVpGhZMrw== + dependencies: + eslint-rule-composer "^0.3.0" + +eslint-rule-composer@^0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/eslint-rule-composer/-/eslint-rule-composer-0.3.0.tgz#79320c927b0c5c0d3d3d2b76c8b4a488f25bbaf9" + integrity sha512-bt+Sh8CtDmn2OajxvNO+BX7Wn4CIWMpTRm3MaiKPCQcnnlm0CS2mhui6QaoeQugs+3Kj2ESKEEGJUdVafwhiCg== + eslint-scope@^5.1.1: version "5.1.1" resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-5.1.1.tgz#e786e59a66cb92b3f6c1fb0d508aab174848f48c" @@ -6841,7 +7211,7 @@ ethers@5.5.4: "@ethersproject/web" "5.5.1" "@ethersproject/wordlists" "5.5.0" -ethers@5.7.2, ethers@^5.6.8, ethers@^5.7.2: +ethers@5.7.2, ethers@^5.7.2: version "5.7.2" resolved "https://registry.yarnpkg.com/ethers/-/ethers-5.7.2.tgz#3a7deeabbb8c030d4126b24f84e525466145872e" integrity sha512-wswUsmWo1aOK8rR7DIKiWSw9DbLWe6x98Jrn8wcTflTVvaXhAMaB5zGAXy0GYQEQp9iO1iSHWVyARQm11zUtyg== @@ -7655,6 +8025,11 @@ internal-slot@^1.0.3, internal-slot@^1.0.4: has "^1.0.3" side-channel "^1.0.4" +"internmap@1 - 2": + version "2.0.3" + resolved "https://registry.yarnpkg.com/internmap/-/internmap-2.0.3.tgz#6685f23755e43c524e251d29cbc97248e3061009" + integrity sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg== + interpret@^1.0.0: version "1.4.0" resolved "https://registry.yarnpkg.com/interpret/-/interpret-1.4.0.tgz#665ab8bc4da27a774a40584e812e3e0fa45b1a1e" @@ -7963,6 +8338,11 @@ isomorphic-ws@^4.0.1: resolved "https://registry.yarnpkg.com/isomorphic-ws/-/isomorphic-ws-4.0.1.tgz#55fd4cd6c5e6491e76dc125938dd863f5cd4f2dc" integrity sha512-BhBvN2MBpWTaSHdWRb/bwdZJ1WaehQ2L1KngkCkfLUGF0mAWAT1sQUQacEmQ0jXkFw/czDXPNQSL5u2/Krsz1w== +isows@1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/isows/-/isows-1.0.3.tgz#93c1cf0575daf56e7120bab5c8c448b0809d0d74" + integrity sha512-2cKei4vlmg2cxEjm3wVSqn8pcoRF/LX/wpifuuNquFO4SQmPwarClT+SUCA2lt+l581tTeZIPIZuIDo2jWN1fg== + istanbul-lib-coverage@^3.0.0, istanbul-lib-coverage@^3.2.0: version "3.2.0" resolved "https://registry.yarnpkg.com/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.0.tgz#189e7909d0a39fa5a3dfad5b03f71947770191d3" @@ -8765,7 +9145,7 @@ lodash.uniqby@^4.7.0: resolved "https://registry.yarnpkg.com/lodash.uniqby/-/lodash.uniqby-4.7.0.tgz#d99c07a669e9e6d24e1362dfe266c67616af1302" integrity sha512-e/zcLx6CSbmaEgFHCA7BnoQKyCtKMxnuWrJygbwPs/AIn+IMKl66L8/s+wBUn5LRw2pZx3bUHibiV1b6aTWIww== -lodash@^4.17.11, lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.16, lodash@^4.17.20, lodash@^4.17.4: +lodash@^4.17.11, lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.16, lodash@^4.17.19, lodash@^4.17.20, lodash@^4.17.21, lodash@^4.17.4: version "4.17.21" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== @@ -9787,6 +10167,11 @@ react-dom@^17.0.2: object-assign "^4.1.1" scheduler "^0.20.2" +react-fast-compare@^3.2.0: + version "3.2.2" + resolved "https://registry.yarnpkg.com/react-fast-compare/-/react-fast-compare-3.2.2.tgz#929a97a532304ce9fee4bcae44234f1ce2c21d49" + integrity sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ== + react-is@^16.13.1, react-is@^16.7.0: version "16.13.1" resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" @@ -9950,6 +10335,11 @@ regenerator-runtime@^0.13.11: resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz#f6dca3e7ceec20590d07ada785636a90cdca17f9" integrity sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg== +regenerator-runtime@^0.14.0: + version "0.14.1" + resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz#356ade10263f685dda125100cd862c1db895327f" + integrity sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw== + regenerator-transform@^0.15.1: version "0.15.1" resolved "https://registry.yarnpkg.com/regenerator-transform/-/regenerator-transform-0.15.1.tgz#f6c4e99fc1b4591f780db2586328e4d9a9d8dc56" @@ -10732,6 +11122,11 @@ stylis@4.1.3: resolved "https://registry.yarnpkg.com/stylis/-/stylis-4.1.3.tgz#fd2fbe79f5fed17c55269e16ed8da14c84d069f7" integrity sha512-GP6WDNWf+o403jrEp9c5jibKavrtLW+/qYGhFxFrG8maXhwTBI7gLLhiBb0o7uFccWN+EOS9aMO6cGHWAO07OA== +stylis@4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/stylis/-/stylis-4.2.0.tgz#79daee0208964c8fe695a42fcffcac633a211a51" + integrity sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw== + superstruct@^0.14.2: version "0.14.2" resolved "https://registry.yarnpkg.com/superstruct/-/superstruct-0.14.2.tgz#0dbcdf3d83676588828f1cf5ed35cda02f59025b" @@ -11296,6 +11691,320 @@ varuint-bitcoin@^1.1.2: dependencies: safe-buffer "^5.1.1" +victory-area@^36.9.2: + version "36.9.2" + resolved "https://registry.yarnpkg.com/victory-area/-/victory-area-36.9.2.tgz#8dd79834cb182cbac0eb480d040dd6059e24bc43" + integrity sha512-32aharvPf2RgdQB+/u1j3/ajYFNH/7ugLX9ZRpdd65gP6QEbtXL+58gS6CxvFw6gr/y8a0xMlkMKkpDVacXLpw== + dependencies: + lodash "^4.17.19" + victory-core "^36.9.2" + victory-vendor "^36.9.2" + +victory-axis@^36.9.2: + version "36.9.2" + resolved "https://registry.yarnpkg.com/victory-axis/-/victory-axis-36.9.2.tgz#80137a900671e918d9296f0f12f8252b6094b09b" + integrity sha512-4Odws+IAjprJtBg2b2ZCxEPgrQ6LgIOa22cFkGghzOSfTyNayN4M3AauNB44RZyn2O/hDiM1gdBkEg1g9YDevQ== + dependencies: + lodash "^4.17.19" + victory-core "^36.9.2" + +victory-bar@^36.9.2: + version "36.9.2" + resolved "https://registry.yarnpkg.com/victory-bar/-/victory-bar-36.9.2.tgz#8ab0f67337394b71d8bd6ee1599bd260f3d63303" + integrity sha512-R3LFoR91FzwWcnyGK2P8DHNVv9gsaWhl5pSr2KdeNtvLbZVEIvUkTeVN9RMBMzterSFPw0mbWhS1Asb3sV6PPw== + dependencies: + lodash "^4.17.19" + victory-core "^36.9.2" + victory-vendor "^36.9.2" + +victory-box-plot@^36.9.2: + version "36.9.2" + resolved "https://registry.yarnpkg.com/victory-box-plot/-/victory-box-plot-36.9.2.tgz#504c0ceef303a7c56ce2877711d53df99915e9c4" + integrity sha512-nUD45V/YHDkAKZyak7YDsz+Vk1F9N0ica3jWQe0AY0JqD9DleHa8RY/olSVws26kLyEj1I+fQqva6GodcLaIqQ== + dependencies: + lodash "^4.17.19" + victory-core "^36.9.2" + victory-vendor "^36.9.2" + +victory-brush-container@^36.9.2: + version "36.9.2" + resolved "https://registry.yarnpkg.com/victory-brush-container/-/victory-brush-container-36.9.2.tgz#989c2b4787fb222f8354202c7ff0d0b3fa236e53" + integrity sha512-KcQjzFeo40tn52cJf1A02l5MqeR9GKkk3loDqM3T2hfi1PCyUrZXEUjGN5HNlLizDRvtcemaAHNAWlb70HbG/g== + dependencies: + lodash "^4.17.19" + react-fast-compare "^3.2.0" + victory-core "^36.9.2" + +victory-brush-line@^36.9.2: + version "36.9.2" + resolved "https://registry.yarnpkg.com/victory-brush-line/-/victory-brush-line-36.9.2.tgz#8fc446c77cb56d981e482f3a8119cc399ba1860b" + integrity sha512-/ncj8HEyl73fh8bhU4Iqe79DL62QP2rWWoogINxsGvndrhpFbL9tj7IPSEawi+riOh/CmohgI/ETu/V7QU9cJw== + dependencies: + lodash "^4.17.19" + react-fast-compare "^3.2.0" + victory-core "^36.9.2" + +victory-candlestick@^36.9.2: + version "36.9.2" + resolved "https://registry.yarnpkg.com/victory-candlestick/-/victory-candlestick-36.9.2.tgz#c2a4cc775c476f20d8853f8402f5df0c734ba9ff" + integrity sha512-hbStzF61GHkkflJWFgLTZSR8SOm8siJn65rwApLJBIA283yWOlyPjdr/kIQtO/h5QkIiXIuLb7RyiUAJEnH9WA== + dependencies: + lodash "^4.17.19" + victory-core "^36.9.2" + +victory-canvas@^36.9.2: + version "36.9.2" + resolved "https://registry.yarnpkg.com/victory-canvas/-/victory-canvas-36.9.2.tgz#5da579eeb47f9a8c14499c4c656137d12a6a9bd8" + integrity sha512-ImHJ7JQCpQ9aGCsh37EeVAmqJc7R0gl2CLM99gP9GfuJuZeoZ/GVfX6QFamfr19rYQOD2m9pVbecySBzdYI1zQ== + dependencies: + lodash "^4.17.19" + victory-bar "^36.9.2" + victory-core "^36.9.2" + +victory-chart@^36.9.2: + version "36.9.2" + resolved "https://registry.yarnpkg.com/victory-chart/-/victory-chart-36.9.2.tgz#ab09f566722d7337e55ebca45a6a82ed071fb277" + integrity sha512-dMNcS0BpqL3YiGvI4BSEmPR76FCksCgf3K4CSZ7C/MGyrElqB6wWwzk7afnlB1Qr71YIHXDmdwsPNAl/iEwTtA== + dependencies: + lodash "^4.17.19" + react-fast-compare "^3.2.0" + victory-axis "^36.9.2" + victory-core "^36.9.2" + victory-polar-axis "^36.9.2" + victory-shared-events "^36.9.2" + +victory-core@^36.9.2: + version "36.9.2" + resolved "https://registry.yarnpkg.com/victory-core/-/victory-core-36.9.2.tgz#bb82846e8f60b62f51e70b2658192c8434596d02" + integrity sha512-AzmMy+9MYMaaRmmZZovc/Po9urHne3R3oX7bbXeQdVuK/uMBrlPiv11gVJnuEH2SXLVyep43jlKgaBp8ef9stQ== + dependencies: + lodash "^4.17.21" + react-fast-compare "^3.2.0" + victory-vendor "^36.9.2" + +victory-create-container@^36.9.2: + version "36.9.2" + resolved "https://registry.yarnpkg.com/victory-create-container/-/victory-create-container-36.9.2.tgz#d913683cc2a9dda25f58c1f1336e0985f8288712" + integrity sha512-uA0dh1R0YDzuXyE/7StZvq4qshet+WYceY7R1UR5mR/F9079xy+iQsa2Ca4h97/GtVZoLO6r1eKLWBt9TN+U7A== + dependencies: + lodash "^4.17.19" + victory-brush-container "^36.9.2" + victory-core "^36.9.2" + victory-cursor-container "^36.9.2" + victory-selection-container "^36.9.2" + victory-voronoi-container "^36.9.2" + victory-zoom-container "^36.9.2" + +victory-cursor-container@^36.9.2: + version "36.9.2" + resolved "https://registry.yarnpkg.com/victory-cursor-container/-/victory-cursor-container-36.9.2.tgz#4f874c76c02c80a4f3d09ffa741076f905f8ed4f" + integrity sha512-jidab4j3MaciF3fGX70jTj4H9rrLcY8o2LUrhJ67ZLvEFGGmnPtph+p8Fe97Umrag7E/DszjNxQZolpwlgUh3g== + dependencies: + lodash "^4.17.19" + victory-core "^36.9.2" + +victory-errorbar@^36.9.2: + version "36.9.2" + resolved "https://registry.yarnpkg.com/victory-errorbar/-/victory-errorbar-36.9.2.tgz#8dca0ee735f1328809399cbdf625e66a4f6e8bcf" + integrity sha512-i/WPMN6/7F55FpEpN9WcwiWwaFJ+2ymfTgfBDLkUD3XJ52HGen4BxUt1ouwDA3FXz9kLa/h6Wbp/fnRhX70row== + dependencies: + lodash "^4.17.19" + victory-core "^36.9.2" + +victory-group@^36.9.2: + version "36.9.2" + resolved "https://registry.yarnpkg.com/victory-group/-/victory-group-36.9.2.tgz#4451b3cf9a4a9488271277c31d85022dfdb59397" + integrity sha512-wBmpsjBTKva8mxHvHNY3b8RE58KtnpLLItEyyAHaYkmExwt3Uj8Cld3sF3vmeuijn2iR64NPKeMbgMbfZJzycw== + dependencies: + lodash "^4.17.19" + react-fast-compare "^3.2.0" + victory-core "^36.9.2" + victory-shared-events "^36.9.2" + +victory-histogram@^36.9.2: + version "36.9.2" + resolved "https://registry.yarnpkg.com/victory-histogram/-/victory-histogram-36.9.2.tgz#e1314ca0c950c7a8950157a8254debaf4ecb4b04" + integrity sha512-w0ipFwWZ533qyqduRacr5cf+H4PGAUTdWNyGvZbWyu4+GtYYjGdoOolfUcO1ee8VJ1kZodpG8Z7ud6I/GWIzjQ== + dependencies: + lodash "^4.17.19" + react-fast-compare "^3.2.0" + victory-bar "^36.9.2" + victory-core "^36.9.2" + victory-vendor "^36.9.2" + +victory-legend@^36.9.2: + version "36.9.2" + resolved "https://registry.yarnpkg.com/victory-legend/-/victory-legend-36.9.2.tgz#2ca9e36b7be60bc4a64711f25524ac1290e75453" + integrity sha512-cucFJpv6fty+yXp5pElQFQnHBk1TqA4guGUMI+XF/wLlnuM4bhdAtASobRIIBkz0mHGBaCAAV4PzL9azPU/9dg== + dependencies: + lodash "^4.17.19" + victory-core "^36.9.2" + +victory-line@^36.9.2: + version "36.9.2" + resolved "https://registry.yarnpkg.com/victory-line/-/victory-line-36.9.2.tgz#02e3e1f404ac4b0a2cca4ae4684c20037e2a51a3" + integrity sha512-kmYFZUo0o2xC8cXRsmt/oUBRQSZJVT2IJnAkboUepypoj09e6CY5tRH4TSdfEDGkBk23xQkn7d4IFgl4kAGnSA== + dependencies: + lodash "^4.17.19" + victory-core "^36.9.2" + victory-vendor "^36.9.2" + +victory-pie@^36.9.2: + version "36.9.2" + resolved "https://registry.yarnpkg.com/victory-pie/-/victory-pie-36.9.2.tgz#2af3c12b9251de20f11a8325c821aede9cb5f8a5" + integrity sha512-i3zWezvy5wQEkhXKt4rS9ILGH7Vr9Q5eF9fKO4GMwDPBdYOTE3Dh2tVaSrfDC8g9zFIc0DKzOtVoJRTb+0AkPg== + dependencies: + lodash "^4.17.19" + victory-core "^36.9.2" + victory-vendor "^36.9.2" + +victory-polar-axis@^36.9.2: + version "36.9.2" + resolved "https://registry.yarnpkg.com/victory-polar-axis/-/victory-polar-axis-36.9.2.tgz#574a7deede92d227e20e9ad4938c57633f5e5ac3" + integrity sha512-HBR90FF4M56yf/atXjSmy3DMps1vSAaLXmdVXLM/A5g+0pUS7HO719r5x6dsR3I6Rm+8x6Kk8xJs0qgpnGQIEw== + dependencies: + lodash "^4.17.19" + victory-core "^36.9.2" + +victory-scatter@^36.9.2: + version "36.9.2" + resolved "https://registry.yarnpkg.com/victory-scatter/-/victory-scatter-36.9.2.tgz#f07dced7660f90e2a898053431462d3c6372149f" + integrity sha512-hK9AtbJQfaW05i8BH7Lf1HK7vWMAfQofj23039HEQJqTKbCL77YT+Q0LhZw1a1BRCpC/5aSg9EuqblhfIYw2wg== + dependencies: + lodash "^4.17.19" + victory-core "^36.9.2" + +victory-selection-container@^36.9.2: + version "36.9.2" + resolved "https://registry.yarnpkg.com/victory-selection-container/-/victory-selection-container-36.9.2.tgz#bff359d27d50b04a473eacdb8e8c66488afd20a4" + integrity sha512-chboroEwqqVlMB60kveXM2WznJ33ZM00PWkFVCoJDzHHlYs7TCADxzhqet2S67SbZGSyvSprY2YztSxX8kZ+XQ== + dependencies: + lodash "^4.17.19" + victory-core "^36.9.2" + +victory-shared-events@^36.9.2: + version "36.9.2" + resolved "https://registry.yarnpkg.com/victory-shared-events/-/victory-shared-events-36.9.2.tgz#cf0cf2220ee1eb90baa16e202873b20254ab9cde" + integrity sha512-W/atiw3Or6MnpBuhluFv6007YrixIRh5NtiRvtFLGxNuQJLYjaSh6koRAih5xJer5Pj7YUx0tL9x67jTRcJ6Dg== + dependencies: + json-stringify-safe "^5.0.1" + lodash "^4.17.19" + react-fast-compare "^3.2.0" + victory-core "^36.9.2" + +victory-stack@^36.9.2: + version "36.9.2" + resolved "https://registry.yarnpkg.com/victory-stack/-/victory-stack-36.9.2.tgz#25cd48ed66b4c9163993e6ac8d770dca791e2074" + integrity sha512-imR6FniVlDFlBa/B3Est8kTryNhWj2ZNpivmVOebVDxkKcVlLaDg3LotCUOI7NzOhBQaro0UzeE9KmZV93JcYA== + dependencies: + lodash "^4.17.19" + react-fast-compare "^3.2.0" + victory-core "^36.9.2" + victory-shared-events "^36.9.2" + +victory-tooltip@^36.9.2: + version "36.9.2" + resolved "https://registry.yarnpkg.com/victory-tooltip/-/victory-tooltip-36.9.2.tgz#8e240a03f80909e80a9501419bec01bc13700919" + integrity sha512-76seo4TWD1WfZHJQH87IP3tlawv38DuwrUxpnTn8+uW6/CUex82poQiVevYdmJzhataS9jjyCWv3w7pOmLBCLg== + dependencies: + lodash "^4.17.19" + victory-core "^36.9.2" + +victory-vendor@^36.9.2: + version "36.9.2" + resolved "https://registry.yarnpkg.com/victory-vendor/-/victory-vendor-36.9.2.tgz#668b02a448fa4ea0f788dbf4228b7e64669ff801" + integrity sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ== + dependencies: + "@types/d3-array" "^3.0.3" + "@types/d3-ease" "^3.0.0" + "@types/d3-interpolate" "^3.0.1" + "@types/d3-scale" "^4.0.2" + "@types/d3-shape" "^3.1.0" + "@types/d3-time" "^3.0.0" + "@types/d3-timer" "^3.0.0" + d3-array "^3.1.6" + d3-ease "^3.0.1" + d3-interpolate "^3.0.1" + d3-scale "^4.0.2" + d3-shape "^3.1.0" + d3-time "^3.0.0" + d3-timer "^3.0.1" + +victory-voronoi-container@^36.9.2: + version "36.9.2" + resolved "https://registry.yarnpkg.com/victory-voronoi-container/-/victory-voronoi-container-36.9.2.tgz#1b3ce4dd43ceb371f6caba6b0724ca563de87b96" + integrity sha512-NIVYqck9N4OQnEz9mgQ4wILsci3OBWWK7RLuITGHyoD7Ne/+WH1i0Pv2y9eIx+f55rc928FUTugPPhkHvXyH3A== + dependencies: + delaunay-find "0.0.6" + lodash "^4.17.19" + react-fast-compare "^3.2.0" + victory-core "^36.9.2" + victory-tooltip "^36.9.2" + +victory-voronoi@^36.9.2: + version "36.9.2" + resolved "https://registry.yarnpkg.com/victory-voronoi/-/victory-voronoi-36.9.2.tgz#b58883b2a14c8ad0e4c131a1d02c6e428ecae612" + integrity sha512-50fq0UBTAFxxU+nabOIPE5P2v/2oAbGAX+Ckz6lu8LFwwig4J1DSz0/vQudqDGjzv3JNEdqTD4FIpyjbxLcxiA== + dependencies: + d3-voronoi "^1.1.4" + lodash "^4.17.19" + victory-core "^36.9.2" + +victory-zoom-container@^36.9.2: + version "36.9.2" + resolved "https://registry.yarnpkg.com/victory-zoom-container/-/victory-zoom-container-36.9.2.tgz#2899c8fa06d772864128b44130d48744298b76d0" + integrity sha512-pXa2Ji6EX/pIarKT6Hcmmu2n7IG/x8Vs0D2eACQ/nbpvZa+DXWIxCRW4hcg2Va35fmXcDIEpGaX3/soXzZ+pbw== + dependencies: + lodash "^4.17.19" + victory-core "^36.9.2" + +victory@^36.9.2: + version "36.9.2" + resolved "https://registry.yarnpkg.com/victory/-/victory-36.9.2.tgz#0f4ceec0c732bf166f9a3997cbf6c6df54bf1476" + integrity sha512-kgVgiSno4KpD0HxmUo5GzqWI4P/eILLOM6AmJfAlagCnOzrtYGsAw+N1YxOcYvTiKsh/zmWawxHlpw3TMenFDQ== + dependencies: + victory-area "^36.9.2" + victory-axis "^36.9.2" + victory-bar "^36.9.2" + victory-box-plot "^36.9.2" + victory-brush-container "^36.9.2" + victory-brush-line "^36.9.2" + victory-candlestick "^36.9.2" + victory-canvas "^36.9.2" + victory-chart "^36.9.2" + victory-core "^36.9.2" + victory-create-container "^36.9.2" + victory-cursor-container "^36.9.2" + victory-errorbar "^36.9.2" + victory-group "^36.9.2" + victory-histogram "^36.9.2" + victory-legend "^36.9.2" + victory-line "^36.9.2" + victory-pie "^36.9.2" + victory-polar-axis "^36.9.2" + victory-scatter "^36.9.2" + victory-selection-container "^36.9.2" + victory-shared-events "^36.9.2" + victory-stack "^36.9.2" + victory-tooltip "^36.9.2" + victory-voronoi "^36.9.2" + victory-voronoi-container "^36.9.2" + victory-zoom-container "^36.9.2" + +viem@^1.6.0: + version "1.21.4" + resolved "https://registry.yarnpkg.com/viem/-/viem-1.21.4.tgz#883760e9222540a5a7e0339809202b45fe6a842d" + integrity sha512-BNVYdSaUjeS2zKQgPs+49e5JKocfo60Ib2yiXOWBT6LuVxY1I/6fFX3waEtpXvL1Xn4qu+BVitVtMh9lyThyhQ== + dependencies: + "@adraffy/ens-normalize" "1.10.0" + "@noble/curves" "1.2.0" + "@noble/hashes" "1.3.2" + "@scure/bip32" "1.3.2" + "@scure/bip39" "1.2.1" + abitype "0.9.8" + isows "1.0.3" + ws "8.13.0" + w3c-xmlserializer@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/w3c-xmlserializer/-/w3c-xmlserializer-4.0.0.tgz#aebdc84920d806222936e3cdce408e32488a3073" @@ -11488,6 +12197,11 @@ ws@7.5.9, ws@^7.2.0, ws@^7.4.5, ws@^7.4.6, ws@^7.5.1: resolved "https://registry.yarnpkg.com/ws/-/ws-7.5.9.tgz#54fa7db29f4c7cec68b1ddd3a89de099942bb591" integrity sha512-F+P9Jil7UiSKSkppIiD94dN07AwvFixvLIj1Og1Rl9GGMuNipJnV9JzjD6XuqmAeiswGvUmNLjr5cFuXwNS77Q== +ws@8.13.0: + version "8.13.0" + resolved "https://registry.yarnpkg.com/ws/-/ws-8.13.0.tgz#9a9fb92f93cf41512a0735c8f4dd09b8a1211cd0" + integrity sha512-x9vcZYTrFPC7aSIbj7sRCYo7L/Xb8Iy+pW0ng0wt2vCJv7M9HOMy0UoN3rr+IFC7hb7vXoqS+P9ktyLLLhO+LA== + ws@^8.11.0, ws@^8.5.0: version "8.12.0" resolved "https://registry.yarnpkg.com/ws/-/ws-8.12.0.tgz#485074cc392689da78e1828a9ff23585e06cddd8"