├── .gitignore
├── LICENSE
├── README.md
├── analyse_logs.py
├── claim_staking_rewards.sh
├── contracts
└── ServiceRegistryTokenUtility.json
├── images
├── docker.png
├── metamask_import_private_key.png
├── staking_fsm.svg
└── trader_fsm_transitions.png
├── rank_traders.py
├── report.py
├── reset_staking.sh
├── run_service.sh
├── scripts
├── __init__.py
├── change_keys_json_password.py
├── check_python.py
├── choose_staking.py
├── claim_staking_rewards.py
├── erc20_balance.py
├── get_agent_bond.py
├── get_available_staking_slots.py
├── get_safe_owners.py
├── is_keys_json_password_valid.py
├── mech_events.py
├── service_hash.py
├── staking.py
├── swap_safe_owner.py
└── utils.py
├── stop_service.sh
├── terminate_on_chain_service.sh
└── trades.py
/.gitignore:
--------------------------------------------------------------------------------
1 | .trader_runner*
2 | .operate*
3 | trader
4 | *.DS_Store
5 | __pycache__
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Apache License
2 | Version 2.0, January 2004
3 | http://www.apache.org/licenses/
4 |
5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6 |
7 | 1. Definitions.
8 |
9 | "License" shall mean the terms and conditions for use, reproduction,
10 | and distribution as defined by Sections 1 through 9 of this document.
11 |
12 | "Licensor" shall mean the copyright owner or entity authorized by
13 | the copyright owner that is granting the License.
14 |
15 | "Legal Entity" shall mean the union of the acting entity and all
16 | other entities that control, are controlled by, or are under common
17 | control with that entity. For the purposes of this definition,
18 | "control" means (i) the power, direct or indirect, to cause the
19 | direction or management of such entity, whether by contract or
20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the
21 | outstanding shares, or (iii) beneficial ownership of such entity.
22 |
23 | "You" (or "Your") shall mean an individual or Legal Entity
24 | exercising permissions granted by this License.
25 |
26 | "Source" form shall mean the preferred form for making modifications,
27 | including but not limited to software source code, documentation
28 | source, and configuration files.
29 |
30 | "Object" form shall mean any form resulting from mechanical
31 | transformation or translation of a Source form, including but
32 | not limited to compiled object code, generated documentation,
33 | and conversions to other media types.
34 |
35 | "Work" shall mean the work of authorship, whether in Source or
36 | Object form, made available under the License, as indicated by a
37 | copyright notice that is included in or attached to the work
38 | (an example is provided in the Appendix below).
39 |
40 | "Derivative Works" shall mean any work, whether in Source or Object
41 | form, that is based on (or derived from) the Work and for which the
42 | editorial revisions, annotations, elaborations, or other modifications
43 | represent, as a whole, an original work of authorship. For the purposes
44 | of this License, Derivative Works shall not include works that remain
45 | separable from, or merely link (or bind by name) to the interfaces of,
46 | the Work and Derivative Works thereof.
47 |
48 | "Contribution" shall mean any work of authorship, including
49 | the original version of the Work and any modifications or additions
50 | to that Work or Derivative Works thereof, that is intentionally
51 | submitted to Licensor for inclusion in the Work by the copyright owner
52 | or by an individual or Legal Entity authorized to submit on behalf of
53 | the copyright owner. For the purposes of this definition, "submitted"
54 | means any form of electronic, verbal, or written communication sent
55 | to the Licensor or its representatives, including but not limited to
56 | communication on electronic mailing lists, source code control systems,
57 | and issue tracking systems that are managed by, or on behalf of, the
58 | Licensor for the purpose of discussing and improving the Work, but
59 | excluding communication that is conspicuously marked or otherwise
60 | designated in writing by the copyright owner as "Not a Contribution."
61 |
62 | "Contributor" shall mean Licensor and any individual or Legal Entity
63 | on behalf of whom a Contribution has been received by Licensor and
64 | subsequently incorporated within the Work.
65 |
66 | 2. Grant of Copyright License. Subject to the terms and conditions of
67 | this License, each Contributor hereby grants to You a perpetual,
68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69 | copyright license to reproduce, prepare Derivative Works of,
70 | publicly display, publicly perform, sublicense, and distribute the
71 | Work and such Derivative Works in Source or Object form.
72 |
73 | 3. Grant of Patent License. Subject to the terms and conditions of
74 | this License, each Contributor hereby grants to You a perpetual,
75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76 | (except as stated in this section) patent license to make, have made,
77 | use, offer to sell, sell, import, and otherwise transfer the Work,
78 | where such license applies only to those patent claims licensable
79 | by such Contributor that are necessarily infringed by their
80 | Contribution(s) alone or by combination of their Contribution(s)
81 | with the Work to which such Contribution(s) was submitted. If You
82 | institute patent litigation against any entity (including a
83 | cross-claim or counterclaim in a lawsuit) alleging that the Work
84 | or a Contribution incorporated within the Work constitutes direct
85 | or contributory patent infringement, then any patent licenses
86 | granted to You under this License for that Work shall terminate
87 | as of the date such litigation is filed.
88 |
89 | 4. Redistribution. You may reproduce and distribute copies of the
90 | Work or Derivative Works thereof in any medium, with or without
91 | modifications, and in Source or Object form, provided that You
92 | meet the following conditions:
93 |
94 | (a) You must give any other recipients of the Work or
95 | Derivative Works a copy of this License; and
96 |
97 | (b) You must cause any modified files to carry prominent notices
98 | stating that You changed the files; and
99 |
100 | (c) You must retain, in the Source form of any Derivative Works
101 | that You distribute, all copyright, patent, trademark, and
102 | attribution notices from the Source form of the Work,
103 | excluding those notices that do not pertain to any part of
104 | the Derivative Works; and
105 |
106 | (d) If the Work includes a "NOTICE" text file as part of its
107 | distribution, then any Derivative Works that You distribute must
108 | include a readable copy of the attribution notices contained
109 | within such NOTICE file, excluding those notices that do not
110 | pertain to any part of the Derivative Works, in at least one
111 | of the following places: within a NOTICE text file distributed
112 | as part of the Derivative Works; within the Source form or
113 | documentation, if provided along with the Derivative Works; or,
114 | within a display generated by the Derivative Works, if and
115 | wherever such third-party notices normally appear. The contents
116 | of the NOTICE file are for informational purposes only and
117 | do not modify the License. You may add Your own attribution
118 | notices within Derivative Works that You distribute, alongside
119 | or as an addendum to the NOTICE text from the Work, provided
120 | that such additional attribution notices cannot be construed
121 | as modifying the License.
122 |
123 | You may add Your own copyright statement to Your modifications and
124 | may provide additional or different license terms and conditions
125 | for use, reproduction, or distribution of Your modifications, or
126 | for any such Derivative Works as a whole, provided Your use,
127 | reproduction, and distribution of the Work otherwise complies with
128 | the conditions stated in this License.
129 |
130 | 5. Submission of Contributions. Unless You explicitly state otherwise,
131 | any Contribution intentionally submitted for inclusion in the Work
132 | by You to the Licensor shall be under the terms and conditions of
133 | this License, without any additional terms or conditions.
134 | Notwithstanding the above, nothing herein shall supersede or modify
135 | the terms of any separate license agreement you may have executed
136 | with Licensor regarding such Contributions.
137 |
138 | 6. Trademarks. This License does not grant permission to use the trade
139 | names, trademarks, service marks, or product names of the Licensor,
140 | except as required for reasonable and customary use in describing the
141 | origin of the Work and reproducing the content of the NOTICE file.
142 |
143 | 7. Disclaimer of Warranty. Unless required by applicable law or
144 | agreed to in writing, Licensor provides the Work (and each
145 | Contributor provides its Contributions) on an "AS IS" BASIS,
146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147 | implied, including, without limitation, any warranties or conditions
148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149 | PARTICULAR PURPOSE. You are solely responsible for determining the
150 | appropriateness of using or redistributing the Work and assume any
151 | risks associated with Your exercise of permissions under this License.
152 |
153 | 8. Limitation of Liability. In no event and under no legal theory,
154 | whether in tort (including negligence), contract, or otherwise,
155 | unless required by applicable law (such as deliberate and grossly
156 | negligent acts) or agreed to in writing, shall any Contributor be
157 | liable to You for damages, including any direct, indirect, special,
158 | incidental, or consequential damages of any character arising as a
159 | result of this License or out of the use or inability to use the
160 | Work (including but not limited to damages for loss of goodwill,
161 | work stoppage, computer failure or malfunction, or any and all
162 | other commercial damages or losses), even if such Contributor
163 | has been advised of the possibility of such damages.
164 |
165 | 9. Accepting Warranty or Additional Liability. While redistributing
166 | the Work or Derivative Works thereof, You may choose to offer,
167 | and charge a fee for, acceptance of support, warranty, indemnity,
168 | or other liability obligations and/or rights consistent with this
169 | License. However, in accepting such obligations, You may act only
170 | on Your own behalf and on Your sole responsibility, not on behalf
171 | of any other Contributor, and only if You agree to indemnify,
172 | defend, and hold each Contributor harmless for any liability
173 | incurred by, or claims asserted against, such Contributor by reason
174 | of your accepting any such warranty or additional liability.
175 |
176 | END OF TERMS AND CONDITIONS
177 |
178 | APPENDIX: How to apply the Apache License to your work.
179 |
180 | To apply the Apache License to your work, attach the following
181 | boilerplate notice, with the fields enclosed by brackets "[]"
182 | replaced with your own identifying information. (Don't include
183 | the brackets!) The text should be enclosed in the appropriate
184 | comment syntax for the file format. We also recommend that a
185 | file or class name and description of purpose be included on the
186 | same "printed page" as the copyright notice for easier
187 | identification within third-party archives.
188 |
189 | Copyright 2023 Valory AG
190 |
191 | Licensed under the Apache License, Version 2.0 (the "License");
192 | you may not use this file except in compliance with the License.
193 | You may obtain a copy of the License at
194 |
195 | http://www.apache.org/licenses/LICENSE-2.0
196 |
197 | Unless required by applicable law or agreed to in writing, software
198 | distributed under the License is distributed on an "AS IS" BASIS,
199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200 | See the License for the specific language governing permissions and
201 | limitations under the License.
202 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 | 🚨 WARNING: Repository will be deprecated soon 🚨
3 |
4 |
5 |
6 | This repository has been deprecated and further maintenance is not guaranteed moving forward.
7 |
8 | If you are using it for the first time, please use the new Quickstart.
9 |
10 | If you are already running agents using this quickstart, please use the Migration Script to migrate your agents to the new Quickstart.
11 |
12 |
13 | # trader-quickstart
14 |
15 | A quickstart for the trader agent for AI prediction markets on Gnosis at https://github.com/valory-xyz/trader
16 |
17 | ## Compatible Systems
18 |
19 | - Windows 10/11: WSL2 / Git BASH
20 | - Mac ARM / Intel
21 | - Linux
22 | - Raspberry Pi 4
23 |
24 | ## System Requirements
25 |
26 | Ensure your machine satisfies the requirements:
27 |
28 | - Python `==3.10`
29 | - [Poetry](https://python-poetry.org/docs/) `>=1.4.0`
30 | - [Docker Engine](https://docs.docker.com/engine/install/) `<25.0.0`
31 | - [Docker Compose](https://docs.docker.com/compose/install/)
32 |
33 | ## Resource Requirements
34 |
35 | - You need xDAI on Gnosis Chain in one of your wallets.
36 | - You need an RPC for your agent instance. We recommend [Quicknode RPC](https://www.quicknode.com/).
37 | - (From release v0.16.0 onwards) You will need a Subgraph API key that can be obtained at [The Graph](https://thegraph.com/studio/apikeys/).
38 |
39 | ## Run the Service
40 |
41 | ### For Non-Stakers
42 |
43 | Clone this repository locally and execute:
44 |
45 | ```bash
46 | chmod +x run_service.sh
47 | ./run_service.sh
48 | ```
49 |
50 | Answer `1) No staking` when prompted:
51 |
52 | ```text
53 | Please, select your staking program preference
54 | ```
55 |
56 | ### For Stakers
57 |
58 | > :warning: **Warning**
59 | > The code within this repository is provided without any warranties. It is important to note that the code has not been audited for potential security vulnerabilities.
60 | > Using this code could potentially lead to loss of funds, compromised data, or asset risk.
61 | > Exercise caution and use this code at your own risk. Please refer to the [LICENSE](./LICENSE) file for details about the terms and conditions.
62 |
63 | Each staking program has different OLAS requirements. The script will check that your owner address meets the minimum required OLAS on the Gnosis Chain.
64 |
65 | Clone this repository locally and execute:
66 |
67 | ```bash
68 | chmod +x run_service.sh
69 | ./run_service.sh
70 | ```
71 |
72 | Select your preferred staking program when prompted:
73 |
74 | ```text
75 | Please, select your staking program preference
76 | ----------------------------------------------
77 | 1) No staking
78 | Your Olas Predict agent will still actively participate in prediction
79 | markets, but it will not be staked within any staking program.
80 |
81 | 2) Quickstart Beta - Hobbyist
82 | The Quickstart Beta - Hobbyist staking contract offers 100 slots for
83 | operators running Olas Predict agents with the quickstart. It is designed as
84 | a step up from Coastal Staker Expeditions, requiring 100 OLAS for staking.
85 | The rewards are also more attractive than with Coastal Staker Expeditions.
86 |
87 | 3) Quickstart Beta - Expert
88 | The Quickstart Beta - Expert staking contract offers 20 slots for operators
89 | running Olas Predict agents with the quickstart. It is designed for
90 | professional agent operators, requiring 1000 OLAS for staking. The rewards
91 | are proportional to the Quickstart Beta - Hobbyist.
92 |
93 | ...
94 | ```
95 |
96 | Find below a diagram of the possible status a service can be in the staking program:
97 |
98 | 
99 |
100 | Services can become staked by invoking the `stake()` contract method, where service parameters and deposit amounts are verified. Staked services can call the `checkpoint()` method at regular intervals, ensuring liveness checks and calculating staking rewards. In case a service remains inactive beyond the specified `maxAllowedInactivity` time, it faces eviction from the staking program, ceasing to accrue additional rewards. Staked or evicted services can be unstaked by calling the `unstake()` contract method. They can do so after `minStakingDuration` has passed or if no more staking rewards are available.
101 |
102 | __Notes__:
103 |
104 | - Staking is currently in a testing phase, so the number of trader agents that can be staked might be limited.
105 | - Services are evicted after accumulating 2 consecutive checkpoints without meeting the activity threshold.
106 | - Currently, the minimum staking time is approximately 3 days. In particular, a service cannot be unstaked during the minimum staking period.
107 | - Once a staking program is selected, you can reset your preference by stopping your agent by running ./stop_service.sh and then running the command
108 |
109 | ``` bash
110 | ./reset_staking.sh
111 | ```
112 |
113 | Keep in mind that your service must stay for `minStakingDuration` in a staking program (typically 3 days) before you can change to a new program.
114 |
115 | ### Service is Running
116 |
117 | Once the command has completed, i.e. the service is running, you can see the live logs with:
118 |
119 | ```bash
120 | docker logs $(docker ps --filter "name=trader" --format "{{.Names}}" | grep "_abci" | head -n 1) --follow
121 | ```
122 |
123 | To stop your agent, use:
124 |
125 | ```bash
126 | ./stop_service.sh
127 | ```
128 |
129 | ### Claim accrued OLAS staking rewards
130 |
131 | If your service is staked, you can claim accrued OLAS staking rewards through the script
132 |
133 | ```bash
134 | ./claim_staking_rewards.sh
135 | ```
136 |
137 | The accrued OLAS will be transferred to your service Safe without having to unstake your service.
138 |
139 | ### Backups
140 |
141 | Agent runners are recommended to create a [backup](https://github.com/valory-xyz/trader-quickstart#backup-and-recovery) of the relevant secret key material.
142 |
143 | ### Skip user prompts
144 |
145 | You can optionally pass `--attended=false` or export the environment variable `ATTENDED=false` to skip asking for inputs from the user.
146 | Note: In this case, if the service is staked, then it will not update the on-chain service, to avoid unstaking. If you choose a staking contract with different parameters (e.g., different bond), then you have to execute the attended mode of the script to update the on-chain service.
147 |
148 | ## Observe your agents
149 |
150 | 1. Use the `trades` command to display information about placed trades by a given address:
151 |
152 | ```bash
153 | cd trader; poetry run python ../trades.py --creator YOUR_SAFE_ADDRESS; cd ..
154 | ```
155 |
156 | Or restrict the search to specific dates by defining the "from" and "to" dates:
157 |
158 | ```bash
159 | cd trader; poetry run python ../trades.py --creator YOUR_SAFE_ADDRESS --from-date 2023-08-15:03:50:00 --to-date 2023-08-20:13:45:00; cd ..
160 | ```
161 |
162 | 2. Use the `report` command to display a summary of the service status:
163 |
164 | ```bash
165 | cd trader; poetry run python ../report.py; cd ..
166 | ```
167 |
168 | 3. Use the `analyse_logs.py` script to investigate your agent's logs:
169 |
170 | ```bash
171 | cd trader; poetry run python ../analyse_logs.py --agent aea_0 --reset-db; cd ..
172 | ```
173 |
174 | For example, inspect the state transitions using the following command:
175 |
176 | ```bash
177 | cd trader; poetry run python ../analyse_logs.py --agent aea_0 --fsm --reset-db; cd ..
178 | ```
179 |
180 | This will output the different state transitions of your agent per period, for example:
181 |
182 | 
183 |
184 | For more options on the above command run:
185 |
186 | ```bash
187 | cd trader; poetry run autonomy analyse logs --help; cd ..
188 | ```
189 |
190 | or take a look at the [command documentation](https://docs.autonolas.network/open-autonomy/advanced_reference/commands/autonomy_analyse/#autonomy-analyse-logs).
191 |
192 | ## Update between versions
193 |
194 | Simply pull the latest script:
195 |
196 | ```bash
197 | git pull origin
198 | ```
199 |
200 | Remove the existing trader folder:
201 |
202 | ```bash
203 | rm -rf trader
204 | ```
205 |
206 | Then continue above with "Run the script".
207 |
208 | ## Change the password of your key files
209 |
210 | > :warning: **Warning**
211 | > The code within this repository is provided without any warranties. It is important to note that the code has not been audited for potential security vulnerabilities.
212 | >
213 | > If you are updating the password for your key files, it is strongly advised to [create a backup](https://github.com/valory-xyz/trader-quickstart#backup-and-recovery) of the old configuration (located in the `./trader_runner` folder) before proceeding. This backup should be retained until you can verify that the changes are functioning as expected. For instance, run the service multiple times to ensure there are no issues with the new password before discarding the backup.
214 |
215 | If you have started you script specifying a password to protect your key files, you can change it by running the following command:
216 |
217 | ```bash
218 | cd trader; poetry run python ../scripts/change_keys_json_password.py ../.trader_runner --current_password --new_password ; cd ..
219 | ```
220 |
221 | This will change the password in the following files:
222 |
223 | - `.trader_runner/keys.json`
224 | - `.trader_runner/operator_keys.json`
225 | - `.trader_runner/agent_pkey.txt`
226 | - `.trader_runner/operator_pkey.txt`
227 |
228 | If your key files are not encrypted, you must not use the `--current-password` argument. If you want to remove the password protection of your key files,
229 | you must not specify the `--new-password` argument.
230 |
231 | ## Advice for Mac users
232 |
233 | In Docker Desktop make sure that in `Settings -> Advanced` the following boxes are ticked
234 |
235 | 
236 |
237 | ## Advice for Windows users using Git BASH
238 |
239 | We provide some hints to have your Windows system ready to run the agent. The instructions below have been tested in Windows 11.
240 |
241 | Execute the following steps in a PowerShell terminal:
242 |
243 | 1. Install [Git](https://git-scm.com/download/win) and Git Bash:
244 |
245 | ```bash
246 | winget install --id Git.Git -e --source winget
247 | ```
248 |
249 | 2. Install Python 3.10:
250 |
251 | ```bash
252 | winget install Python.Python.3.10
253 | ```
254 |
255 | 3. Close and re-open the PowerShell terminal.
256 |
257 | 4. Install [Poetry](https://python-poetry.org/docs/):
258 |
259 | ```bash
260 | curl.exe -sSL https://install.python-poetry.org | python -
261 | ```
262 |
263 | 5. Add Poetry to your user's path:
264 |
265 | ```bash
266 | $existingUserPath = (Get-Item -Path HKCU:\Environment).GetValue("PATH", $null, "DoNotExpandEnvironmentNames")
267 |
268 | $newUserPath = "$existingUserPath;$Env:APPDATA\Python\Scripts"
269 |
270 | [System.Environment]::SetEnvironmentVariable("Path", $newUserPath, "User")
271 | ```
272 |
273 | 6. Install [Docker Desktop](https://www.docker.com/products/docker-desktop/):
274 |
275 | ```bash
276 | winget install -e --id Docker.DockerDesktop
277 | ```
278 |
279 | 7. Log out of your Windows session and then log back in.
280 |
281 | 8. Open [Docker Desktop](https://www.docker.com/products/docker-desktop/) and leave it opened in the background.
282 |
283 | Now, open a Git Bash terminal and follow the instructions in the "[Run the script](#run-the-script)" section as well as the subsequent sections. You might need to install Microsoft Visual C++ 14.0 or greater.
284 |
285 | ## Advanced usage
286 |
287 | This chapter is for advanced users who want to further customize the trader agent's behaviour without changing the underlying trading logic.
288 |
289 | ##### Policy weights
290 |
291 | This script automatically sets some default weights to the agent's policy as a warm start.
292 | to help convergence and improve tool selection.
293 | These data were obtained after many days of running the service and are set
294 | [here](https://github.com/valory-xyz/trader-quickstart/blob/0f093ebbf0857b8484a017912c3992f00fbe1a29/run_service.sh#L133-L137).
295 | As a result, the current weights are always deleted and replaced by this strategy
296 | which is considered to boost the initial performance of the service.
297 |
298 | However, you may have found better performing policy weights and would like to remove this logic.
299 | It can easily be done, by removing this method call,
300 | [here](https://github.com/valory-xyz/trader-quickstart/blob/0f093ebbf0857b8484a017912c3992f00fbe1a29/run_service.sh#L698),
301 | in order to set your own custom warm start.
302 | Setting your own custom weights can be done by editing the corresponding files in `.trader_runner`.
303 | Moreover, you may store your current policy as a backup before editing those files, using the following set of commands:
304 |
305 | ```shell
306 | cp ".trader_runner/available_tools_store.json" ".trader_runner/available_tools_store_$(date +"%d-%m-%Y")".json
307 | cp ".trader_runner/policy_store.json" ".trader_runner/policy_store_$(date +"%d-%m-%Y")".json
308 | cp ".trader_runner/utilized_tools.json" ".trader_runner/utilized_tools_$(date +"%d-%m-%Y")".json
309 | ```
310 |
311 | ##### Tool selection
312 |
313 | Sometimes, a mech tool might temporarily return invalid results.
314 | As a result, the service would end up performing mech calls without being able to use the response.
315 | Assuming that this tool has a large reward rate in the policy weights,
316 | the service might end up spending a considerable amount of xDAI before adjusting the tool's reward rate,
317 | without making any progress.
318 | If a tool is temporarily misbehaving, you could use an environment variable in order to exclude it.
319 | This environment variable is defined
320 | [here](https://github.com/valory-xyz/trader/blob/v0.8.0/packages/valory/services/trader/service.yaml#L109-L112)
321 | and can be overriden by setting it anywhere in the `run_service.sh` script with a new value, e.g.:
322 |
323 | ```shell
324 | IRRELEVANT_TOOLS=["some-misbehaving-tool", "openai-text-davinci-002", "openai-text-davinci-003", "openai-gpt-3.5-turbo", "openai-gpt-4", "stabilityai-stable-diffusion-v1-5", "stabilityai-stable-diffusion-xl-beta-v2-2-2", "stabilityai-stable-diffusion-512-v2-1", "stabilityai-stable-diffusion-768-v2-1"]
325 | ```
326 |
327 | ##### Environment variables
328 |
329 | You may customize the agent's behaviour by setting these trader-specific environment variables.
330 |
331 | | Name | Type | Default Value | Description |
332 | | --- | --- | --- | --- |
333 | | `ON_CHAIN_SERVICE_ID` | `int` | `null` | The ID of the on-chain service. |
334 | | `OMEN_CREATORS` | `list` | `["0x89c5cc945dd550BcFfb72Fe42BfF002429F46Fec"]` | The addresses of the market creator(s) that the service will track. |
335 | | `OPENING_MARGIN` | `int` | `300` | The markets opening before this margin will not be fetched. |
336 | | `LANGUAGES` | `list` | `["en_US"]` | Filter questions by languages. |
337 | | `SAMPLE_BETS_CLOSING_DAYS` | `int` | `10` | Sample the bets that are closed within this number of days. |
338 | | `TRADING_STRATEGY` | `str` | `kelly_criterion_no_conf` | Trading strategy to use. |
339 | | `USE_FALLBACK_STRATEGY` | `bool` | `true` | Whether to use the fallback strategy. |
340 | | `BET_THRESHOLD` | `int` | `100000000000000000` | Threshold (wei) for placing a bet. A bet will only be placed if `potential_net_profit` - `BET_THRESHOLD` >= 0. |
341 | | `PROMPT_TEMPLATE` | `str` | `With the given question "@{question}" and the 'yes' option represented by '@{yes}' and the 'no' option represented by '@{no}', what are the respective probabilities of 'p_yes' and 'p_no' occurring?` | The prompt template to use for prompting the mech. |
342 | | `DUST_THRESHOLD` | `int` | `10000000000000` | Minimum amount (wei) below which a position's redeeming amount will be considered dust. |
343 | | `POLICY_EPSILON` | `float` | `0.1` | Epsilon value for the e-Greedy policy for the tool selection based on tool accuracy. |
344 | | `DISABLE_TRADING` | `bool` | `false` | Whether to disable trading. |
345 | | `STOP_TRADING_IF_STAKING_KPI_MET` | `bool` | `true` | Whether to stop trading if the staking KPI is met. |
346 | | `AGENT_BALANCE_THRESHOLD` | `int` | `10000000000000000` | Balance threshold (wei) below which the agent will stop trading and a refill will be required. |
347 | | `REFILL_CHECK_INTERVAL` | `int` | `10` | Interval in seconds to check the agent balance, when waiting for a refill. |
348 | | `FILE_HASH_TO_STRATEGIES_JSON` | `list` | `[["bafybeihufqu2ra7vud4h6g2nwahx7mvdido7ff6prwnib2tdlc4np7dw24",["bet_amount_per_threshold"]],["bafybeibxfp27rzrfnp7sxq62vwv32pdvrijxi7vzg7ihukkaka3bwzrgae",["kelly_criterion_no_conf"]]]` | A list of mapping from ipfs file hash to strategy names. |
349 | | `STRATEGIES_KWARGS` | `list` | `[["bet_kelly_fraction",1.0],["floor_balance",500000000000000000],["bet_amount_per_threshold",{"0.0":0,"0.1":0,"0.2":0,"0.3":0,"0.4":0,"0.5":0,"0.6":60000000000000000,"0.7":90000000000000000,"0.8":100000000000000000,"0.9":1000000000000000000,"1.0":10000000000000000000}]]` | A list of keyword arguments for the strategies. |
350 | | `USE_SUBGRAPH_FOR_REDEEMING` | `bool` | `true` | Whether to use the subgraph to check if a position is redeemed. |
351 | | `USE_NEVERMINED` | `bool` | `false` | Whether to use Nevermined. |
352 | | `SUBSCRIPTION_PARAMS` | `list` | `[["base_url", "https://marketplace-api.gnosis.nevermined.app/api/v1/metadata/assets/ddo"],["did", "did:nv:01706149da2f9f3f67cf79ec86c37d63cec87fc148f5633b12bf6695653d5b3c"],["escrow_payment_condition_address", "0x31B2D187d674C9ACBD2b25f6EDce3d2Db2B7f446"],["lock_payment_condition_address", "0x2749DDEd394196835199471027713773736bffF2"],["transfer_nft_condition_address", "0x659fCA7436936e9fe8383831b65B8B442eFc8Ea8"],["token_address", "0x1b5DeaD7309b56ca7663b3301A503e077Be18cba"], ["order_address","0x72201948087aE83f8Eac22cf7A9f2139e4cFA829"], ["nft_amount", "100"], ["payment_token","0x0000000000000000000000000000000000000000"], ["order_address", "0x72201948087aE83f8Eac22cf7A9f2139e4cFA829"],["price", "1000000000000000000"]]` | Parameters for the Nevermined subscription. |
353 |
354 | The rest of the common environment variables are present in the [service.yaml](https://github.com/valory-xyz/trader/blob/v0.18.2/packages/valory/services/trader/service.yaml), which are customizable too.
355 |
356 | ##### Checking agents' health
357 |
358 | You may check the health of the agents by querying the `/healthcheck` endpoint. For example:
359 |
360 | ```shell
361 | curl -sL localhost:8716/healthcheck | jq -C
362 | ```
363 |
364 | This will return a JSON output with the following fields:
365 |
366 | | Field | Type | Criteria |
367 | | --- | --- | --- |
368 | | `seconds_since_last_transition` | float | The number of seconds passed since the last transition in the FSM. |
369 | | `is_tm_healthy` | bool | `false` if more than `BLOCKS_STALL_TOLERANCE` (60) seconds have passed since the last begin block request received from the tendermint node. `true` otherwise. |
370 | | `period` | int | The number of full cycles completed in the FSM. |
371 | | `reset_pause_duration` | int | The number of seconds to wait before starting the next FSM cycle. |
372 | | `rounds` | list | The last rounds (upto 25) in the FSM that happened including the current one. |
373 | | `is_transitioning_fast` | bool | `true` if `is_tm_healthy` is `true` and `seconds_since_last_transition` is less than twice the `reset_pause_duration`. `false` otherwise. |
374 |
375 | So, you can usually use `is_transitioning_fast` as a rule to check if an agent is healthly. To add a more strict check, you can also tune a threshold for the `seconds_since_last_transition` and rate of change of `period`, but that will require some monitoring to fine tune it.
376 |
377 | ## Backup and Recovery
378 |
379 | When executed for the first time, the `run_service.sh` script creates a number of Gnosis chain accounts:
380 |
381 | - one EOA account will be used as the service owner and agent operator,
382 | - one EOA account will be used for the trading agent, and
383 | - one smart contract account corresponds to a [Safe](https://app.safe.global/) wallet with a single owner (the agent account).
384 |
385 | The addresses and private keys of the EOA accounts (plus some additional configuration) are stored within the folder `.trader_runner`. In order to avoid losing your assets, back up this folder in a safe place, and do not publish or share its contents with unauthorized parties.
386 |
387 | You can gain access to the assets of your service as follows:
388 |
389 | 1. Ensure that your service is stopped by running `stop_service.sh`.
390 | 2. Ensure that you have a hot wallet (e.g., [MetaMask](https://metamask.io/)) installed and set up in your browser.
391 | 3. Import the two EOAs accounts using the private keys. In MetaMask, select "Add account or hardware wallet" → "Import account" → "Select Type: Private Key", and enter the private key of the owner/operator EOA account (located in `.trader_runner/operator_pkey.txt`):
392 |
393 |
394 | 4. Repeat the same process with the agent EOA account (private key located in `.trader_runner/agent_pkey.json`).
395 |
396 | Now, you have full access through the hot wallet to the EOAs addresses associated to your service and you can transfer their assets to any other address. You can also manage the assets of the service Safe through the DApp https://app.safe.global/, using the address located in the file `.trader_runner/service_safe_address.txt`.
397 |
398 | ## Terminate your on-chain service
399 |
400 | If you wish to terminate your on-chain service (and receive back the staking/bonding funds to your owner/operator address in case your service is staked) execute:
401 |
402 | ```bash
403 | ./stop_service.sh
404 | ./terminate_on_chain_service.sh
405 | ```
406 |
407 | ## RPC-related Error Messages
408 |
409 | When updating the service, you may need to re-run the script if you obtain any of the following error messages:
410 |
411 | ```Error: Service terminatation failed with following error; ChainTimeoutError(Timed out when waiting for transaction to go through)
412 |
413 | Error: Service unbonding failed with following error; ChainTimeoutError(Timed out when waiting for transaction to go through)
414 |
415 | Error: Component mint failed with following error; ChainTimeoutError(Timed out when waiting for transaction to go through)
416 |
417 | Error: Service activation failed with following error; ChainTimeoutError(Timed out when waiting for transaction to go through)
418 |
419 | Error: Service deployment failed with following error; ChainTimeoutError(Timed out when waiting for transaction to go through)
420 |
421 | Error: Service terminatation failed with following error; ChainInteractionError({'code': -32010, 'message': 'AlreadyKnown'})
422 | ```
423 |
424 | ## Build deployments without executing the service
425 |
426 | The script builds both a Docker Compose deployment (on `./trader/trader_service/abci_build_????`)
427 | and a Kubernetes deployment (on `./trader/trader_service/abci_build_k8s`).
428 | Then, by default, the script will launch the local Docker Compose deployment.
429 | If you just want to build the deployment without executing the service
430 | (for example, if you are deploying to a custom Kubernetes cluster), then execute the script as:
431 |
432 | ```bash
433 | ./run_service.sh --build-only
434 | ```
435 |
--------------------------------------------------------------------------------
/analyse_logs.py:
--------------------------------------------------------------------------------
1 | import os
2 | import subprocess
3 | import sys
4 | import argparse
5 |
6 |
7 | def _parse_args():
8 | """Parse the script arguments."""
9 | parser = argparse.ArgumentParser(description="Analyse agent logs.")
10 |
11 | parser.add_argument(
12 | "--service-dir",
13 | default="trader_service",
14 | help="The service directory containing build directories (default: 'trader_service')."
15 | )
16 | parser.add_argument(
17 | "--from-dir",
18 | help="Path to the logs directory. If not provided, it is auto-detected."
19 | )
20 | parser.add_argument(
21 | "--agent",
22 | default="aea_0",
23 | help="The agent name to analyze (default: 'aea_0')."
24 | )
25 | parser.add_argument(
26 | "--reset-db",
27 | action="store_true",
28 | help="Use this flag to disable resetting the log database."
29 | )
30 | parser.add_argument(
31 | "--start-time",
32 | help="Start time in `YYYY-MM-DD H:M:S,MS` format."
33 | )
34 | parser.add_argument(
35 | "--end-time",
36 | help="End time in `YYYY-MM-DD H:M:S,MS` format."
37 | )
38 | parser.add_argument(
39 | "--log-level",
40 | choices=["INFO", "DEBUG", "WARNING", "ERROR", "CRITICAL"],
41 | help="Logging level."
42 | )
43 | parser.add_argument(
44 | "--period",
45 | type=int,
46 | help="Period ID."
47 | )
48 | parser.add_argument(
49 | "--round",
50 | help="Round name."
51 | )
52 | parser.add_argument(
53 | "--behaviour",
54 | help="Behaviour name filter."
55 | )
56 | parser.add_argument(
57 | "--fsm",
58 | action="store_true",
59 | help="Print only the FSM execution path."
60 | )
61 | parser.add_argument(
62 | "--include-regex",
63 | help="Regex pattern to include in the result."
64 | )
65 | parser.add_argument(
66 | "--exclude-regex",
67 | help="Regex pattern to exclude from the result."
68 | )
69 |
70 | return parser.parse_args()
71 |
72 |
73 | def find_build_directory(service_dir):
74 | """Find the appropriate build directory within the service directory."""
75 | try:
76 | # create a list of all build directories
77 | build_dirs = [
78 | d for d in os.listdir(service_dir)
79 | if d.startswith("abci_build_") and os.path.isdir(os.path.join(service_dir, d))
80 | ]
81 | # iterate through the build directories to find the one that contains logs
82 | for build_dir in build_dirs:
83 | build_dir = os.path.join(service_dir, build_dir)
84 | logs_dir = os.path.join(build_dir, "persistent_data", "logs")
85 | # Check if the logs folder exists and contains files
86 | if os.path.exists(logs_dir) and os.listdir(logs_dir):
87 | return build_dir
88 | return os.path.join(service_dir, "abci_build")
89 | except FileNotFoundError:
90 | print(f"Service directory '{service_dir}' not found")
91 | sys.exit(1)
92 |
93 |
94 | def run_analysis(logs_dir, **kwargs):
95 | """Run the log analysis command."""
96 | command = [
97 | "poetry", "run", "autonomy", "analyse", "logs",
98 | "--from-dir", logs_dir,
99 | ]
100 | if kwargs.get("agent"):
101 | command.extend(["--agent", kwargs.get("agent")])
102 | if kwargs.get("reset_db"):
103 | command.extend(["--reset-db"])
104 | if kwargs.get("start_time"):
105 | command.extend(["--start-time", kwargs.get("start_time")])
106 | if kwargs.get("end_time"):
107 | command.extend(["--end-time", kwargs.get("end_time")])
108 | if kwargs.get("log_level"):
109 | command.extend(["--log-level", kwargs.get("log_level")])
110 | if kwargs.get("period"):
111 | command.extend(["--period", kwargs.get("period")])
112 | if kwargs.get("round"):
113 | command.extend(["--round", kwargs.get("round")])
114 | if kwargs.get("behaviour"):
115 | command.extend(["--behaviour", kwargs.get("behaviour")])
116 | if kwargs.get("fsm"):
117 | command.extend(["--fsm"])
118 | if kwargs.get("include_regex"):
119 | command.extend(["--include-regex", kwargs.get("include_regex")])
120 | if kwargs.get("exclude_regex"):
121 | command.extend(["--exclude-regex", kwargs.get("exclude_regex")])
122 |
123 | try:
124 | subprocess.run(command, check=True)
125 | print("Analysis completed successfully.")
126 | except subprocess.CalledProcessError as e:
127 | print(f"Command failed with exit code {e.returncode}")
128 | sys.exit(e.returncode)
129 | except FileNotFoundError:
130 | print("Poetry or autonomy not found. Ensure they are installed and accessible.")
131 | sys.exit(1)
132 |
133 |
134 | if __name__ == "__main__":
135 | # Parse user arguments
136 | args = _parse_args()
137 |
138 | # Determine the logs directory
139 | if args.from_dir:
140 | logs_dir = args.from_dir
141 | if not os.path.exists(logs_dir):
142 | print(f"Specified logs directory '{logs_dir}' not found.")
143 | sys.exit(1)
144 | else:
145 | # Auto-detect the logs directory
146 | build_dir = find_build_directory(args.service_dir)
147 | logs_dir = os.path.join(build_dir, "persistent_data", "logs")
148 | if not os.path.exists(logs_dir):
149 | print(f"Logs directory '{logs_dir}' not found.")
150 | sys.exit(1)
151 |
152 | # Run the analysis
153 | run_analysis(logs_dir, **vars(args))
154 |
--------------------------------------------------------------------------------
/claim_staking_rewards.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | # ------------------------------------------------------------------------------
4 | #
5 | # Copyright 2023-2024 Valory AG
6 | #
7 | # Licensed under the Apache License, Version 2.0 (the "License");
8 | # you may not use this file except in compliance with the License.
9 | # You may obtain a copy of the License at
10 | #
11 | # http://www.apache.org/licenses/LICENSE-2.0
12 | #
13 | # Unless required by applicable law or agreed to in writing, software
14 | # distributed under the License is distributed on an "AS IS" BASIS,
15 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16 | # See the License for the specific language governing permissions and
17 | # limitations under the License.
18 | #
19 | # ------------------------------------------------------------------------------
20 |
21 | cd trader; poetry run python "../scripts/claim_staking_rewards.py"; cd ..
22 |
--------------------------------------------------------------------------------
/images/docker.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/valory-xyz/trader-quickstart/1274e7992b107ffbde07d8e7327bcf3e1393b474/images/docker.png
--------------------------------------------------------------------------------
/images/metamask_import_private_key.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/valory-xyz/trader-quickstart/1274e7992b107ffbde07d8e7327bcf3e1393b474/images/metamask_import_private_key.png
--------------------------------------------------------------------------------
/images/staking_fsm.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
--------------------------------------------------------------------------------
/images/trader_fsm_transitions.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/valory-xyz/trader-quickstart/1274e7992b107ffbde07d8e7327bcf3e1393b474/images/trader_fsm_transitions.png
--------------------------------------------------------------------------------
/rank_traders.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 | # -*- coding: utf-8 -*-
3 | # ------------------------------------------------------------------------------
4 | #
5 | # Copyright 2022-2023 Valory AG
6 | #
7 | # Licensed under the Apache License, Version 2.0 (the "License");
8 | # you may not use this file except in compliance with the License.
9 | # You may obtain a copy of the License at
10 | #
11 | # http://www.apache.org/licenses/LICENSE-2.0
12 | #
13 | # Unless required by applicable law or agreed to in writing, software
14 | # distributed under the License is distributed on an "AS IS" BASIS,
15 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16 | # See the License for the specific language governing permissions and
17 | # limitations under the License.
18 | #
19 | # ------------------------------------------------------------------------------
20 |
21 | """This script queries the OMEN subgraph to obtain the trades of a given address."""
22 |
23 | import datetime
24 | import os
25 | import sys
26 | from argparse import ArgumentParser
27 | from collections import defaultdict
28 | from dotenv import load_dotenv
29 | from pathlib import Path
30 | from string import Template
31 | from typing import Any
32 |
33 | import requests
34 | import trades
35 | from trades import MarketAttribute, MarketState, wei_to_xdai
36 |
37 |
38 | QUERY_BATCH_SIZE = 1000
39 | DUST_THRESHOLD = 10000000000000
40 | INVALID_ANSWER = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF
41 | FPMM_CREATOR = "0x89c5cc945dd550bcffb72fe42bff002429f46fec"
42 | DEFAULT_FROM_DATE = "1970-01-01T00:00:00"
43 | DEFAULT_TO_DATE = "2038-01-19T03:14:07"
44 | SCRIPT_PATH = Path(__file__).resolve().parent
45 | STORE_PATH = Path(SCRIPT_PATH, ".trader_runner")
46 | ENV_FILE = Path(STORE_PATH, ".env")
47 |
48 | load_dotenv(ENV_FILE)
49 |
50 |
51 | headers = {
52 | "Accept": "application/json, multipart/mixed",
53 | "Content-Type": "application/json",
54 | }
55 |
56 |
57 | omen_xdai_trades_query = Template(
58 | """
59 | {
60 | fpmmTrades(
61 | where: {
62 | type: Buy,
63 | fpmm_: {
64 | creator: "${fpmm_creator}"
65 | creationTimestamp_gte: "${fpmm_creationTimestamp_gte}",
66 | creationTimestamp_lt: "${fpmm_creationTimestamp_lte}"
67 | },
68 | creationTimestamp_gte: "${creationTimestamp_gte}",
69 | creationTimestamp_lte: "${creationTimestamp_lte}"
70 | id_gt: "${id_gt}"
71 | }
72 | first: ${first}
73 | orderBy: id
74 | orderDirection: asc
75 | ) {
76 | id
77 | title
78 | collateralToken
79 | outcomeTokenMarginalPrice
80 | oldOutcomeTokenMarginalPrice
81 | type
82 | creator {
83 | id
84 | }
85 | creationTimestamp
86 | collateralAmount
87 | collateralAmountUSD
88 | feeAmount
89 | outcomeIndex
90 | outcomeTokensTraded
91 | transactionHash
92 | fpmm {
93 | id
94 | outcomes
95 | title
96 | answerFinalizedTimestamp
97 | currentAnswer
98 | isPendingArbitration
99 | arbitrationOccurred
100 | openingTimestamp
101 | condition {
102 | id
103 | }
104 | }
105 | }
106 | }
107 | """
108 | )
109 |
110 | ATTRIBUTE_CHOICES = {i.name: i for i in MarketAttribute}
111 |
112 |
113 | def _parse_args() -> Any:
114 | """Parse the creator positional argument."""
115 | parser = ArgumentParser(description="Get trades on Omen for a Safe address.")
116 | parser.add_argument(
117 | "--from-date",
118 | type=datetime.datetime.fromisoformat,
119 | default=DEFAULT_FROM_DATE,
120 | help="Start date for trades (UTC) in YYYY-MM-DD:HH:mm:ss format",
121 | )
122 | parser.add_argument(
123 | "--to-date",
124 | type=datetime.datetime.fromisoformat,
125 | default=DEFAULT_TO_DATE,
126 | help="End date for trades (UTC) in YYYY-MM-DD:HH:mm:ss format",
127 | )
128 | parser.add_argument(
129 | "--fpmm-created-from-date",
130 | type=datetime.datetime.fromisoformat,
131 | default=DEFAULT_FROM_DATE,
132 | help="Start date for market open date (UTC) in YYYY-MM-DD:HH:mm:ss format",
133 | )
134 | parser.add_argument(
135 | "--fpmm-created-to-date",
136 | type=datetime.datetime.fromisoformat,
137 | default=DEFAULT_TO_DATE,
138 | help="End date for market open date (UTC) in YYYY-MM-DD:HH:mm:ss format",
139 | )
140 | parser.add_argument(
141 | "--sort-by",
142 | choices=list(MarketAttribute),
143 | default=MarketAttribute.ROI,
144 | type=MarketAttribute.argparse,
145 | help="Specify the market attribute for sorting.",
146 | )
147 | args = parser.parse_args()
148 |
149 | args.from_date = args.from_date.replace(tzinfo=datetime.timezone.utc)
150 | args.to_date = args.to_date.replace(tzinfo=datetime.timezone.utc)
151 | args.fpmm_created_from_date = args.fpmm_created_from_date.replace(
152 | tzinfo=datetime.timezone.utc
153 | )
154 | args.fpmm_created_to_date = args.fpmm_created_to_date.replace(
155 | tzinfo=datetime.timezone.utc
156 | )
157 |
158 | return args
159 |
160 |
161 | def _to_content(q: str) -> dict[str, Any]:
162 | """Convert the given query string to payload content, i.e., add it under a `queries` key and convert it to bytes."""
163 | finalized_query = {
164 | "query": q,
165 | "variables": None,
166 | "extensions": {"headers": None},
167 | }
168 | return finalized_query
169 |
170 |
171 | def _query_omen_xdai_subgraph(
172 | from_timestamp: float,
173 | to_timestamp: float,
174 | fpmm_from_timestamp: float,
175 | fpmm_to_timestamp: float,
176 | ) -> dict[str, Any]:
177 | """Query the subgraph."""
178 | subgraph_api_key = os.getenv('SUBGRAPH_API_KEY')
179 | url = f"https://gateway-arbitrum.network.thegraph.com/api/{subgraph_api_key}/subgraphs/id/9fUVQpFwzpdWS9bq5WkAnmKbNNcoBwatMR4yZq81pbbz"
180 |
181 | grouped_results = defaultdict(list)
182 | id_gt = ""
183 |
184 | while True:
185 | query = omen_xdai_trades_query.substitute(
186 | fpmm_creator=FPMM_CREATOR.lower(),
187 | creationTimestamp_gte=int(from_timestamp),
188 | creationTimestamp_lte=int(to_timestamp),
189 | fpmm_creationTimestamp_gte=int(fpmm_from_timestamp),
190 | fpmm_creationTimestamp_lte=int(fpmm_to_timestamp),
191 | first=QUERY_BATCH_SIZE,
192 | id_gt=id_gt,
193 | )
194 | content_json = _to_content(query)
195 | res = requests.post(url, headers=headers, json=content_json)
196 | result_json = res.json()
197 | user_trades = result_json.get("data", {}).get("fpmmTrades", [])
198 |
199 | if not user_trades:
200 | break
201 |
202 | for trade in user_trades:
203 | fpmm_id = trade.get("fpmm", {}).get("id")
204 | grouped_results[fpmm_id].append(trade)
205 |
206 | id_gt = user_trades[len(user_trades) - 1]["id"]
207 |
208 | all_results = {
209 | "data": {
210 | "fpmmTrades": [
211 | trade
212 | for trades_list in grouped_results.values()
213 | for trade in trades_list
214 | ]
215 | }
216 | }
217 |
218 | return all_results
219 |
220 |
221 | def _group_trades_by_creator(trades_json: dict[str, Any]) -> dict[str, Any]:
222 | """Group trades by creator ID from the given JSON data."""
223 |
224 | fpmm_trades = trades_json["data"]["fpmmTrades"]
225 | trades_by_creator = defaultdict(list)
226 |
227 | for trade in fpmm_trades:
228 | _creator_id = trade["creator"]["id"]
229 | trades_by_creator[_creator_id].append(trade)
230 |
231 | _creator_to_trades = {
232 | creator_id: {"data": {"fpmmTrades": trades}}
233 | for creator_id, trades in trades_by_creator.items()
234 | }
235 | return _creator_to_trades
236 |
237 |
238 | def _print_user_summary(
239 | creator_to_statistics: dict[str, Any],
240 | sort_by_attribute: MarketAttribute = MarketAttribute.ROI,
241 | state: MarketState = MarketState.CLOSED,
242 | ) -> None:
243 | """Prints user ranking."""
244 |
245 | sorted_users = sorted(
246 | creator_to_statistics.items(),
247 | key=lambda item: item[1][sort_by_attribute][state],
248 | reverse=True,
249 | )
250 |
251 | print("")
252 | title = f"User summary for {state} markets sorted by {sort_by_attribute}:"
253 | print()
254 | print("-" * len(title))
255 | print(title)
256 | print("-" * len(title))
257 | print("")
258 |
259 | titles = [
260 | "User ID".ljust(42),
261 | "Ntrades".rjust(8),
262 | "Nwins".rjust(8),
263 | "Nredem".rjust(8),
264 | "Investment".rjust(13),
265 | "Fees".rjust(13),
266 | "Earnings".rjust(13),
267 | "Net Earn.".rjust(13),
268 | "Redemptions".rjust(13),
269 | "ROI".rjust(9),
270 | "\n",
271 | ]
272 |
273 | output = "".join(titles)
274 | for user_id, statistics_table in sorted_users:
275 | values = [
276 | user_id,
277 | str(statistics_table[MarketAttribute.NUM_TRADES][state]).rjust(8),
278 | str(statistics_table[MarketAttribute.WINNER_TRADES][state]).rjust(8),
279 | str(statistics_table[MarketAttribute.NUM_REDEEMED][state]).rjust(8),
280 | wei_to_xdai(statistics_table[MarketAttribute.INVESTMENT][state]).rjust(13),
281 | wei_to_xdai(statistics_table[MarketAttribute.FEES][state]).rjust(13),
282 | wei_to_xdai(statistics_table[MarketAttribute.EARNINGS][state]).rjust(13),
283 | wei_to_xdai(statistics_table[MarketAttribute.NET_EARNINGS][state]).rjust(13),
284 | wei_to_xdai(statistics_table[MarketAttribute.REDEMPTIONS][state]).rjust(13),
285 | f"{statistics_table[MarketAttribute.ROI][state] * 100.0:7.2f}%".rjust(9),
286 | "\n",
287 | ]
288 | output += "".join(values)
289 |
290 | print(output)
291 |
292 |
293 | def _print_progress_bar( # pylint: disable=too-many-arguments
294 | iteration: int,
295 | total: int,
296 | prefix: str = "Computing statistics:",
297 | suffix: str = "Complete",
298 | length: int = 50,
299 | fill: str = "#",
300 | ) -> None:
301 | """Prints the progress bar"""
302 | if len(fill) != 1:
303 | raise ValueError("Fill character must be a single character.")
304 |
305 | percent = ("{0:.1f}").format(100 * (iteration / float(total)))
306 | filled_length = int(length * iteration // total)
307 | bar = fill * filled_length + "-" * (length - filled_length)
308 | progress_string = f"({iteration} of {total}) - {percent}%"
309 | sys.stdout.write("\r%s |%s| %s %s" % (prefix, bar, progress_string, suffix))
310 | sys.stdout.flush()
311 |
312 |
313 | if __name__ == "__main__":
314 | print("Starting script")
315 | user_args = _parse_args()
316 |
317 | with open(trades.RPC_PATH, "r", encoding="utf-8") as rpc_file:
318 | rpc = rpc_file.read()
319 |
320 | print("Querying Thegraph...")
321 | all_trades_json = _query_omen_xdai_subgraph(
322 | user_args.from_date.timestamp(),
323 | user_args.to_date.timestamp(),
324 | user_args.fpmm_created_from_date.timestamp(),
325 | user_args.fpmm_created_to_date.timestamp(),
326 | )
327 | print(f'Total trading transactions: {len(all_trades_json["data"]["fpmmTrades"])}')
328 |
329 | creator_to_trades = _group_trades_by_creator(all_trades_json)
330 | total_traders = len(creator_to_trades.items())
331 | print(f"Total traders: {total_traders}")
332 |
333 | creator_to_statistics = {}
334 | _print_progress_bar(0, total_traders)
335 | for i, (creator_id, trades_json_id) in enumerate(
336 | creator_to_trades.items(), start=1
337 | ):
338 | _, statistics_table_id = trades.parse_user(rpc, creator_id, trades_json_id, {})
339 | creator_to_statistics[creator_id] = statistics_table_id
340 | _print_progress_bar(i, total_traders)
341 |
342 | _print_user_summary(creator_to_statistics, user_args.sort_by)
343 |
--------------------------------------------------------------------------------
/report.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 | # -*- coding: utf-8 -*-
3 | # ------------------------------------------------------------------------------
4 | #
5 | # Copyright 2022-2023 Valory AG
6 | #
7 | # Licensed under the Apache License, Version 2.0 (the "License");
8 | # you may not use this file except in compliance with the License.
9 | # You may obtain a copy of the License at
10 | #
11 | # http://www.apache.org/licenses/LICENSE-2.0
12 | #
13 | # Unless required by applicable law or agreed to in writing, software
14 | # distributed under the License is distributed on an "AS IS" BASIS,
15 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16 | # See the License for the specific language governing permissions and
17 | # limitations under the License.
18 | #
19 | # ------------------------------------------------------------------------------
20 |
21 | """Obtains a report of the current service."""
22 |
23 | import json
24 | import math
25 | import time
26 | import traceback
27 | from argparse import ArgumentParser
28 | from collections import Counter
29 |
30 | from dotenv import dotenv_values
31 | from enum import Enum
32 | from pathlib import Path
33 | from typing import Any
34 |
35 | import docker
36 | import trades
37 | from trades import (
38 | MarketAttribute,
39 | MarketState,
40 | get_balance,
41 | get_token_balance,
42 | wei_to_olas,
43 | wei_to_unit,
44 | wei_to_wxdai,
45 | wei_to_xdai,
46 | )
47 | from web3 import HTTPProvider, Web3
48 |
49 |
50 | SCRIPT_PATH = Path(__file__).resolve().parent
51 | STORE_PATH = Path(SCRIPT_PATH, ".trader_runner")
52 | DOTENV_PATH = Path(STORE_PATH, ".env")
53 | RPC_PATH = Path(STORE_PATH, "rpc.txt")
54 | AGENT_KEYS_JSON_PATH = Path(STORE_PATH, "keys.json")
55 | OPERATOR_KEYS_JSON_PATH = Path(STORE_PATH, "operator_keys.json")
56 | SAFE_ADDRESS_PATH = Path(STORE_PATH, "service_safe_address.txt")
57 | SERVICE_ID_PATH = Path(STORE_PATH, "service_id.txt")
58 | STAKING_TOKEN_JSON_PATH = Path(
59 | SCRIPT_PATH,
60 | "trader",
61 | "packages",
62 | "valory",
63 | "contracts",
64 | "staking_token",
65 | "build",
66 | "StakingToken.json",
67 | )
68 | ACTIVITY_CHECKER_JSON_PATH = Path(
69 | SCRIPT_PATH,
70 | "trader",
71 | "packages",
72 | "valory",
73 | "contracts",
74 | "mech_activity",
75 | "build",
76 | "MechActivity.json",
77 | )
78 | SERVICE_REGISTRY_L2_JSON_PATH = Path(
79 | SCRIPT_PATH,
80 | "trader",
81 | "packages",
82 | "valory",
83 | "contracts",
84 | "service_registry",
85 | "build",
86 | "ServiceRegistryL2.json",
87 | )
88 | SERVICE_REGISTRY_TOKEN_UTILITY_JSON_PATH = Path(
89 | SCRIPT_PATH,
90 | "contracts",
91 | "ServiceRegistryTokenUtility.json",
92 | )
93 | MECH_CONTRACT_ADDRESS = "0x77af31De935740567Cf4fF1986D04B2c964A786a"
94 | MECH_CONTRACT_JSON_PATH = Path(
95 | SCRIPT_PATH,
96 | "trader",
97 | "packages",
98 | "valory",
99 | "contracts",
100 | "mech",
101 | "build",
102 | "mech.json",
103 | )
104 |
105 | SAFE_BALANCE_THRESHOLD = 500000000000000000
106 | AGENT_XDAI_BALANCE_THRESHOLD = 50000000000000000
107 | OPERATOR_XDAI_BALANCE_THRESHOLD = 50000000000000000
108 | MECH_REQUESTS_PER_EPOCH_THRESHOLD = 10
109 | TRADES_LOOKBACK_DAYS = 3
110 | MULTI_TRADE_LOOKBACK_DAYS = TRADES_LOOKBACK_DAYS
111 | SECONDS_PER_DAY = 60 * 60 * 24
112 |
113 | OUTPUT_WIDTH = 80
114 | TRADER_CONTAINER_PREFIX = "trader"
115 | AGENT_CONTAINER_IDENTIFIER = "abci"
116 | NODE_CONTAINER_IDENTIFIER = "tm"
117 |
118 |
119 | class ColorCode:
120 | """Terminal color codes"""
121 |
122 | GREEN = "\033[92m"
123 | RED = "\033[91m"
124 | YELLOW = "\033[93m"
125 | RESET = "\033[0m"
126 |
127 |
128 | class StakingState(Enum):
129 | """Staking state enumeration for the staking."""
130 |
131 | UNSTAKED = 0
132 | STAKED = 1
133 | EVICTED = 2
134 |
135 |
136 | def _color_string(text: str, color_code: str) -> str:
137 | return f"{color_code}{text}{ColorCode.RESET}"
138 |
139 |
140 | def _color_bool(
141 | is_true: bool, true_string: str = "True", false_string: str = "False"
142 | ) -> str:
143 | if is_true:
144 | return _color_string(true_string, ColorCode.GREEN)
145 | return _color_string(false_string, ColorCode.RED)
146 |
147 |
148 | def _color_percent(p: float, multiplier: float = 100, symbol: str = "%") -> str:
149 | if p >= 0:
150 | return f"{p*multiplier:.2f} {symbol}"
151 | return _color_string(f"{p*multiplier:.2f} {symbol}", ColorCode.RED)
152 |
153 |
154 | def _trades_since_message(trades_json: dict[str, Any], utc_ts: float = 0) -> str:
155 | filtered_trades = [
156 | trade
157 | for trade in trades_json.get("data", {}).get("fpmmTrades", [])
158 | if float(trade["creationTimestamp"]) >= utc_ts
159 | ]
160 | unique_markets = set(trade["fpmm"]["id"] for trade in filtered_trades)
161 | trades_count = len(filtered_trades)
162 | markets_count = len(unique_markets)
163 | return f"{trades_count} trades on {markets_count} markets"
164 |
165 |
166 | def _calculate_retrades_since(trades_json: dict[str, Any], utc_ts: float = 0) -> tuple[Counter[Any], int, int, int]:
167 | filtered_trades = Counter((
168 | trade.get("fpmm", {}).get("id", None)
169 | for trade in trades_json.get("data", {}).get("fpmmTrades", [])
170 | if float(trade.get("creationTimestamp", 0)) >= utc_ts
171 | ))
172 |
173 | if None in filtered_trades:
174 | raise ValueError(
175 | f"Unexpected format in trades_json: {filtered_trades[None]} trades have no associated market ID.")
176 |
177 | unique_markets = set(filtered_trades)
178 | n_unique_markets = len(unique_markets)
179 | n_trades = sum(filtered_trades.values())
180 | n_retrades = sum(n_bets - 1 for n_bets in filtered_trades.values() if n_bets > 1)
181 |
182 | return filtered_trades, n_unique_markets, n_trades, n_retrades
183 |
184 | def _retrades_since_message(n_unique_markets: int, n_trades: int, n_retrades: int) -> str:
185 | return f"{n_retrades} re-trades on total {n_trades} trades in {n_unique_markets} markets"
186 |
187 | def _average_trades_since_message(n_trades: int, n_markets: int) -> str:
188 | if not n_markets:
189 | average_trades = 0
190 | else:
191 | average_trades = round(n_trades / n_markets, 2)
192 |
193 | return f"{average_trades} trades per market"
194 |
195 | def _max_trades_per_market_since_message(filtered_trades: Counter[Any]) -> str:
196 | if not filtered_trades:
197 | max_trades = 0
198 | else:
199 | max_trades = max(filtered_trades.values())
200 |
201 | return f"{max_trades} trades per market"
202 |
203 |
204 | def _get_mech_requests_count(
205 | mech_requests: dict[str, Any], timestamp: float = 0
206 | ) -> int:
207 | return sum(
208 | 1
209 | for mech_request in mech_requests.values()
210 | if mech_request.get("block_timestamp", 0) > timestamp
211 | )
212 |
213 |
214 | def _print_section_header(header: str) -> None:
215 | print("\n\n" + header)
216 | print("=" * OUTPUT_WIDTH)
217 |
218 |
219 | def _print_subsection_header(header: str) -> None:
220 | print("\n" + header)
221 | print("-" * OUTPUT_WIDTH)
222 |
223 |
224 | def _print_status(key: str, value: str, message: str = "") -> None:
225 | print(f"{key:<30}{value:<10} {message or ''}")
226 |
227 |
228 | def _warning_message(current_value: int, threshold: int = 0, message: str = "") -> str:
229 | default_message = _color_string(
230 | f"- Balance too low. Threshold is {wei_to_unit(threshold):.2f}.",
231 | ColorCode.YELLOW,
232 | )
233 | if current_value < threshold:
234 | return (
235 | _color_string(f"{message}", ColorCode.YELLOW)
236 | if message
237 | else default_message
238 | )
239 | return ""
240 |
241 |
242 | def _get_agent_status() -> str:
243 | client = docker.from_env()
244 | agent_running = node_running = service_running = False
245 | for container in client.containers.list():
246 | container_name = container.name
247 | if TRADER_CONTAINER_PREFIX in container_name:
248 | if AGENT_CONTAINER_IDENTIFIER in container_name:
249 | agent_running = True
250 | if NODE_CONTAINER_IDENTIFIER in container_name:
251 | node_running = True
252 | if agent_running and node_running:
253 | service_running = True
254 | break
255 |
256 | return _color_bool(service_running, "Running", "Stopped")
257 |
258 |
259 | def _parse_args() -> Any:
260 | """Parse the script arguments."""
261 | parser = ArgumentParser(description="Get a report for a trader service.")
262 | args = parser.parse_args()
263 | return args
264 |
265 |
266 | if __name__ == "__main__":
267 | user_args = _parse_args()
268 |
269 | with open(AGENT_KEYS_JSON_PATH, "r", encoding="utf-8") as file:
270 | agent_keys_data = json.load(file)
271 | agent_address = agent_keys_data[0]["address"]
272 |
273 | with open(OPERATOR_KEYS_JSON_PATH, "r", encoding="utf-8") as file:
274 | operator_keys_data = json.load(file)
275 | operator_address = operator_keys_data[0]["address"]
276 |
277 | with open(SAFE_ADDRESS_PATH, "r", encoding="utf-8") as file:
278 | safe_address = file.read().strip()
279 |
280 | with open(SERVICE_ID_PATH, "r", encoding="utf-8") as file:
281 | service_id = int(file.read().strip())
282 |
283 | with open(RPC_PATH, "r", encoding="utf-8") as file:
284 | rpc = file.read().strip()
285 |
286 | env_file_vars = dotenv_values(DOTENV_PATH)
287 |
288 | # Prediction market trading
289 | mech_requests = trades.get_mech_requests(safe_address)
290 | mech_statistics = trades.get_mech_statistics(mech_requests)
291 | trades_json = trades._query_omen_xdai_subgraph(safe_address)
292 | _, statistics_table = trades.parse_user(
293 | rpc, safe_address, trades_json, mech_statistics
294 | )
295 |
296 | print("")
297 | print("==============")
298 | print("Service report")
299 | print("==============")
300 |
301 | # Performance
302 | _print_section_header("Performance")
303 | _print_subsection_header("Staking")
304 |
305 | try:
306 | w3 = Web3(HTTPProvider(rpc))
307 |
308 | staking_token_address = env_file_vars.get("CUSTOM_STAKING_ADDRESS")
309 | with open(STAKING_TOKEN_JSON_PATH, "r", encoding="utf-8") as file:
310 | staking_token_data = json.load(file)
311 |
312 | staking_token_abi = staking_token_data.get("abi", [])
313 | staking_token_contract = w3.eth.contract(
314 | address=staking_token_address, abi=staking_token_abi # type: ignore
315 | )
316 |
317 | staking_state = StakingState(
318 | staking_token_contract.functions.getStakingState(
319 | service_id
320 | ).call()
321 | )
322 |
323 | is_staked = (
324 | staking_state == StakingState.STAKED
325 | or staking_state == StakingState.EVICTED
326 | )
327 | _print_status("Is service staked?", _color_bool(is_staked, "Yes", "No"))
328 | if is_staked:
329 | _print_status("Staking program", env_file_vars.get("STAKING_PROGRAM")) # type: ignore
330 | if staking_state == StakingState.STAKED:
331 | _print_status("Staking state", staking_state.name)
332 | elif staking_state == StakingState.EVICTED:
333 | _print_status("Staking state", _color_string(staking_state.name, ColorCode.RED))
334 |
335 | if is_staked:
336 |
337 | activity_checker_address = staking_token_contract.functions.activityChecker().call()
338 | with open(ACTIVITY_CHECKER_JSON_PATH, "r", encoding="utf-8") as file:
339 | activity_checker_data = json.load(file)
340 |
341 | activity_checker_abi = activity_checker_data.get("abi", [])
342 | activity_checker_contract = w3.eth.contract(
343 | address=activity_checker_address, abi=activity_checker_abi # type: ignore
344 | )
345 |
346 | with open(
347 | SERVICE_REGISTRY_TOKEN_UTILITY_JSON_PATH, "r", encoding="utf-8"
348 | ) as file:
349 | service_registry_token_utility_data = json.load(file)
350 |
351 | service_registry_token_utility_contract_address = (
352 | staking_token_contract.functions.serviceRegistryTokenUtility().call()
353 | )
354 | service_registry_token_utility_abi = (
355 | service_registry_token_utility_data.get("abi", [])
356 | )
357 | service_registry_token_utility_contract = w3.eth.contract(
358 | address=service_registry_token_utility_contract_address,
359 | abi=service_registry_token_utility_abi,
360 | )
361 |
362 | mech_contract_address = env_file_vars.get("MECH_CONTRACT_ADDRESS")
363 | with open(MECH_CONTRACT_JSON_PATH, "r", encoding="utf-8") as file:
364 | mech_contract_data = json.load(file)
365 |
366 | mech_contract_abi = mech_contract_data.get("abi", [])
367 |
368 | mech_contract = w3.eth.contract(
369 | address=mech_contract_address, abi=mech_contract_abi # type: ignore
370 | )
371 |
372 | security_deposit = (
373 | service_registry_token_utility_contract.functions.getOperatorBalance(
374 | operator_address, service_id
375 | ).call()
376 | )
377 | agent_id = int(env_file_vars.get("AGENT_ID").strip())
378 | agent_bond = service_registry_token_utility_contract.functions.getAgentBond(
379 | service_id, agent_id
380 | ).call()
381 | min_staking_deposit = (
382 | staking_token_contract.functions.minStakingDeposit().call()
383 | )
384 |
385 | # In the setting 1 agent instance as of now: minOwnerBond = minStakingDeposit
386 | min_security_deposit = min_staking_deposit
387 | _print_status(
388 | "Staked (security deposit)",
389 | f"{wei_to_olas(security_deposit)} {_warning_message(security_deposit, min_security_deposit)}",
390 | )
391 | _print_status(
392 | "Staked (agent bond)",
393 | f"{wei_to_olas(agent_bond)} {_warning_message(agent_bond, min_staking_deposit)}",
394 | )
395 |
396 | service_info = staking_token_contract.functions.mapServiceInfo(
397 | service_id
398 | ).call()
399 | rewards = service_info[3]
400 | _print_status("Accrued rewards", f"{wei_to_olas(rewards)}")
401 |
402 | liveness_ratio = (
403 | activity_checker_contract.functions.livenessRatio().call()
404 | )
405 | mech_requests_24h_threshold = math.ceil(
406 | (liveness_ratio * 60 * 60 * 24) / 10**18
407 | )
408 |
409 | next_checkpoint_ts = (
410 | staking_token_contract.functions.getNextRewardCheckpointTimestamp().call()
411 | )
412 | liveness_period = (
413 | staking_token_contract.functions.livenessPeriod().call()
414 | )
415 | last_checkpoint_ts = next_checkpoint_ts - liveness_period
416 |
417 | mech_request_count = mech_contract.functions.getRequestsCount(safe_address).call()
418 | mech_request_count_on_last_checkpoint = (
419 | staking_token_contract.functions.getServiceInfo(service_id).call()
420 | )[2][1]
421 | mech_requests_since_last_cp = mech_request_count - mech_request_count_on_last_checkpoint
422 | # mech_requests_current_epoch = _get_mech_requests_count(
423 | # mech_requests, last_checkpoint_ts
424 | # )
425 | mech_requests_current_epoch = mech_requests_since_last_cp
426 | _print_status(
427 | "Num. Mech txs current epoch",
428 | f"{mech_requests_current_epoch} {_warning_message(mech_requests_current_epoch, mech_requests_24h_threshold, f'- Too low. Threshold is {mech_requests_24h_threshold}.')}",
429 | )
430 |
431 | except Exception: # pylint: disable=broad-except
432 | traceback.print_exc()
433 | print("An error occurred while interacting with the staking contract.")
434 |
435 | _print_subsection_header("Prediction market trading")
436 | _print_status(
437 | "ROI on closed markets",
438 | _color_percent(statistics_table[MarketAttribute.ROI][MarketState.CLOSED]),
439 | )
440 |
441 | since_ts = time.time() - SECONDS_PER_DAY * TRADES_LOOKBACK_DAYS
442 | _print_status(
443 | f"Trades on last {TRADES_LOOKBACK_DAYS} days",
444 | _trades_since_message(trades_json, since_ts),
445 | )
446 |
447 | #Multi trade strategy
448 | retrades_since_ts = time.time() - SECONDS_PER_DAY * MULTI_TRADE_LOOKBACK_DAYS
449 | filtered_trades, n_unique_markets, n_trades, n_retrades = _calculate_retrades_since(trades_json, retrades_since_ts)
450 | _print_subsection_header(f"Multi-trade markets in previous {MULTI_TRADE_LOOKBACK_DAYS} days")
451 | _print_status(f"Multi-trade markets", _retrades_since_message(n_unique_markets, n_trades, n_retrades))
452 | _print_status(f"Average trades per market", _average_trades_since_message(n_trades, n_unique_markets))
453 | _print_status(f"Max trades per market", _max_trades_per_market_since_message(filtered_trades))
454 |
455 | # Service
456 | _print_section_header("Service")
457 | _print_status("ID", str(service_id))
458 |
459 | # Agent
460 | agent_status = _get_agent_status()
461 | agent_xdai = get_balance(agent_address, rpc)
462 | _print_subsection_header("Agent")
463 | _print_status("Status (on this machine)", agent_status)
464 | _print_status("Address", agent_address)
465 | _print_status(
466 | "xDAI Balance",
467 | f"{wei_to_xdai(agent_xdai)} {_warning_message(agent_xdai, AGENT_XDAI_BALANCE_THRESHOLD)}",
468 | )
469 |
470 | # Safe
471 | safe_xdai = get_balance(safe_address, rpc)
472 | safe_wxdai = get_token_balance(safe_address, trades.WXDAI_CONTRACT_ADDRESS, rpc)
473 | _print_subsection_header(
474 | f"Safe {_warning_message(safe_xdai + safe_wxdai, SAFE_BALANCE_THRESHOLD)}"
475 | )
476 | _print_status("Address", safe_address)
477 | _print_status("xDAI Balance", wei_to_xdai(safe_xdai))
478 | _print_status("WxDAI Balance", wei_to_wxdai(safe_wxdai))
479 |
480 | # Owner/Operator
481 | operator_xdai = get_balance(operator_address, rpc)
482 | _print_subsection_header("Owner/Operator")
483 | _print_status("Address", operator_address)
484 | _print_status(
485 | "xDAI Balance",
486 | f"{wei_to_xdai(operator_xdai)} {_warning_message(operator_xdai, OPERATOR_XDAI_BALANCE_THRESHOLD)}",
487 | )
488 | print("")
489 |
--------------------------------------------------------------------------------
/reset_staking.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | # force utf mode for python, cause sometimes there are issues with local codepages
4 | export PYTHONUTF8=1
5 |
6 |
7 | # Check if --attended flag is passed
8 | export ATTENDED=true
9 | for arg in "$@"; do
10 | if [ "$arg" = "--attended=false" ]; then
11 | export ATTENDED=false
12 | fi
13 | done
14 |
15 | cd trader; poetry run python ../scripts/choose_staking.py --reset; cd ..
16 |
--------------------------------------------------------------------------------
/scripts/__init__.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | # ------------------------------------------------------------------------------
3 | #
4 | # Copyright 2021-2023 Valory AG
5 | #
6 | # Licensed under the Apache License, Version 2.0 (the "License");
7 | # you may not use this file except in compliance with the License.
8 | # You may obtain a copy of the License at
9 | #
10 | # http://www.apache.org/licenses/LICENSE-2.0
11 | #
12 | # Unless required by applicable law or agreed to in writing, software
13 | # distributed under the License is distributed on an "AS IS" BASIS,
14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15 | # See the License for the specific language governing permissions and
16 | # limitations under the License.
17 | #
18 | # ------------------------------------------------------------------------------
19 |
20 | """This directory contains scripts for the trader-quickstart repository."""
21 |
--------------------------------------------------------------------------------
/scripts/change_keys_json_password.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 | # -*- coding: utf-8 -*-
3 | # ------------------------------------------------------------------------------
4 | #
5 | # Copyright 2022-2023 Valory AG
6 | #
7 | # Licensed under the Apache License, Version 2.0 (the "License");
8 | # you may not use this file except in compliance with the License.
9 | # You may obtain a copy of the License at
10 | #
11 | # http://www.apache.org/licenses/LICENSE-2.0
12 | #
13 | # Unless required by applicable law or agreed to in writing, software
14 | # distributed under the License is distributed on an "AS IS" BASIS,
15 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16 | # See the License for the specific language governing permissions and
17 | # limitations under the License.
18 | #
19 | # ------------------------------------------------------------------------------
20 |
21 | """Changes the key files password of the trader store."""
22 |
23 | import argparse
24 | import json
25 | import tempfile
26 | from pathlib import Path
27 |
28 | from aea.crypto.helpers import DecryptError, KeyIsIncorrect
29 | from aea_ledger_ethereum.ethereum import EthereumCrypto
30 |
31 |
32 | def _change_keys_json_password(
33 | keys_json_path: Path, pkey_txt_path: Path, current_password: str, new_password: str
34 | ) -> None: # pylint: disable=too-many-arguments
35 | keys_json_reencrypeted = []
36 | keys = json.load(keys_json_path.open("r"))
37 |
38 | with tempfile.TemporaryDirectory() as temp_dir:
39 | for idx, key in enumerate(keys):
40 | temp_file = Path(temp_dir, str(idx))
41 | temp_file.open("w+", encoding="utf-8").write(str(key["private_key"]))
42 | try:
43 | crypto = EthereumCrypto.load_private_key_from_path(
44 | str(temp_file), password=current_password
45 | )
46 |
47 | if new_password:
48 | new_private_key_value = (
49 | f"{json.dumps(crypto.encrypt(new_password))}"
50 | )
51 | else:
52 | print(
53 | "WARNING: No new password provided. Files will be not encrypted."
54 | )
55 | new_private_key_value = crypto.key.hex()
56 |
57 | keys_json_reencrypeted.append(
58 | {
59 | "address": crypto.address,
60 | "private_key": new_private_key_value,
61 | }
62 | )
63 | json.dump(keys_json_reencrypeted, keys_json_path.open("w+"), indent=2)
64 | print(f"Changed password {keys_json_path}")
65 |
66 | with open(pkey_txt_path, "w", encoding="utf-8") as file:
67 | if new_private_key_value.startswith("0x"):
68 | file.write(new_private_key_value[2:])
69 | else:
70 | file.write(new_private_key_value)
71 | print(f"Ovewritten {pkey_txt_path}")
72 | except (DecryptError, KeyIsIncorrect):
73 | print("Bad password provided.")
74 | except json.decoder.JSONDecodeError:
75 | print(
76 | "Wrong key file format. If key file is not encrypted, do not provide '--current_password' parameter"
77 | )
78 |
79 |
80 | if __name__ == "__main__":
81 | parser = argparse.ArgumentParser(description="Change key files password.")
82 | parser.add_argument(
83 | "store_path", type=str, help="Path to the trader store directory."
84 | )
85 | parser.add_argument(
86 | "--current_password",
87 | type=str,
88 | help="Current password. If not provided, it is assumed files are not encrypted.",
89 | )
90 | parser.add_argument(
91 | "--new_password",
92 | type=str,
93 | help="New password. If not provided, it will decrypt key files.",
94 | )
95 | args = parser.parse_args()
96 |
97 | for json_file, pkey_file in (
98 | ("keys", "agent_pkey"),
99 | ("operator_keys", "operator_pkey"),
100 | ):
101 | _change_keys_json_password(
102 | Path(args.store_path, f"{json_file}.json"),
103 | Path(args.store_path, f"{pkey_file}.txt"),
104 | args.current_password,
105 | args.new_password,
106 | )
107 | print("")
108 |
--------------------------------------------------------------------------------
/scripts/check_python.py:
--------------------------------------------------------------------------------
1 | import sys
2 |
3 | if (
4 | sys.version_info.major != 3
5 | or sys.version_info.minor < 8
6 | or sys.version_info.minor > 11
7 | ):
8 | print(
9 | "Python version >=3.8.0, <3.12.0 is required but found "
10 | f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}"
11 | )
12 | sys.exit(1)
13 |
14 | print(
15 | "Python version "
16 | f"{sys.version_info.major}.{sys.version_info.minor}.{sys.version_info.micro}"
17 | " is compatible\n"
18 | )
19 |
--------------------------------------------------------------------------------
/scripts/choose_staking.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 | # -*- coding: utf-8 -*-
3 | # ------------------------------------------------------------------------------
4 | #
5 | # Copyright 2023-2024 Valory AG
6 | #
7 | # Licensed under the Apache License, Version 2.0 (the "License");
8 | # you may not use this file except in compliance with the License.
9 | # You may obtain a copy of the License at
10 | #
11 | # http://www.apache.org/licenses/LICENSE-2.0
12 | #
13 | # Unless required by applicable law or agreed to in writing, software
14 | # distributed under the License is distributed on an "AS IS" BASIS,
15 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16 | # See the License for the specific language governing permissions and
17 | # limitations under the License.
18 | #
19 | # ------------------------------------------------------------------------------
20 |
21 | """Choose staking program."""
22 |
23 | import argparse
24 | import json
25 | import os
26 | import sys
27 | import textwrap
28 | from pathlib import Path
29 | from typing import Any, Dict, List
30 |
31 | import requests
32 | from dotenv import dotenv_values, set_key, unset_key
33 | from web3 import Web3
34 |
35 |
36 | SCRIPT_PATH = Path(__file__).resolve().parent
37 | STORE_PATH = Path(SCRIPT_PATH, "..", ".trader_runner")
38 | DOTENV_PATH = Path(STORE_PATH, ".env")
39 | RPC_PATH = Path(STORE_PATH, "rpc.txt")
40 | STAKING_TOKEN_INSTANCE_ABI_PATH = Path(
41 | SCRIPT_PATH,
42 | "..",
43 | "trader",
44 | "packages",
45 | "valory",
46 | "contracts",
47 | "staking_token",
48 | "build",
49 | "StakingToken.json",
50 | )
51 | STAKING_TOKEN_IMPLEMENTATION_ABI_PATH = STAKING_TOKEN_INSTANCE_ABI_PATH
52 | ACTIVITY_CHECKER_ABI_PATH = Path(
53 | SCRIPT_PATH,
54 | "..",
55 | "trader",
56 | "packages",
57 | "valory",
58 | "contracts",
59 | "mech_activity",
60 | "build",
61 | "MechActivity.json",
62 | )
63 |
64 | IPFS_ADDRESS = "https://gateway.autonolas.tech/ipfs/f01701220{hash}"
65 | NEVERMINED_MECH_CONTRACT_ADDRESS = "0x327E26bDF1CfEa50BFAe35643B23D5268E41F7F9"
66 | NEVERMINED_AGENT_REGISTRY_ADDRESS = "0xAed729d4f4b895d8ca84ba022675bB0C44d2cD52"
67 | NEVERMINED_MECH_REQUEST_PRICE = "0"
68 | ZERO_ADDRESS = "0x0000000000000000000000000000000000000000"
69 | DEPRECATED_TEXT = "(DEPRECATED)"
70 | NO_STAKING_PROGRAM_ID = "no_staking"
71 | NO_STAKING_PROGRAM_METADATA = {
72 | "name": "No staking",
73 | "description": "Your Olas Predict agent will still actively participate in prediction\
74 | markets, but it will not be staked within any staking program.",
75 | }
76 | NO_STAKING_PROGRAM_ENV_VARIABLES = {
77 | "USE_STAKING": "false",
78 | "STAKING_PROGRAM": NO_STAKING_PROGRAM_ID,
79 | "AGENT_ID": "25",
80 | "CUSTOM_SERVICE_REGISTRY_ADDRESS": "0x9338b5153AE39BB89f50468E608eD9d764B755fD",
81 | "CUSTOM_SERVICE_REGISTRY_TOKEN_UTILITY_ADDRESS": "0xa45E64d13A30a51b91ae0eb182e88a40e9b18eD8",
82 | "MECH_CONTRACT_ADDRESS": "0x77af31De935740567Cf4fF1986D04B2c964A786a",
83 | "CUSTOM_OLAS_ADDRESS": ZERO_ADDRESS,
84 | "CUSTOM_STAKING_ADDRESS": "0x43fB32f25dce34EB76c78C7A42C8F40F84BCD237", # Non-staking agents need to specify an arbitrary staking contract so that they can call getStakingState()
85 | "MECH_ACTIVITY_CHECKER_CONTRACT": ZERO_ADDRESS,
86 | "MIN_STAKING_BOND_OLAS": "0",
87 | "MIN_STAKING_DEPOSIT_OLAS": "0",
88 | }
89 |
90 | STAKING_PROGRAMS = {
91 | NO_STAKING_PROGRAM_ID: ZERO_ADDRESS,
92 | "quickstart_beta_hobbyist": "0x389B46c259631Acd6a69Bde8B6cEe218230bAE8C",
93 | "quickstart_beta_hobbyist_2": "0x238EB6993b90a978ec6AAD7530d6429c949C08DA",
94 | "quickstart_beta_expert": "0x5344B7DD311e5d3DdDd46A4f71481bD7b05AAA3e",
95 | "quickstart_beta_expert_2": "0xb964e44c126410df341ae04B13aB10A985fE3513",
96 | "quickstart_beta_expert_3": "0x80faD33Cadb5F53f9D29F02Db97D682E8b101618",
97 | "quickstart_beta_expert_4": "0xaD9d891134443B443D7F30013c7e14Fe27F2E029",
98 | "quickstart_beta_expert_5": "0xE56dF1E563De1B10715cB313D514af350D207212",
99 | "quickstart_beta_expert_6": "0x2546214aEE7eEa4bEE7689C81231017CA231Dc93",
100 | "quickstart_beta_expert_7": "0xD7A3C8b975f71030135f1a66e9e23164d54fF455",
101 | "quickstart_beta_expert_8": "0x356C108D49C5eebd21c84c04E9162de41933030c",
102 | "quickstart_beta_expert_9": "0x17dBAe44BC5618Cc254055b386A29576b4F87015",
103 | "quickstart_beta_expert_10": "0xB0ef657b8302bd2c74B6E6D9B2b4b39145b19c6f",
104 | "quickstart_beta_expert_11": "0x3112c1613eAC3dBAE3D4E38CeF023eb9E2C91CF7",
105 | "quickstart_beta_expert_12": "0xF4a75F476801B3fBB2e7093aCDcc3576593Cc1fc",
106 | }
107 |
108 | DEPRECATED_STAKING_PROGRAMS = {
109 | "quickstart_alpha_everest": "0x5add592ce0a1B5DceCebB5Dcac086Cd9F9e3eA5C",
110 | "quickstart_alpha_alpine": "0x2Ef503950Be67a98746F484DA0bBAdA339DF3326",
111 | "quickstart_alpha_coastal": "0x43fB32f25dce34EB76c78C7A42C8F40F84BCD237",
112 | }
113 |
114 |
115 | def _prompt_select_staking_program() -> str:
116 | env_file_vars = dotenv_values(DOTENV_PATH)
117 |
118 | program_id = None
119 | if "STAKING_PROGRAM" in env_file_vars:
120 | print("The staking program is already selected.")
121 |
122 | program_id = env_file_vars.get("STAKING_PROGRAM")
123 | if program_id not in STAKING_PROGRAMS:
124 | print(f"WARNING: Selected staking program {program_id} is unknown.")
125 | print("")
126 | program_id = None
127 |
128 | if not program_id:
129 | if os.environ.get("ATTENDED") == "false":
130 | print(
131 | "No staking program set in environment variable STAKING_PROGRAM. Defaulting to 'no_staking'."
132 | )
133 | return NO_STAKING_PROGRAM_ID
134 |
135 | print("Please, select your staking program preference")
136 | print("----------------------------------------------")
137 | ids = list(STAKING_PROGRAMS.keys())
138 | for index, key in enumerate(ids):
139 | metadata = _get_staking_contract_metadata(program_id=key)
140 | name = metadata["name"]
141 | description = metadata["description"]
142 | wrapped_description = textwrap.fill(
143 | description, width=80, initial_indent=" ", subsequent_indent=" "
144 | )
145 | print(f"{index + 1}) {name}\n{wrapped_description}\n")
146 |
147 | while True:
148 | try:
149 | choice = int(input(f"Enter your choice (1 - {len(ids)}): ")) - 1
150 | if not (0 <= choice < len(ids)):
151 | raise ValueError
152 | program_id = ids[choice]
153 | break
154 | except ValueError:
155 | print(f"Please enter a valid option (1 - {len(ids)}).")
156 |
157 | print(f"Selected staking program: {program_id}")
158 | print("")
159 | return program_id
160 |
161 |
162 | def _get_abi(contract_address: str) -> List:
163 | contract_abi_url = (
164 | "https://gnosis.blockscout.com/api/v2/smart-contracts/{contract_address}"
165 | )
166 | response = requests.get(
167 | contract_abi_url.format(contract_address=contract_address)
168 | ).json()
169 |
170 | if "result" in response:
171 | result = response["result"]
172 | try:
173 | abi = json.loads(result)
174 | except json.JSONDecodeError:
175 | print("Error: Failed to parse 'result' field as JSON")
176 | sys.exit(1)
177 | else:
178 | abi = response.get("abi")
179 |
180 | return abi if abi else []
181 |
182 |
183 | def _load_abi_from_file(path: Path) -> Dict[str, Any]:
184 | if not os.path.exists(path):
185 | print(
186 | "Error: Contract airtfacts not found. Please execute 'run_service.sh' before executing this script."
187 | )
188 | sys.exit(1)
189 |
190 | with open(path, "r", encoding="utf-8") as f:
191 | data = json.load(f)
192 |
193 | return data.get("abi")
194 |
195 |
196 | contracts_cache: Dict[str, Any] = {}
197 |
198 |
199 | def _get_staking_token_contract(program_id: str, use_blockscout: bool = False) -> Any:
200 | if program_id in contracts_cache:
201 | return contracts_cache[program_id]
202 |
203 | with open(RPC_PATH, "r", encoding="utf-8") as file:
204 | rpc = file.read().strip()
205 |
206 | w3 = Web3(Web3.HTTPProvider(rpc))
207 | staking_token_instance_address = STAKING_PROGRAMS.get(program_id)
208 | if use_blockscout:
209 | abi = _get_abi(staking_token_instance_address)
210 | else:
211 | abi = _load_abi_from_file(STAKING_TOKEN_INSTANCE_ABI_PATH)
212 | contract = w3.eth.contract(address=staking_token_instance_address, abi=abi)
213 |
214 | if "getImplementation" in [func.fn_name for func in contract.all_functions()]:
215 | # It is a proxy contract
216 | implementation_address = contract.functions.getImplementation().call()
217 | if use_blockscout:
218 | abi = _get_abi(implementation_address)
219 | else:
220 | abi = _load_abi_from_file(STAKING_TOKEN_IMPLEMENTATION_ABI_PATH)
221 | contract = w3.eth.contract(address=staking_token_instance_address, abi=abi)
222 |
223 | contracts_cache[program_id] = contract
224 | return contract
225 |
226 |
227 | def _get_staking_contract_metadata(
228 | program_id: str, use_blockscout: bool = False
229 | ) -> Dict[str, str]:
230 | try:
231 | if program_id == NO_STAKING_PROGRAM_ID:
232 | return NO_STAKING_PROGRAM_METADATA
233 |
234 | staking_token_contract = _get_staking_token_contract(
235 | program_id=program_id, use_blockscout=use_blockscout
236 | )
237 | metadata_hash = staking_token_contract.functions.metadataHash().call()
238 | ipfs_address = IPFS_ADDRESS.format(hash=metadata_hash.hex())
239 | response = requests.get(ipfs_address)
240 |
241 | if response.status_code == 200:
242 | return response.json()
243 |
244 | raise Exception( # pylint: disable=broad-except
245 | f"Failed to fetch data from {ipfs_address}: {response.status_code}"
246 | )
247 | except Exception: # pylint: disable=broad-except
248 | return {
249 | "name": program_id,
250 | "description": program_id,
251 | }
252 |
253 |
254 | def _get_staking_env_variables( # pylint: disable=too-many-locals
255 | program_id: str, use_blockscout: bool = False
256 | ) -> Dict[str, str]:
257 | if program_id == NO_STAKING_PROGRAM_ID:
258 | return NO_STAKING_PROGRAM_ENV_VARIABLES
259 |
260 | staking_token_instance_address = STAKING_PROGRAMS.get(program_id)
261 | staking_token_contract = _get_staking_token_contract(
262 | program_id=program_id, use_blockscout=use_blockscout
263 | )
264 | agent_id = staking_token_contract.functions.agentIds(0).call()
265 | service_registry = staking_token_contract.functions.serviceRegistry().call()
266 | staking_token = staking_token_contract.functions.stakingToken().call()
267 | service_registry_token_utility = (
268 | staking_token_contract.functions.serviceRegistryTokenUtility().call()
269 | )
270 | min_staking_deposit = staking_token_contract.functions.minStakingDeposit().call()
271 | min_staking_bond = min_staking_deposit
272 |
273 | if "activityChecker" in [
274 | func.fn_name for func in staking_token_contract.all_functions()
275 | ]:
276 | activity_checker = staking_token_contract.functions.activityChecker().call()
277 |
278 | if use_blockscout:
279 | abi = _get_abi(activity_checker)
280 | else:
281 | abi = _load_abi_from_file(ACTIVITY_CHECKER_ABI_PATH)
282 |
283 | with open(RPC_PATH, "r", encoding="utf-8") as file:
284 | rpc = file.read().strip()
285 |
286 | w3 = Web3(Web3.HTTPProvider(rpc))
287 | activity_checker_contract = w3.eth.contract(address=activity_checker, abi=abi)
288 | agent_mech = activity_checker_contract.functions.agentMech().call()
289 | else:
290 | activity_checker = ZERO_ADDRESS
291 | agent_mech = staking_token_contract.functions.agentMech().call()
292 |
293 | return {
294 | "USE_STAKING": "true",
295 | "STAKING_PROGRAM": program_id,
296 | "AGENT_ID": agent_id,
297 | "CUSTOM_SERVICE_REGISTRY_ADDRESS": service_registry,
298 | "CUSTOM_SERVICE_REGISTRY_TOKEN_UTILITY_ADDRESS": service_registry_token_utility,
299 | "CUSTOM_OLAS_ADDRESS": staking_token,
300 | "CUSTOM_STAKING_ADDRESS": staking_token_instance_address,
301 | "MECH_ACTIVITY_CHECKER_CONTRACT": activity_checker,
302 | "MECH_CONTRACT_ADDRESS": agent_mech,
303 | "MIN_STAKING_BOND_OLAS": min_staking_bond,
304 | "MIN_STAKING_DEPOSIT_OLAS": min_staking_deposit,
305 | }
306 |
307 |
308 | def _set_dotenv_file_variables(env_vars: Dict[str, str]) -> None:
309 | for key, value in env_vars.items():
310 | if value:
311 | set_key(
312 | dotenv_path=DOTENV_PATH,
313 | key_to_set=key,
314 | value_to_set=value,
315 | quote_mode="never",
316 | )
317 | else:
318 | unset_key(dotenv_path=DOTENV_PATH, key_to_unset=key)
319 |
320 |
321 | def _get_nevermined_env_variables() -> Dict[str, str]:
322 | env_file_vars = dotenv_values(DOTENV_PATH)
323 | use_nevermined = False
324 |
325 | if "USE_NEVERMINED" not in env_file_vars:
326 | set_key(
327 | dotenv_path=DOTENV_PATH,
328 | key_to_set="USE_NEVERMINED",
329 | value_to_set="false",
330 | quote_mode="never",
331 | )
332 | elif env_file_vars.get("USE_NEVERMINED").strip() not in ("True", "true"):
333 | set_key(
334 | dotenv_path=DOTENV_PATH,
335 | key_to_set="USE_NEVERMINED",
336 | value_to_set="false",
337 | quote_mode="never",
338 | )
339 | else:
340 | use_nevermined = True
341 |
342 | if use_nevermined:
343 | print(
344 | " - A Nevermined subscription will be used to pay for the mech requests."
345 | )
346 | return {
347 | "MECH_CONTRACT_ADDRESS": NEVERMINED_MECH_CONTRACT_ADDRESS,
348 | "AGENT_REGISTRY_ADDRESS": NEVERMINED_AGENT_REGISTRY_ADDRESS,
349 | "MECH_REQUEST_PRICE": NEVERMINED_MECH_REQUEST_PRICE,
350 | }
351 | else:
352 | print(" - No Nevermined subscription set.")
353 | return {"AGENT_REGISTRY_ADDRESS": "", "MECH_REQUEST_PRICE": ""}
354 |
355 |
356 | def main() -> None:
357 | """Main method"""
358 | parser = argparse.ArgumentParser(description="Set up staking configuration.")
359 | parser.add_argument(
360 | "--reset",
361 | action="store_true",
362 | help="Reset USE_STAKING and STAKING_PROGRAM in .env file",
363 | )
364 | parser.add_argument(
365 | "--use_blockscout",
366 | action="store_true",
367 | help="Use Blockscout to retrieve contract data.",
368 | )
369 | args = parser.parse_args()
370 |
371 | if args.reset:
372 | env_file_vars = dotenv_values(DOTENV_PATH)
373 | staking_program = env_file_vars.get("STAKING_PROGRAM")
374 | print("=====================================")
375 | print("Reset your staking program preference")
376 | print("=====================================")
377 | print("")
378 | print(f"Your current staking program preference is set to '{staking_program}'.")
379 | print(
380 | "You can reset your preference. However, your trader might not be able to switch between staking contracts until it has been staked for a minimum staking period in the current program."
381 | )
382 | print("")
383 | if os.environ.get("ATTENDED") == "true":
384 | response = (
385 | input(
386 | "Do you want to reset your staking program preference? (yes/no): "
387 | )
388 | .strip()
389 | .lower()
390 | )
391 | if response not in ["yes", "y"]:
392 | return
393 |
394 | print("")
395 | unset_key(dotenv_path=DOTENV_PATH, key_to_unset="USE_STAKING")
396 | unset_key(dotenv_path=DOTENV_PATH, key_to_unset="STAKING_PROGRAM")
397 | print(
398 | f"Environment variables USE_STAKING and STAKING_PROGRAM have been reset in '{DOTENV_PATH}'."
399 | )
400 | print("")
401 |
402 | program_id = _prompt_select_staking_program()
403 |
404 | print(" - Populating staking program variables in the .env file")
405 | staking_env_variables = _get_staking_env_variables(
406 | program_id, use_blockscout=args.use_blockscout
407 | )
408 | _set_dotenv_file_variables(staking_env_variables)
409 |
410 | print(" - Populating Nevermined variables in the .env file")
411 | print("")
412 | nevermined_env_variables = _get_nevermined_env_variables()
413 | _set_dotenv_file_variables(nevermined_env_variables)
414 | print("")
415 | print("Finished populating the .env file.")
416 |
417 |
418 | if __name__ == "__main__":
419 | main()
420 |
--------------------------------------------------------------------------------
/scripts/claim_staking_rewards.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 | # -*- coding: utf-8 -*-
3 | # ------------------------------------------------------------------------------
4 | #
5 | # Copyright 2024 Valory AG
6 | #
7 | # Licensed under the Apache License, Version 2.0 (the "License");
8 | # you may not use this file except in compliance with the License.
9 | # You may obtain a copy of the License at
10 | #
11 | # http://www.apache.org/licenses/LICENSE-2.0
12 | #
13 | # Unless required by applicable law or agreed to in writing, software
14 | # distributed under the License is distributed on an "AS IS" BASIS,
15 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16 | # See the License for the specific language governing permissions and
17 | # limitations under the License.
18 | #
19 | # ------------------------------------------------------------------------------
20 |
21 | """Claim earned OLAS"""
22 |
23 | import json
24 | import os
25 | import sys
26 | from getpass import getpass
27 | from pathlib import Path
28 | from typing import Any, Dict, Optional
29 |
30 | from aea.crypto.helpers import DecryptError, KeyIsIncorrect
31 | from aea_ledger_ethereum.ethereum import EthereumCrypto
32 | from dotenv import dotenv_values
33 | from web3 import Web3
34 |
35 |
36 | SCRIPT_PATH = Path(__file__).resolve().parent
37 | STORE_PATH = Path(SCRIPT_PATH, "..", ".trader_runner")
38 | DOTENV_PATH = Path(STORE_PATH, ".env")
39 | RPC_PATH = Path(STORE_PATH, "rpc.txt")
40 | SERVICE_ID_PATH = Path(STORE_PATH, "service_id.txt")
41 | SERVICE_SAFE_ADDRESS_PATH = Path(STORE_PATH, "service_safe_address.txt")
42 | OPERATOR_PKEY_PATH = Path(STORE_PATH, "operator_pkey.txt")
43 | DEFAULT_ENCODING = "utf-8"
44 |
45 | OLAS_TOKEN_ADDRESS_GNOSIS = "0xcE11e14225575945b8E6Dc0D4F2dD4C570f79d9f"
46 | GNOSIS_CHAIN_ID = 100
47 | DEFAULT_GAS = 100000
48 | SAFE_WEBAPP_URL = "https://app.safe.global/home?safe=gno:"
49 |
50 | STAKING_TOKEN_INSTANCE_ABI_PATH = Path(
51 | SCRIPT_PATH,
52 | "..",
53 | "trader",
54 | "packages",
55 | "valory",
56 | "contracts",
57 | "staking_token",
58 | "build",
59 | "StakingToken.json",
60 | )
61 | STAKING_TOKEN_IMPLEMENTATION_ABI_PATH = STAKING_TOKEN_INSTANCE_ABI_PATH
62 |
63 | ERC20_ABI_PATH = Path(
64 | SCRIPT_PATH,
65 | "..",
66 | "trader",
67 | "packages",
68 | "valory",
69 | "contracts",
70 | "erc20",
71 | "build",
72 | "ERC20.json",
73 | )
74 |
75 |
76 | def _is_keystore(pkeypath: Path) -> bool:
77 | try:
78 | with open(pkeypath, "r", encoding="utf-8") as f:
79 | json.load(f)
80 | return True
81 | except json.JSONDecodeError:
82 | return False
83 |
84 |
85 | def _load_abi_from_file(path: Path) -> Dict[str, Any]:
86 | if not os.path.exists(path):
87 | print(
88 | "Error: Contract airtfacts not found. Please execute 'run_service.sh' before executing this script."
89 | )
90 | sys.exit(1)
91 |
92 | with open(path, "r", encoding=DEFAULT_ENCODING) as f:
93 | data = json.load(f)
94 |
95 | return data.get("abi")
96 |
97 |
98 | def _erc20_balance(
99 | address: str,
100 | token_address: str = OLAS_TOKEN_ADDRESS_GNOSIS,
101 | token_name: str = "OLAS",
102 | decimal_precision: int = 2,
103 | ) -> str:
104 | """Get ERC20 balance"""
105 | rpc = RPC_PATH.read_text(encoding=DEFAULT_ENCODING).strip()
106 | w3 = Web3(Web3.HTTPProvider(rpc))
107 | abi = _load_abi_from_file(ERC20_ABI_PATH)
108 | contract = w3.eth.contract(address=token_address, abi=abi)
109 | balance = contract.functions.balanceOf(address).call()
110 | return f"{balance / 10**18:.{decimal_precision}f} {token_name}"
111 |
112 |
113 | def _claim_rewards( # pylint: disable=too-many-locals
114 | password: Optional[str] = None,
115 | ) -> None:
116 | service_safe_address = SERVICE_SAFE_ADDRESS_PATH.read_text(
117 | encoding=DEFAULT_ENCODING
118 | ).strip()
119 | print(
120 | f"OLAS Balance of service Safe {service_safe_address}: {_erc20_balance(service_safe_address)}"
121 | )
122 |
123 | env_file_vars = dotenv_values(DOTENV_PATH)
124 | staking_token_address = env_file_vars["CUSTOM_STAKING_ADDRESS"]
125 | service_id = int(SERVICE_ID_PATH.read_text(encoding=DEFAULT_ENCODING).strip())
126 |
127 | rpc = RPC_PATH.read_text(encoding=DEFAULT_ENCODING).strip()
128 | w3 = Web3(Web3.HTTPProvider(rpc))
129 | abi = _load_abi_from_file(STAKING_TOKEN_IMPLEMENTATION_ABI_PATH)
130 | staking_token_contract = w3.eth.contract(address=staking_token_address, abi=abi)
131 |
132 | try:
133 | ethereum_crypto = EthereumCrypto(OPERATOR_PKEY_PATH, password=password)
134 | operator_address = ethereum_crypto.address
135 | operator_pkey = ethereum_crypto.private_key
136 | except DecryptError:
137 | print(
138 | f"Could not decrypt key {OPERATOR_PKEY_PATH}. Please verify if your key file is password-protected, and if the provided password is correct (passwords are case-sensitive)."
139 | )
140 | sys.exit(1)
141 | except KeyIsIncorrect:
142 | print(f"Error decoding key file {OPERATOR_PKEY_PATH}.")
143 | sys.exit(1)
144 |
145 | function = staking_token_contract.functions.claim(service_id)
146 | claim_transaction = function.build_transaction(
147 | {
148 | "chainId": GNOSIS_CHAIN_ID,
149 | "gas": DEFAULT_GAS,
150 | "gasPrice": w3.to_wei("3", "gwei"),
151 | "nonce": w3.eth.get_transaction_count(operator_address),
152 | }
153 | )
154 |
155 | signed_tx = w3.eth.account.sign_transaction(claim_transaction, operator_pkey)
156 | tx_hash = w3.eth.send_raw_transaction(signed_tx.rawTransaction)
157 | tx_receipt = w3.eth.wait_for_transaction_receipt(tx_hash)
158 | print(f"Claim transaction done. Hash: {tx_hash.hex()}")
159 |
160 | if "status" in tx_receipt and tx_receipt["status"] == 0:
161 | print(
162 | "WARNING: The transaction was reverted. This may be caused because your service does not have rewards to claim."
163 | )
164 | else:
165 | print("")
166 | print(f"Claimed OLAS transferred to your service Safe {service_safe_address}")
167 |
168 | print("")
169 | print(
170 | f"You can use your Owner/Operator wallet (address {operator_address}) to connect your Safe at {SAFE_WEBAPP_URL}{service_safe_address}."
171 | )
172 |
173 |
174 | def main() -> None:
175 | """Main method."""
176 | print("---------------------")
177 | print("Claim staking rewards")
178 | print("---------------------")
179 | print("")
180 | print(
181 | "This script will claim the OLAS staking rewards accrued in the current staking contract and transfer them to your service Safe."
182 | )
183 | _continue = input("Do you want to continue (yes/no)? ").strip().lower()
184 |
185 | if _continue not in ("y", "yes"):
186 | sys.exit(0)
187 |
188 | print("")
189 |
190 | password = None
191 | if _is_keystore(OPERATOR_PKEY_PATH):
192 | print("Enter your password")
193 | print("-------------------")
194 | print("Your key files are protected with a password.")
195 | password = getpass("Please, enter your password: ").strip()
196 | print("")
197 |
198 | _claim_rewards(password)
199 |
200 |
201 | if __name__ == "__main__":
202 | main()
203 |
--------------------------------------------------------------------------------
/scripts/erc20_balance.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 | # -*- coding: utf-8 -*-
3 | # ------------------------------------------------------------------------------
4 | #
5 | # Copyright 2022-2023 Valory AG
6 | #
7 | # Licensed under the Apache License, Version 2.0 (the "License");
8 | # you may not use this file except in compliance with the License.
9 | # You may obtain a copy of the License at
10 | #
11 | # http://www.apache.org/licenses/LICENSE-2.0
12 | #
13 | # Unless required by applicable law or agreed to in writing, software
14 | # distributed under the License is distributed on an "AS IS" BASIS,
15 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16 | # See the License for the specific language governing permissions and
17 | # limitations under the License.
18 | #
19 | # ------------------------------------------------------------------------------
20 |
21 | """This script prints the wxDAI balance of an address in WEI."""
22 |
23 | import json
24 | import sys
25 |
26 | from web3 import Web3, HTTPProvider
27 |
28 | WXDAI_CONTRACT_ADDRESS = "0xe91D153E0b41518A2Ce8Dd3D7944Fa863463a97d"
29 | WXDAI_ABI_PATH = "../trader/packages/valory/contracts/erc20/build/ERC20.json"
30 |
31 |
32 | def get_balance() -> int:
33 | """Get the wxDAI balance of an address in WEI."""
34 | w3 = Web3(HTTPProvider(rpc))
35 | contract_instance = w3.eth.contract(address=token, abi=abi)
36 | return contract_instance.functions.balanceOf(w3.to_checksum_address(address)).call()
37 |
38 |
39 | def read_abi() -> str:
40 | """Read and return the wxDAI contract's ABI."""
41 | with open(WXDAI_ABI_PATH) as f:
42 | data = json.loads(f.read())
43 |
44 | return data.get('abi', [])
45 |
46 |
47 | if __name__ == "__main__":
48 | if len(sys.argv) != 4:
49 | raise ValueError("Expected the address and the rpc as positional arguments.")
50 | else:
51 | token = sys.argv[1]
52 | address = sys.argv[2]
53 | rpc = sys.argv[3]
54 | abi = read_abi()
55 | print(get_balance())
56 |
--------------------------------------------------------------------------------
/scripts/get_agent_bond.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 | # -*- coding: utf-8 -*-
3 | # ------------------------------------------------------------------------------
4 | #
5 | # Copyright 2022-2023 Valory AG
6 | #
7 | # Licensed under the Apache License, Version 2.0 (the "License");
8 | # you may not use this file except in compliance with the License.
9 | # You may obtain a copy of the License at
10 | #
11 | # http://www.apache.org/licenses/LICENSE-2.0
12 | #
13 | # Unless required by applicable law or agreed to in writing, software
14 | # distributed under the License is distributed on an "AS IS" BASIS,
15 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16 | # See the License for the specific language governing permissions and
17 | # limitations under the License.
18 | #
19 | # ------------------------------------------------------------------------------
20 |
21 | """Get agent bond."""
22 |
23 | import argparse
24 | import json
25 | import os
26 | import sys
27 | from pathlib import Path
28 | from typing import Any, Dict, List
29 |
30 | import requests
31 | from web3 import Web3
32 |
33 |
34 | ZERO_ADDRESS = "0x0000000000000000000000000000000000000000"
35 | SCRIPT_PATH = Path(__file__).resolve().parent
36 | SERVICE_REGISTRY_TOKEN_UTILITY_ABI_PATH = Path(
37 | SCRIPT_PATH, "..", "contracts", "ServiceRegistryTokenUtility.json"
38 | )
39 |
40 |
41 | def _get_abi(contract_address: str) -> List:
42 | contract_abi_url = (
43 | "https://gnosis.blockscout.com/api/v2/smart-contracts/{contract_address}"
44 | )
45 | response = requests.get(
46 | contract_abi_url.format(contract_address=contract_address)
47 | ).json()
48 |
49 | if "result" in response:
50 | result = response["result"]
51 | try:
52 | abi = json.loads(result)
53 | except json.JSONDecodeError:
54 | print("Error: Failed to parse 'result' field as JSON")
55 | sys.exit(1)
56 | else:
57 | abi = response.get("abi")
58 |
59 | return abi if abi else []
60 |
61 |
62 | def _load_abi_from_file(path: Path) -> Dict[str, Any]:
63 | if not os.path.exists(path):
64 | print(
65 | "Error: Contract airtfacts not found. Please execute 'run_service.sh' before executing this script."
66 | )
67 | sys.exit(1)
68 |
69 | with open(path, "r", encoding="utf-8") as f:
70 | data = json.load(f)
71 |
72 | return data.get("abi")
73 |
74 |
75 | def main() -> None:
76 | """Main method"""
77 | parser = argparse.ArgumentParser(
78 | description="Get agent bond from service registry token utility contract."
79 | )
80 | parser.add_argument(
81 | "service_registry", type=str, help="Service registry contract address"
82 | )
83 | parser.add_argument(
84 | "service_registry_token_utility",
85 | type=str,
86 | help="Service registry token utility contract address",
87 | )
88 | parser.add_argument("service_id", type=int, help="Service ID")
89 | parser.add_argument("agent_id", type=int, help="Agent ID")
90 | parser.add_argument("rpc", type=str, help="RPC")
91 | parser.add_argument(
92 | "--use_blockscout",
93 | action="store_true",
94 | help="Use Blockscout to retrieve contract data.",
95 | )
96 | args = parser.parse_args()
97 |
98 | service_registry = args.service_registry
99 | service_registry_token_utility = args.service_registry_token_utility
100 | service_id = args.service_id
101 | agent_id = args.agent_id
102 | rpc = args.rpc
103 |
104 | w3 = Web3(Web3.HTTPProvider(rpc))
105 |
106 | if args.use_blockscout:
107 | abi = _get_abi(service_registry_token_utility)
108 | else:
109 | abi = _load_abi_from_file(SERVICE_REGISTRY_TOKEN_UTILITY_ABI_PATH)
110 |
111 | contract = w3.eth.contract(address=service_registry_token_utility, abi=abi)
112 | token = contract.functions.mapServiceIdTokenDeposit(service_id).call()[0]
113 |
114 | # If service is token-secured, retrieve bond from Service Registry Token Utility
115 | if token != ZERO_ADDRESS:
116 | agent_bond = contract.functions.getAgentBond(service_id, agent_id).call()
117 | print(agent_bond)
118 | # Otherwise, retrieve bond from Service Registry
119 | else:
120 | abi = _get_abi(service_registry)
121 | contract = w3.eth.contract(address=service_registry, abi=abi)
122 | agent_bond = contract.functions.getService(service_id).call()[0]
123 | print(agent_bond)
124 |
125 |
126 | if __name__ == "__main__":
127 | main()
128 |
--------------------------------------------------------------------------------
/scripts/get_available_staking_slots.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 | # -*- coding: utf-8 -*-
3 | # ------------------------------------------------------------------------------
4 | #
5 | # Copyright 2022-2024 Valory AG
6 | #
7 | # Licensed under the Apache License, Version 2.0 (the "License");
8 | # you may not use this file except in compliance with the License.
9 | # You may obtain a copy of the License at
10 | #
11 | # http://www.apache.org/licenses/LICENSE-2.0
12 | #
13 | # Unless required by applicable law or agreed to in writing, software
14 | # distributed under the License is distributed on an "AS IS" BASIS,
15 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16 | # See the License for the specific language governing permissions and
17 | # limitations under the License.
18 | #
19 | # ------------------------------------------------------------------------------
20 |
21 | """Get the available staking slots."""
22 |
23 | import argparse
24 | import sys
25 | import traceback
26 | import typing
27 | from pathlib import Path
28 |
29 | from aea_ledger_ethereum.ethereum import EthereumApi, EthereumCrypto
30 | from utils import get_available_staking_slots
31 |
32 |
33 | if __name__ == "__main__":
34 | try:
35 | parser = argparse.ArgumentParser(
36 | description="Get the available staking slots."
37 | )
38 | parser.add_argument(
39 | "staking_contract_address",
40 | type=str,
41 | help="The staking contract address.",
42 | )
43 | parser.add_argument("rpc", type=str, help="RPC for the Gnosis chain")
44 | args = parser.parse_args()
45 |
46 | ledger_api = EthereumApi(address=args.rpc)
47 | available_staking_slots = get_available_staking_slots(
48 | ledger_api, args.staking_contract_address
49 | )
50 |
51 | print(available_staking_slots)
52 |
53 | except Exception as e: # pylint: disable=broad-except
54 | print(f"An error occurred while executing {Path(__file__).name}: {str(e)}")
55 | traceback.print_exc()
56 | sys.exit(1)
57 |
--------------------------------------------------------------------------------
/scripts/get_safe_owners.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 | # -*- coding: utf-8 -*-
3 | # ------------------------------------------------------------------------------
4 | #
5 | # Copyright 2022-2023 Valory AG
6 | #
7 | # Licensed under the Apache License, Version 2.0 (the "License");
8 | # you may not use this file except in compliance with the License.
9 | # You may obtain a copy of the License at
10 | #
11 | # http://www.apache.org/licenses/LICENSE-2.0
12 | #
13 | # Unless required by applicable law or agreed to in writing, software
14 | # distributed under the License is distributed on an "AS IS" BASIS,
15 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16 | # See the License for the specific language governing permissions and
17 | # limitations under the License.
18 | #
19 | # ------------------------------------------------------------------------------
20 |
21 | """Get a Safe current owners' addresses."""
22 |
23 | import argparse
24 | import sys
25 | import traceback
26 | import typing
27 | from pathlib import Path
28 |
29 | from aea.contracts.base import Contract
30 | from aea_ledger_ethereum.ethereum import EthereumApi, EthereumCrypto
31 |
32 | from packages.valory.contracts.gnosis_safe.contract import GnosisSafeContract
33 |
34 |
35 | ContractType = typing.TypeVar("ContractType")
36 |
37 |
38 | def load_contract(ctype: ContractType) -> ContractType:
39 | """Load contract."""
40 | *parts, _ = ctype.__module__.split(".")
41 | path = "/".join(parts)
42 | return Contract.from_dir(directory=path)
43 |
44 |
45 | if __name__ == "__main__":
46 | try:
47 | parser = argparse.ArgumentParser(
48 | description="Get a Safe current owners' addresses."
49 | )
50 | parser.add_argument(
51 | "safe_address",
52 | type=str,
53 | help="Safe address",
54 | )
55 | parser.add_argument(
56 | "private_key_path",
57 | type=str,
58 | help="Path to the file containing the Ethereum private key",
59 | )
60 | parser.add_argument("rpc", type=str, help="RPC for the Gnosis chain")
61 | parser.add_argument("--password", type=str, help="Private key password")
62 | args = parser.parse_args()
63 |
64 | ledger_api = EthereumApi(address=args.rpc)
65 | ethereum_crypto: EthereumCrypto
66 | ethereum_crypto = EthereumCrypto(
67 | private_key_path=args.private_key_path, password=args.password
68 | )
69 |
70 | safe = load_contract(GnosisSafeContract)
71 | print(
72 | safe.get_owners(
73 | ledger_api=ledger_api, contract_address=args.safe_address
74 | ).get("owners", [])
75 | )
76 |
77 | except Exception as e: # pylint: disable=broad-except
78 | print(f"An error occurred while executing {Path(__file__).name}: {str(e)}")
79 | traceback.print_exc()
80 | sys.exit(1)
81 |
--------------------------------------------------------------------------------
/scripts/is_keys_json_password_valid.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 | # -*- coding: utf-8 -*-
3 | # ------------------------------------------------------------------------------
4 | #
5 | # Copyright 2022-2023 Valory AG
6 | #
7 | # Licensed under the Apache License, Version 2.0 (the "License");
8 | # you may not use this file except in compliance with the License.
9 | # You may obtain a copy of the License at
10 | #
11 | # http://www.apache.org/licenses/LICENSE-2.0
12 | #
13 | # Unless required by applicable law or agreed to in writing, software
14 | # distributed under the License is distributed on an "AS IS" BASIS,
15 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16 | # See the License for the specific language governing permissions and
17 | # limitations under the License.
18 | #
19 | # ------------------------------------------------------------------------------
20 |
21 | """Checks if the provided password is valid for a keys.json file."""
22 |
23 | import argparse
24 | import json
25 | import tempfile
26 | import traceback
27 | from pathlib import Path
28 |
29 | from aea.crypto.helpers import DecryptError, KeyIsIncorrect
30 | from aea_ledger_ethereum.ethereum import EthereumCrypto
31 |
32 |
33 | def _is_keys_json_password_valid(
34 | keys_json_path: Path, password: str, debug: bool
35 | ) -> bool:
36 | keys = json.load(keys_json_path.open("r"))
37 |
38 | with tempfile.TemporaryDirectory() as temp_dir:
39 | for idx, key in enumerate(keys):
40 | temp_file = Path(temp_dir, str(idx))
41 | temp_file.open("w+", encoding="utf-8").write(str(key["private_key"]))
42 |
43 | try:
44 | EthereumCrypto.load_private_key_from_path(
45 | str(temp_file), password=password
46 | )
47 | except (
48 | DecryptError,
49 | json.decoder.JSONDecodeError,
50 | KeyIsIncorrect,
51 | ):
52 | if debug:
53 | stack_trace = traceback.format_exc()
54 | print(stack_trace)
55 | return False
56 |
57 | return True
58 |
59 |
60 | if __name__ == "__main__":
61 | parser = argparse.ArgumentParser(
62 | description="Checks if the provided password is valid for a keys.json file."
63 | )
64 | parser.add_argument("keys_json_path", type=str, help="Path to the keys.json file.")
65 | parser.add_argument(
66 | "--password",
67 | type=str,
68 | help="Password. If not provided, it assumes keys.json is not password-protected.",
69 | )
70 | parser.add_argument("--debug", action="store_true", help="Prints debug messages.")
71 | args = parser.parse_args()
72 | print(
73 | _is_keys_json_password_valid(
74 | Path(args.keys_json_path), args.password, args.debug
75 | )
76 | )
77 |
--------------------------------------------------------------------------------
/scripts/mech_events.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 | # -*- coding: utf-8 -*-
3 | # ------------------------------------------------------------------------------
4 | #
5 | # Copyright 2022-2023 Valory AG
6 | #
7 | # Licensed under the Apache License, Version 2.0 (the "License");
8 | # you may not use this file except in compliance with the License.
9 | # You may obtain a copy of the License at
10 | #
11 | # http://www.apache.org/licenses/LICENSE-2.0
12 | #
13 | # Unless required by applicable law or agreed to in writing, software
14 | # distributed under the License is distributed on an "AS IS" BASIS,
15 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16 | # See the License for the specific language governing permissions and
17 | # limitations under the License.
18 | #
19 | # ------------------------------------------------------------------------------
20 |
21 | """Utilities to retrieve on-chain Mech events."""
22 |
23 | import json
24 | import os
25 | import sys
26 | import time
27 | from dataclasses import dataclass
28 | from pathlib import Path
29 | from string import Template
30 | from typing import Any, ClassVar, Dict
31 |
32 | import requests
33 | from dotenv import dotenv_values
34 | from gql import Client, gql
35 | from gql.transport.requests import RequestsHTTPTransport
36 | from tqdm import tqdm
37 | from web3.datastructures import AttributeDict
38 |
39 |
40 | SCRIPT_PATH = Path(__file__).resolve().parent
41 | STORE_PATH = Path(SCRIPT_PATH, "..", ".trader_runner")
42 | ENV_FILENAME = ".env"
43 | DOTENV_PATH = STORE_PATH / ENV_FILENAME
44 | MECH_EVENTS_JSON_PATH = STORE_PATH / "mech_events.json"
45 | HTTP = "http://"
46 | HTTPS = HTTP[:4] + "s" + HTTP[4:]
47 | CID_PREFIX = "f01701220"
48 | IPFS_ADDRESS = f"{HTTPS}gateway.autonolas.tech/ipfs/"
49 | MECH_EVENTS_DB_VERSION = 3
50 | DEFAULT_MECH_FEE = 10000000000000000
51 | DEFAULT_FROM_TIMESTAMP = 0
52 | DEFAULT_TO_TIMESTAMP = 2147483647
53 | MECH_SUBGRAPH_URL_TEMPLATE = Template(
54 | "https://gateway.thegraph.com/api/${SUBGRAPH_API_KEY}/subgraphs/id/4YGoX3iXUni1NBhWJS5xyKcntrAzssfytJK7PQxxQk5g"
55 | )
56 | SUBGRAPH_HEADERS = {
57 | "Accept": "application/json, multipart/mixed",
58 | "Content-Type": "application/json",
59 | }
60 | QUERY_BATCH_SIZE = 1000
61 | MECH_EVENTS_SUBGRAPH_QUERY_TEMPLATE = Template(
62 | """
63 | query mech_events_subgraph_query($sender: Bytes, $id_gt: Bytes, $first: Int) {
64 | ${subgraph_event_set_name}(
65 | where: {sender: $sender, id_gt: $id_gt}
66 | first: $first
67 | orderBy: id
68 | orderDirection: asc
69 | ) {
70 | id
71 | ipfsHash
72 | requestId
73 | sender
74 | transactionHash
75 | blockNumber
76 | blockTimestamp
77 | }
78 | }
79 | """
80 | )
81 |
82 |
83 | @dataclass
84 | class MechBaseEvent: # pylint: disable=too-many-instance-attributes
85 | """Base class for mech's on-chain event representation."""
86 |
87 | event_id: str
88 | sender: str
89 | transaction_hash: str
90 | ipfs_hash: str
91 | block_number: int
92 | block_timestamp: int
93 | ipfs_link: str
94 | ipfs_contents: Dict[str, Any]
95 |
96 | event_name: ClassVar[str]
97 | subgraph_event_name: ClassVar[str]
98 |
99 | def __init__(
100 | self,
101 | event_id: str,
102 | sender: str,
103 | ipfs_hash: str,
104 | transaction_hash: str,
105 | block_number: int,
106 | block_timestamp: int,
107 | ): # pylint: disable=too-many-arguments
108 | """Initializes the MechBaseEvent"""
109 | self.event_id = event_id
110 | self.sender = sender
111 | self.ipfs_hash = ipfs_hash
112 | self.transaction_hash = transaction_hash
113 | self.block_number = block_number
114 | self.block_timestamp = block_timestamp
115 | self.ipfs_link = ""
116 | self.ipfs_contents = {}
117 | self._populate_ipfs_contents(ipfs_hash)
118 |
119 | def _populate_ipfs_contents(self, data: str) -> None:
120 | url = f"{IPFS_ADDRESS}{data}"
121 | for _url in [f"{url}/metadata.json", url]:
122 | try:
123 | response = requests.get(_url)
124 | response.raise_for_status()
125 | self.ipfs_contents = response.json()
126 | self.ipfs_link = _url
127 | except Exception: # pylint: disable=broad-except
128 | continue
129 |
130 |
131 | @dataclass
132 | class MechRequest(MechBaseEvent):
133 | """A mech's on-chain response representation."""
134 |
135 | request_id: str
136 | fee: int
137 |
138 | event_name: ClassVar[str] = "Request"
139 | subgraph_event_name: ClassVar[str] = "request"
140 |
141 | def __init__(self, event: AttributeDict):
142 | """Initializes the MechRequest"""
143 |
144 | super().__init__(
145 | event_id=event["requestId"],
146 | sender=event["sender"],
147 | ipfs_hash=event["ipfsHash"],
148 | transaction_hash=event["transactionHash"],
149 | block_number=int(event["blockNumber"]),
150 | block_timestamp=int(event["blockTimestamp"]),
151 | )
152 |
153 | self.request_id = self.event_id
154 | # TODO This should be updated to extract the fee from the transaction.
155 | self.fee = DEFAULT_MECH_FEE
156 |
157 |
158 | def _read_mech_events_data_from_file() -> Dict[str, Any]:
159 | """Read Mech events data from the JSON file."""
160 | try:
161 | with open(MECH_EVENTS_JSON_PATH, "r", encoding="utf-8") as file:
162 | mech_events_data = json.load(file)
163 |
164 | # Check if it is an old DB version
165 | if mech_events_data.get("db_version", 0) < MECH_EVENTS_DB_VERSION:
166 | current_time = time.strftime("%Y-%m-%d_%H-%M-%S")
167 | old_db_filename = f"mech_events.{current_time}.old.json"
168 | os.rename(MECH_EVENTS_JSON_PATH, STORE_PATH / old_db_filename)
169 | mech_events_data = {}
170 | mech_events_data["db_version"] = MECH_EVENTS_DB_VERSION
171 | except FileNotFoundError:
172 | mech_events_data = {}
173 | mech_events_data["db_version"] = MECH_EVENTS_DB_VERSION
174 | except json.decoder.JSONDecodeError:
175 | print(
176 | f'\nERROR: The local Mech events database "{MECH_EVENTS_JSON_PATH.resolve()}" is corrupted. Please try delete or rename the file, and run the script again.'
177 | )
178 | sys.exit(1)
179 |
180 | return mech_events_data
181 |
182 |
183 | MINIMUM_WRITE_FILE_DELAY = 20
184 | last_write_time = 0.0
185 |
186 |
187 | def _write_mech_events_data_to_file(
188 | mech_events_data: Dict[str, Any], force_write: bool = False
189 | ) -> None:
190 | global last_write_time # pylint: disable=global-statement
191 | now = time.time()
192 |
193 | if force_write or (now - last_write_time) >= MINIMUM_WRITE_FILE_DELAY:
194 | with open(MECH_EVENTS_JSON_PATH, "w", encoding="utf-8") as file:
195 | json.dump(mech_events_data, file, indent=2)
196 | last_write_time = now
197 |
198 |
199 | def get_mech_subgraph_url() -> str:
200 | """Get the mech subgraph's URL."""
201 | env_file_vars = dotenv_values(DOTENV_PATH)
202 | return MECH_SUBGRAPH_URL_TEMPLATE.substitute(env_file_vars)
203 |
204 |
205 | def _query_mech_events_subgraph(
206 | sender: str, event_cls: type[MechBaseEvent]
207 | ) -> dict[str, Any]:
208 | """Query the subgraph."""
209 |
210 | mech_subgraph_url = get_mech_subgraph_url()
211 | transport = RequestsHTTPTransport(mech_subgraph_url)
212 | client = Client(transport=transport, fetch_schema_from_transport=True)
213 |
214 | subgraph_event_set_name = f"{event_cls.subgraph_event_name}s"
215 | all_results: dict[str, Any] = {"data": {subgraph_event_set_name: []}}
216 | query = MECH_EVENTS_SUBGRAPH_QUERY_TEMPLATE.safe_substitute(
217 | subgraph_event_set_name=subgraph_event_set_name
218 | )
219 | id_gt = ""
220 | while True:
221 | variables = {
222 | "sender": sender,
223 | "id_gt": id_gt,
224 | "first": QUERY_BATCH_SIZE,
225 | }
226 | response = client.execute(gql(query), variable_values=variables)
227 | events = response.get(subgraph_event_set_name, [])
228 |
229 | if not events:
230 | break
231 |
232 | all_results["data"][subgraph_event_set_name].extend(events)
233 | id_gt = events[len(events) - 1]["id"]
234 |
235 | return all_results
236 |
237 |
238 | # pylint: disable=too-many-locals
239 | def _update_mech_events_db(
240 | sender: str,
241 | event_cls: type[MechBaseEvent],
242 | ) -> None:
243 | """Get the mech Events database."""
244 |
245 | print(
246 | f"Updating the local Mech events database. This may take a while.\n"
247 | f" Event: {event_cls.event_name}\n"
248 | f" Sender address: {sender}"
249 | )
250 |
251 | try:
252 | # Query the subgraph
253 | query = _query_mech_events_subgraph(sender, event_cls)
254 | subgraph_data = query["data"]
255 |
256 | # Read the current Mech events database
257 | mech_events_data = _read_mech_events_data_from_file()
258 | stored_events = mech_events_data.setdefault(sender, {}).setdefault(
259 | event_cls.event_name, {}
260 | )
261 |
262 | subgraph_event_set_name = f"{event_cls.subgraph_event_name}s"
263 | for subgraph_event in tqdm(
264 | subgraph_data[subgraph_event_set_name],
265 | miniters=1,
266 | desc=" Processing",
267 | ):
268 | if subgraph_event[
269 | "requestId"
270 | ] not in stored_events or not stored_events.get(
271 | subgraph_event["requestId"], {}
272 | ).get(
273 | "ipfs_contents"
274 | ):
275 | mech_event = event_cls(subgraph_event) # type: ignore
276 | stored_events[mech_event.event_id] = mech_event.__dict__
277 |
278 | _write_mech_events_data_to_file(mech_events_data=mech_events_data)
279 |
280 | _write_mech_events_data_to_file(
281 | mech_events_data=mech_events_data, force_write=True
282 | )
283 |
284 | except KeyboardInterrupt:
285 | print(
286 | "\n"
287 | "WARNING: The update of the local Mech events database was cancelled. "
288 | "Therefore, the Mech calls and costs might not be reflected accurately. "
289 | "You may attempt to rerun this script to retry synchronizing the database."
290 | )
291 | input("Press Enter to continue...")
292 | except Exception as e: # pylint: disable=broad-except
293 | print(e)
294 | print(
295 | "WARNING: An error occurred while updating the local Mech events database. "
296 | "Therefore, the Mech calls and costs might not be reflected accurately. "
297 | "You may attempt to rerun this script to retry synchronizing the database."
298 | )
299 | input("Press Enter to continue...")
300 |
301 | print("")
302 |
303 |
304 | def _get_mech_events(sender: str, event_cls: type[MechBaseEvent]) -> Dict[str, Any]:
305 | """Updates the local database of Mech events and returns the Mech events."""
306 |
307 | _update_mech_events_db(sender, event_cls)
308 | mech_events_data = _read_mech_events_data_from_file()
309 | sender_data = mech_events_data.get(sender, {})
310 | return sender_data.get(event_cls.event_name, {})
311 |
312 |
313 | def get_mech_requests(
314 | sender: str,
315 | from_timestamp: float = DEFAULT_FROM_TIMESTAMP,
316 | to_timestamp: float = DEFAULT_TO_TIMESTAMP,
317 | ) -> Dict[str, Any]:
318 | """Returns the Mech requests."""
319 |
320 | all_mech_events = _get_mech_events(sender, MechRequest)
321 | filtered_mech_events = {}
322 | for event_id, event_data in all_mech_events.items():
323 | block_timestamp = int(event_data["block_timestamp"])
324 | if from_timestamp <= block_timestamp <= to_timestamp:
325 | filtered_mech_events[event_id] = event_data
326 |
327 | return filtered_mech_events
328 |
--------------------------------------------------------------------------------
/scripts/service_hash.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 | # -*- coding: utf-8 -*-
3 | # ------------------------------------------------------------------------------
4 | #
5 | # Copyright 2022-2023 Valory AG
6 | #
7 | # Licensed under the Apache License, Version 2.0 (the "License");
8 | # you may not use this file except in compliance with the License.
9 | # You may obtain a copy of the License at
10 | #
11 | # http://www.apache.org/licenses/LICENSE-2.0
12 | #
13 | # Unless required by applicable law or agreed to in writing, software
14 | # distributed under the License is distributed on an "AS IS" BASIS,
15 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16 | # See the License for the specific language governing permissions and
17 | # limitations under the License.
18 | #
19 | # ------------------------------------------------------------------------------
20 |
21 | """This script gets the service hash of a specific service on the registry."""
22 |
23 | import json
24 | from typing import List
25 | from pathlib import Path
26 |
27 | import requests
28 | from web3 import Web3, HTTPProvider
29 |
30 | SCRIPT_PATH = Path(__file__).resolve().parent
31 | STORE_PATH = Path(SCRIPT_PATH, "..", ".trader_runner")
32 | DOTENV_PATH = Path(STORE_PATH, ".env")
33 | RPC_PATH = Path(STORE_PATH, "rpc.txt")
34 | SERVICE_ID_PATH = Path(STORE_PATH, "service_id.txt")
35 | PACKAGES_PATH = Path(SCRIPT_PATH, "..", "trader", "packages")
36 |
37 | REGISTRY_JSON = PACKAGES_PATH / "valory" / "contracts" / "service_registry" / "build" / "ServiceRegistryL2.json"
38 | REGISTRY_ADDRESS = "0x9338b5153AE39BB89f50468E608eD9d764B755fD"
39 | AUTONOLAS_GATEWAY = "https://gateway.autonolas.tech/ipfs/"
40 | URI_HASH_POSITION = 7
41 |
42 |
43 | def _get_hash_from_ipfs(hash_decoded: str) -> str:
44 | """Get the service's `bafybei` hash from IPFS."""
45 | res = requests.get(f"{AUTONOLAS_GATEWAY}{hash_decoded}")
46 | if res.status_code == 200:
47 | return res.json().get("code_uri", "")[URI_HASH_POSITION:]
48 | raise ValueError(f"Something went wrong while trying to get the code uri from IPFS: {res}")
49 |
50 |
51 | def get_hash() -> str:
52 | """Get the service's hash."""
53 | contract_data = json.loads(registry_json)
54 | abi = contract_data.get('abi', [])
55 |
56 | w3 = Web3(HTTPProvider(rpc))
57 | contract_instance = w3.eth.contract(address=REGISTRY_ADDRESS, abi=abi)
58 | hash_encoded = contract_instance.functions.getService(int(service_id)).call()[2]
59 | hash_decoded = f"f01701220{hash_encoded.hex()}"
60 | hash_ = _get_hash_from_ipfs(hash_decoded)
61 |
62 | return hash_
63 |
64 |
65 | def _parse_args() -> List[str]:
66 | """Parse the RPC and service id."""
67 | params = []
68 | for path in (RPC_PATH, SERVICE_ID_PATH, REGISTRY_JSON):
69 | with open(path) as file:
70 | params.append(file.read())
71 | return params
72 |
73 |
74 | if __name__ == "__main__":
75 | rpc, service_id, registry_json = _parse_args()
76 | print(get_hash())
77 |
--------------------------------------------------------------------------------
/scripts/staking.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 | # -*- coding: utf-8 -*-
3 | # ------------------------------------------------------------------------------
4 | #
5 | # Copyright 2023-2024 Valory AG
6 | #
7 | # Licensed under the Apache License, Version 2.0 (the "License");
8 | # you may not use this file except in compliance with the License.
9 | # You may obtain a copy of the License at
10 | #
11 | # http://www.apache.org/licenses/LICENSE-2.0
12 | #
13 | # Unless required by applicable law or agreed to in writing, software
14 | # distributed under the License is distributed on an "AS IS" BASIS,
15 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16 | # See the License for the specific language governing permissions and
17 | # limitations under the License.
18 | #
19 | # ------------------------------------------------------------------------------
20 |
21 | """This script performs staking related operations."""
22 |
23 | import argparse
24 | import os
25 | import sys
26 | import time
27 | import traceback
28 | from datetime import datetime
29 | from dotenv import dotenv_values
30 | from pathlib import Path
31 |
32 | import dotenv
33 | from aea_ledger_ethereum.ethereum import EthereumApi, EthereumCrypto
34 | from choose_staking import (
35 | STAKING_PROGRAMS,
36 | DEPRECATED_STAKING_PROGRAMS,
37 | NO_STAKING_PROGRAM_ID,
38 | )
39 | from utils import (
40 | get_available_rewards,
41 | get_available_staking_slots,
42 | get_liveness_period,
43 | get_min_staking_duration,
44 | get_next_checkpoint_ts,
45 | get_service_ids,
46 | get_service_info,
47 | get_stake_txs,
48 | get_unstake_txs,
49 | is_service_evicted,
50 | is_service_staked,
51 | send_tx_and_wait_for_receipt,
52 | )
53 |
54 | SCRIPT_PATH = Path(__file__).resolve().parent
55 | STORE_PATH = Path(SCRIPT_PATH, "..", ".trader_runner")
56 | DOTENV_PATH = Path(STORE_PATH, ".env")
57 |
58 |
59 | def _format_duration(duration_seconds: int) -> str:
60 | days, remainder = divmod(duration_seconds, 86400)
61 | hours, remainder = divmod(remainder, 3600)
62 | minutes, _ = divmod(remainder, 60)
63 | formatted_duration = f"{days}D {hours}h {minutes}m"
64 | return formatted_duration
65 |
66 |
67 | def _check_unstaking_availability(
68 | ledger_api: EthereumApi,
69 | service_id: int,
70 | staking_contract_address: str,
71 | staking_program: str,
72 | ) -> bool:
73 | """Service can only be unstaked if one of these conditions occur:
74 | - No rewards available
75 | - Staked for longer than > minimum_staking_durtion.
76 | A service can NOT be unstaked if evicted but has been staked for < minimum_staking_duration
77 | """
78 |
79 | now = time.time()
80 | ts_start = get_service_info(
81 | ledger_api, service_id, staking_contract_address
82 | )[3]
83 | minimum_staking_duration = get_min_staking_duration(
84 | ledger_api, staking_contract_address
85 | )
86 | available_rewards = get_available_rewards(ledger_api, staking_contract_address)
87 | if (now - ts_start) < minimum_staking_duration and available_rewards > 0:
88 | print(
89 | f"WARNING: Your service has been staked on {staking_program} for {_format_duration(int(now - ts_start))}."
90 | )
91 | print(
92 | f"You cannot unstake your service from {staking_program} until it has been staked for at least {_format_duration(minimum_staking_duration)}."
93 | )
94 | return False
95 |
96 | return True
97 |
98 |
99 | def _get_current_staking_program(ledger_api, service_id):
100 | all_staking_programs = STAKING_PROGRAMS.copy()
101 | all_staking_programs.update(DEPRECATED_STAKING_PROGRAMS)
102 | del all_staking_programs[NO_STAKING_PROGRAM_ID]
103 | del all_staking_programs["quickstart_alpha_everest"] # Very old program, not used likely - causes issues on "is_service_staked"
104 |
105 | staking_program = NO_STAKING_PROGRAM_ID
106 | staking_contract_address = None
107 | for program, address in all_staking_programs.items():
108 | if is_service_staked(
109 | ledger_api, service_id, address
110 | ):
111 | staking_program = program
112 | staking_contract_address = address
113 | print(f"Service {service_id} is staked on {program}.")
114 | else:
115 | print(f"Service {service_id} is not staked on {program}.")
116 | return staking_contract_address, staking_program
117 |
118 |
119 | def _try_unstake_service(
120 | ledger_api: EthereumApi,
121 | service_id: int,
122 | owner_crypto: EthereumCrypto,
123 | warn_if_checkpoint_unavailable: bool = True,
124 | ) -> None:
125 |
126 | staking_contract_address, staking_program = _get_current_staking_program(ledger_api, service_id)
127 | print("")
128 |
129 | # Exit if not staked
130 | if staking_contract_address is None:
131 | print(f"Service {service_id} is not staked in any active program.")
132 | return
133 | else:
134 | print(f"Service {service_id} is staked on {staking_program}.")
135 |
136 | env_file_vars = dotenv_values(DOTENV_PATH)
137 | target_program = env_file_vars.get("STAKING_PROGRAM")
138 | print(f"Target program is set to {target_program}.")
139 | print("")
140 |
141 | # Collect information
142 | next_ts = get_next_checkpoint_ts(ledger_api, staking_contract_address)
143 | liveness_period = get_liveness_period(ledger_api, staking_contract_address)
144 | last_ts = next_ts - liveness_period
145 | now = time.time()
146 |
147 | if is_service_evicted(
148 | ledger_api, service_id, staking_contract_address
149 | ):
150 | print(
151 | f"WARNING: Service {service_id} has been evicted from the {staking_program} staking program due to inactivity."
152 | )
153 | if os.environ.get("ATTENDED") == "true":
154 | input("Press Enter to continue...")
155 |
156 | can_unstake = _check_unstaking_availability(
157 | ledger_api,
158 | service_id,
159 | staking_contract_address,
160 | staking_program,
161 | )
162 |
163 | if not can_unstake:
164 | print("Terminating script.")
165 | sys.exit(1)
166 |
167 | if warn_if_checkpoint_unavailable and (now < next_ts):
168 | formatted_last_ts = datetime.utcfromtimestamp(last_ts).strftime(
169 | "%Y-%m-%d %H:%M:%S UTC"
170 | )
171 | formatted_next_ts = datetime.utcfromtimestamp(next_ts).strftime(
172 | "%Y-%m-%d %H:%M:%S UTC"
173 | )
174 |
175 | print(
176 | "WARNING: Staking checkpoint call not available yet\n"
177 | "--------------------------------------------------\n"
178 | f"The liveness period ({liveness_period/3600} hours) has not passed since the last checkpoint call.\n"
179 | f" - {formatted_last_ts} - Last checkpoint call.\n"
180 | f" - {formatted_next_ts} - Next checkpoint call availability.\n"
181 | "\n"
182 | "If you proceed with unstaking, your agent's work done between the last checkpoint call until now will not be accounted for rewards.\n"
183 | "(Note: To maximize agent work eligible for rewards, the recommended practice is to unstake shortly after a checkpoint has been called and stake again immediately after.)\n"
184 | )
185 |
186 | user_input = "y"
187 | if os.environ.get("ATTENDED") == "true":
188 | user_input = input(
189 | f"Do you want to continue unstaking service {service_id} from {staking_program}? (yes/no)\n"
190 | ).lower()
191 | print()
192 |
193 | if user_input not in ["yes", "y"]:
194 | print("Terminating script.")
195 | sys.exit(1)
196 |
197 | print(f"Unstaking service {service_id} from {staking_program}...")
198 | unstake_txs = get_unstake_txs(
199 | ledger_api, service_id, staking_contract_address
200 | )
201 | for tx in unstake_txs:
202 | send_tx_and_wait_for_receipt(ledger_api, owner_crypto, tx)
203 | print(
204 | f"Successfully unstaked service {service_id} from {staking_program}."
205 | )
206 |
207 |
208 | def _try_stake_service(
209 | ledger_api: EthereumApi,
210 | service_id: int,
211 | owner_crypto: EthereumCrypto,
212 | service_registry_address: str,
213 | staking_contract_address: str,
214 | staking_program: str,
215 | ) -> None:
216 |
217 | print(f"Service {service_id} has set {staking_program} staking program.")
218 |
219 | if staking_program == "no_staking":
220 | return
221 |
222 | if get_available_staking_slots(ledger_api, staking_contract_address) > 0:
223 | print(
224 | f"Service {service_id} is not staked on {staking_program}. Checking for available rewards..."
225 | )
226 | available_rewards = get_available_rewards(ledger_api, staking_contract_address)
227 | if available_rewards == 0:
228 | # no rewards available, do nothing
229 | print(f"No rewards available on the {staking_program} staking program. Service {service_id} cannot be staked.")
230 | print("Please choose another staking program.")
231 | print("Terminating script.")
232 | sys.exit(1)
233 |
234 | print(
235 | f"Rewards available on {staking_program}: {available_rewards/10**18:.2f} OLAS. Staking service {service_id}..."
236 | )
237 | stake_txs = get_stake_txs(
238 | ledger_api,
239 | service_id,
240 | service_registry_address,
241 | staking_contract_address,
242 | )
243 | for tx in stake_txs:
244 | send_tx_and_wait_for_receipt(ledger_api, owner_crypto, tx)
245 |
246 | print(f"Service {service_id} staked successfully on {staking_program}.")
247 | else:
248 | print(
249 | f"All staking slots for contract {staking_contract_address} are taken. Service {service_id} cannot be staked."
250 | )
251 | print("The script will finish.")
252 | sys.exit(1)
253 |
254 |
255 | def main() -> None:
256 | try:
257 |
258 | parser = argparse.ArgumentParser(
259 | description="Stake or unstake the service based on the state."
260 | )
261 | parser.add_argument(
262 | "service_id",
263 | type=int,
264 | help="The on-chain service id.",
265 | )
266 | parser.add_argument(
267 | "service_registry_address",
268 | type=str,
269 | help="The service registry contract address.",
270 | )
271 | parser.add_argument(
272 | "staking_contract_address",
273 | type=str,
274 | help="The staking contract address.",
275 | )
276 | parser.add_argument(
277 | "owner_private_key_path",
278 | type=str,
279 | help="Path to the file containing the service owner's Ethereum private key",
280 | )
281 | parser.add_argument("rpc", type=str, help="RPC for the Gnosis chain")
282 | parser.add_argument(
283 | "unstake",
284 | type=bool,
285 | help="True if the service should be unstaked, False if it should be staked",
286 | default=False,
287 | )
288 | parser.add_argument("--password", type=str, help="Private key password")
289 | args = parser.parse_args()
290 |
291 | env_file_vars = dotenv_values(DOTENV_PATH)
292 | target_program = env_file_vars.get("STAKING_PROGRAM")
293 |
294 | print(f"Starting {Path(__file__).name} script ({target_program})...\n")
295 |
296 | ledger_api = EthereumApi(address=args.rpc)
297 | owner_crypto = EthereumCrypto(
298 | private_key_path=args.owner_private_key_path, password=args.password
299 | )
300 |
301 | # --------------
302 | # Unstaking flow
303 | # --------------
304 | if args.unstake:
305 | _try_unstake_service(
306 | ledger_api=ledger_api,
307 | service_id=args.service_id,
308 | owner_crypto=owner_crypto,
309 | )
310 | return
311 |
312 | # --------------
313 | # Staking flow
314 | # --------------
315 | current_staking_contract_address, current_program = _get_current_staking_program(ledger_api, args.service_id)
316 | is_staked = current_program != NO_STAKING_PROGRAM_ID
317 |
318 | if is_staked and current_program != target_program:
319 | print(
320 | f"WARNING: Service {args.service_id} is staked on {current_program}, but target program is {target_program}. Unstaking..."
321 | )
322 | _try_unstake_service(
323 | ledger_api=ledger_api,
324 | service_id=args.service_id,
325 | owner_crypto=owner_crypto,
326 | )
327 | is_staked = False
328 |
329 | if is_staked and is_service_evicted(
330 | ledger_api, args.service_id, current_staking_contract_address
331 | ):
332 | print(
333 | f"Service {args.service_id} has been evicted from the {current_program} staking program due to inactivity. Unstaking..."
334 | )
335 | _try_unstake_service(
336 | ledger_api=ledger_api,
337 | service_id=args.service_id,
338 | owner_crypto=owner_crypto,
339 | warn_if_checkpoint_unavailable=False,
340 | )
341 | is_staked = False
342 |
343 | if is_staked and get_available_rewards(ledger_api, current_staking_contract_address) == 0:
344 | print(
345 | f"No rewards available on the {current_program} staking program. Unstaking service {args.service_id} from {current_program}..."
346 | )
347 | _try_unstake_service(
348 | ledger_api=ledger_api,
349 | service_id=args.service_id,
350 | owner_crypto=owner_crypto,
351 | )
352 | is_staked = False
353 | elif is_staked:
354 | print(
355 | f"There are rewards available. The service {args.service_id} should remain staked."
356 | )
357 |
358 | if is_staked:
359 | print(
360 | f"Service {args.service_id} is already staked on {target_program}. "
361 | f"Checking if the staking contract has any rewards..."
362 | )
363 | else:
364 |
365 | # At this point must be ensured all these conditions
366 | #
367 | # USE_STAKING==True
368 | # staking_state==Unstaked
369 | # available_slots > 0
370 | # available_rewards > 0
371 | # staking params==OK
372 | # state==DEPLOYED
373 |
374 | _try_stake_service(
375 | ledger_api=ledger_api,
376 | service_id=args.service_id,
377 | owner_crypto=owner_crypto,
378 | service_registry_address=args.service_registry_address,
379 | staking_contract_address=args.staking_contract_address,
380 | staking_program=target_program,
381 | )
382 |
383 | except Exception as e: # pylint: disable=broad-except
384 | print(f"An error occurred while executing {Path(__file__).name}: {str(e)}")
385 | traceback.print_exc()
386 | sys.exit(1)
387 |
388 |
389 | if __name__ == "__main__":
390 | main()
391 |
--------------------------------------------------------------------------------
/scripts/swap_safe_owner.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 | # -*- coding: utf-8 -*-
3 | # ------------------------------------------------------------------------------
4 | #
5 | # Copyright 2022-2023 Valory AG
6 | #
7 | # Licensed under the Apache License, Version 2.0 (the "License");
8 | # you may not use this file except in compliance with the License.
9 | # You may obtain a copy of the License at
10 | #
11 | # http://www.apache.org/licenses/LICENSE-2.0
12 | #
13 | # Unless required by applicable law or agreed to in writing, software
14 | # distributed under the License is distributed on an "AS IS" BASIS,
15 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16 | # See the License for the specific language governing permissions and
17 | # limitations under the License.
18 | #
19 | # ------------------------------------------------------------------------------
20 |
21 | """This script swaps ownership of a Safe with a single owner."""
22 |
23 | import argparse
24 | import binascii
25 | import sys
26 | import traceback
27 | import typing
28 | from pathlib import Path
29 |
30 | from aea.contracts.base import Contract
31 | from aea_ledger_ethereum.ethereum import EthereumApi, EthereumCrypto
32 | from hexbytes import HexBytes
33 | from web3 import HTTPProvider, Web3
34 |
35 | from packages.valory.contracts.gnosis_safe.contract import (
36 | GnosisSafeContract,
37 | SafeOperation,
38 | )
39 | from packages.valory.contracts.multisend.contract import (
40 | MultiSendContract,
41 | MultiSendOperation,
42 | )
43 | from packages.valory.skills.transaction_settlement_abci.payload_tools import (
44 | hash_payload_to_hex,
45 | skill_input_hex_to_payload,
46 | )
47 |
48 |
49 | ContractType = typing.TypeVar("ContractType")
50 |
51 |
52 | def load_contract(ctype: ContractType) -> ContractType:
53 | """Load contract."""
54 | *parts, _ = ctype.__module__.split(".")
55 | path = "/".join(parts)
56 | return Contract.from_dir(directory=path)
57 |
58 |
59 | if __name__ == "__main__":
60 | try:
61 | print(f" - Starting {Path(__file__).name} script...")
62 |
63 | parser = argparse.ArgumentParser(
64 | description="Swap ownership of a Safe with a single owner on the Gnosis chain."
65 | )
66 | parser.add_argument(
67 | "safe_address",
68 | type=str,
69 | help="Safe address",
70 | )
71 | parser.add_argument(
72 | "current_owner_private_key_path",
73 | type=str,
74 | help="Path to the file containing the Ethereum private key",
75 | )
76 | parser.add_argument(
77 | "new_owner_address", type=str, help="Recipient address on the Gnosis chain"
78 | )
79 | parser.add_argument("rpc", type=str, help="RPC for the Gnosis chain")
80 | parser.add_argument("--password", type=str, help="Private key password")
81 | args = parser.parse_args()
82 |
83 | ledger_api = EthereumApi(address=args.rpc)
84 | current_owner_crypto: EthereumCrypto
85 | current_owner_crypto = EthereumCrypto(
86 | private_key_path=args.current_owner_private_key_path, password=args.password
87 | )
88 | owner_cryptos = [current_owner_crypto] # type: ignore
89 |
90 | owners = [
91 | ledger_api.api.to_checksum_address(owner_crypto.address)
92 | for owner_crypto in owner_cryptos
93 | ]
94 |
95 | owner_to_swap = owners[0]
96 |
97 | print(f" - Safe address: {args.safe_address}")
98 | print(f" - Current owner: {owner_to_swap}")
99 | print(f" - New owner: {args.new_owner_address}")
100 |
101 | multisig_address = args.safe_address
102 | multisend_address = "0x40A2aCCbd92BCA938b02010E17A5b8929b49130D"
103 |
104 | print(" - Loading contracts...")
105 |
106 | safe = load_contract(GnosisSafeContract)
107 | multisend = load_contract(MultiSendContract)
108 |
109 | print(" - Building Safe.swapOwner transaction...")
110 |
111 | multisend_txs = []
112 |
113 | txd = safe.get_swap_owner_data(
114 | ledger_api=ledger_api,
115 | contract_address=multisig_address,
116 | old_owner=ledger_api.api.to_checksum_address(owner_to_swap),
117 | new_owner=ledger_api.api.to_checksum_address(args.new_owner_address),
118 | ).get("data")
119 | multisend_txs.append(
120 | {
121 | "operation": MultiSendOperation.CALL,
122 | "to": multisig_address,
123 | "value": 0,
124 | "data": HexBytes(txd[2:]),
125 | }
126 | )
127 |
128 | multisend_txd = multisend.get_tx_data( # type: ignore
129 | ledger_api=ledger_api,
130 | contract_address=multisend_address,
131 | multi_send_txs=multisend_txs,
132 | ).get("data")
133 | multisend_data = bytes.fromhex(multisend_txd[2:])
134 |
135 | safe_tx_hash = safe.get_raw_safe_transaction_hash(
136 | ledger_api=ledger_api,
137 | contract_address=multisig_address,
138 | to_address=multisend_address,
139 | value=0,
140 | data=multisend_data,
141 | safe_tx_gas=0,
142 | operation=SafeOperation.DELEGATE_CALL.value,
143 | ).get("tx_hash")[2:]
144 |
145 | payload_data = hash_payload_to_hex(
146 | safe_tx_hash=safe_tx_hash,
147 | ether_value=0,
148 | safe_tx_gas=0,
149 | to_address=multisend_address,
150 | data=multisend_data,
151 | )
152 |
153 | tx_params = skill_input_hex_to_payload(payload=payload_data)
154 | safe_tx_bytes = binascii.unhexlify(tx_params["safe_tx_hash"])
155 | owner_to_signature = {}
156 |
157 | print(" - Signing Safe.swapOwner transaction...")
158 |
159 | for owner_crypto in owner_cryptos:
160 | signature = owner_crypto.sign_message(
161 | message=safe_tx_bytes,
162 | is_deprecated_mode=True,
163 | )
164 | owner_to_signature[
165 | ledger_api.api.to_checksum_address(owner_crypto.address)
166 | ] = signature[2:]
167 |
168 | tx = safe.get_raw_safe_transaction(
169 | ledger_api=ledger_api,
170 | contract_address=multisig_address,
171 | sender_address=current_owner_crypto.address,
172 | owners=tuple(owners), # type: ignore
173 | to_address=tx_params["to_address"],
174 | value=tx_params["ether_value"],
175 | data=tx_params["data"],
176 | safe_tx_gas=tx_params["safe_tx_gas"],
177 | signatures_by_owner=owner_to_signature,
178 | operation=SafeOperation.DELEGATE_CALL.value,
179 | )
180 | stx = current_owner_crypto.sign_transaction(tx)
181 | tx_digest = ledger_api.send_signed_transaction(stx)
182 |
183 | w3 = Web3(HTTPProvider(args.rpc))
184 |
185 | print(f" - Safe.swapOwner transaction sent. Transaction hash: {tx_digest}")
186 | print(" - Waiting for transaction receipt...")
187 | receipt = w3.eth.wait_for_transaction_receipt(tx_digest)
188 |
189 | if receipt["status"] == 1:
190 | print(" - Safe.swapOwner transaction successfully mined.")
191 | print(
192 | f" - Safe owner successfully swapped from {owner_to_swap} to {args.new_owner_address}"
193 | )
194 | else:
195 | print(" - Safe.swapOwner transaction failed to be mined.")
196 | sys.exit(1)
197 |
198 | except Exception as e: # pylint: disable=broad-except
199 | print(f"An error occurred while executing {Path(__file__).name}: {str(e)}")
200 | traceback.print_exc()
201 | sys.exit(1)
202 |
--------------------------------------------------------------------------------
/scripts/utils.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | # ------------------------------------------------------------------------------
3 | #
4 | # Copyright 2023 Valory AG
5 | #
6 | # Licensed under the Apache License, Version 2.0 (the "License");
7 | # you may not use this file except in compliance with the License.
8 | # You may obtain a copy of the License at
9 | #
10 | # http://www.apache.org/licenses/LICENSE-2.0
11 | #
12 | # Unless required by applicable law or agreed to in writing, software
13 | # distributed under the License is distributed on an "AS IS" BASIS,
14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15 | # See the License for the specific language governing permissions and
16 | # limitations under the License.
17 | #
18 | # ------------------------------------------------------------------------------
19 |
20 | """This package contains utils for working with the staking contract."""
21 |
22 | import typing
23 | from datetime import datetime
24 | import time
25 |
26 | from aea.contracts.base import Contract
27 | from aea_ledger_ethereum.ethereum import EthereumApi, EthereumCrypto
28 | from eth_typing import HexStr
29 |
30 | from packages.valory.contracts.erc20.contract import (
31 | ERC20,
32 | )
33 | from packages.valory.contracts.service_staking_token.contract import (
34 | ServiceStakingTokenContract,
35 | )
36 | from packages.valory.contracts.staking_token.contract import StakingTokenContract
37 | from autonomy.chain.tx import (
38 | TxSettler,
39 | should_retry,
40 | should_reprice,
41 | )
42 | from requests.exceptions import ConnectionError as RequestsConnectionError
43 | from autonomy.chain.exceptions import (
44 | ChainInteractionError,
45 | ChainTimeoutError,
46 | RPCError,
47 | TxBuildError,
48 | )
49 | from autonomy.chain.config import ChainType
50 | from dotenv import dotenv_values
51 | from choose_staking import ZERO_ADDRESS
52 | from packages.valory.skills.staking_abci.rounds import StakingState
53 | from pathlib import Path
54 |
55 | SCRIPT_PATH = Path(__file__).resolve().parent
56 | STORE_PATH = Path(SCRIPT_PATH, "..", ".trader_runner")
57 | DOTENV_PATH = Path(STORE_PATH, ".env")
58 |
59 | DEFAULT_ON_CHAIN_INTERACT_TIMEOUT = 120.0
60 | DEFAULT_ON_CHAIN_INTERACT_RETRIES = 10
61 | DEFAULT_ON_CHAIN_INTERACT_SLEEP = 6.0
62 |
63 |
64 | ZERO_ETH = 0
65 |
66 | ContractType = typing.TypeVar("ContractType")
67 |
68 | GAS_PARAMS = {
69 | "maxFeePerGas": 30_000_000_000,
70 | "maxPriorityFeePerGas": 3_000_000_000,
71 | "gas": 500_000,
72 | }
73 |
74 | def load_contract(ctype: ContractType) -> ContractType:
75 | """Load contract."""
76 | *parts, _ = ctype.__module__.split(".")
77 | path = "/".join(parts)
78 | return Contract.from_dir(directory=path)
79 |
80 |
81 | def get_approval_tx(
82 | ledger_api: EthereumApi,
83 | token: str,
84 | spender: str,
85 | amount: int,
86 | ) -> typing.Dict[str, typing.Any]:
87 | """Get approval tx"""
88 | approval_tx_data = erc20.build_approval_tx(
89 | ledger_api,
90 | token,
91 | spender,
92 | amount,
93 | ).pop("data")
94 | approval_tx = {
95 | "data": approval_tx_data,
96 | "to": token,
97 | "value": ZERO_ETH,
98 | }
99 | return approval_tx
100 |
101 |
102 | def get_balances(
103 | ledger_api: EthereumApi,
104 | token: str,
105 | owner: str,
106 | ) -> typing.Tuple[int, int]:
107 | """Returns the native and token balance of owner."""
108 | balances = erc20.check_balance(ledger_api, token, owner)
109 | token_balance, native_balance = balances.pop("token"), balances.pop("wallet")
110 | return token_balance, native_balance
111 |
112 |
113 | def get_allowance(
114 | ledger_api: EthereumApi,
115 | token: str,
116 | owner: str,
117 | spender: str,
118 | ) -> int:
119 | """Returns the allowance of owner for spender."""
120 | allowance = erc20.get_allowance(ledger_api, token, owner, spender).pop("data")
121 | return allowance
122 |
123 |
124 | def get_stake_txs(
125 | ledger_api: EthereumApi,
126 | service_id: int,
127 | service_registry_address: str,
128 | staking_contract_address: str,
129 | ) -> typing.List:
130 | """Stake the service"""
131 | # 1. approve the service to make use of the
132 |
133 | # we make use of the ERC20 contract to build the approval transaction
134 | # since it has the same interface as ERC721
135 | # we use the ZERO_ADDRESS as the contract address since we don't do any contract interaction here,
136 | # we are simply encoding
137 | approval_tx = get_approval_tx(
138 | ledger_api, service_registry_address, staking_contract_address, service_id
139 | )
140 |
141 | # 2. stake the service
142 | stake_tx_data = staking_contract.build_stake_tx(
143 | ledger_api, staking_contract_address, service_id
144 | ).pop("data")
145 | stake_tx = {
146 | "data": stake_tx_data,
147 | "to": staking_contract_address,
148 | "value": ZERO_ETH,
149 | }
150 | return [approval_tx, stake_tx]
151 |
152 |
153 | def get_unstake_txs(
154 | ledger_api: EthereumApi, service_id: int, staking_contract_address: str
155 | ) -> typing.List:
156 | """Get unstake txs"""
157 |
158 | unstake_tx_data = staking_contract.build_unstake_tx(
159 | ledger_api, staking_contract_address, service_id
160 | ).pop("data")
161 | unstake_tx = {
162 | "data": unstake_tx_data,
163 | "to": staking_contract_address,
164 | "value": ZERO_ETH,
165 | }
166 |
167 | return [unstake_tx]
168 |
169 |
170 | def get_available_rewards(
171 | ledger_api: EthereumApi, staking_contract_address: str
172 | ) -> int:
173 | """Get available rewards."""
174 | rewards = staking_contract.available_rewards(
175 | ledger_api, staking_contract_address
176 | ).pop("data")
177 | return rewards
178 |
179 |
180 | def is_service_staked(
181 | ledger_api: EthereumApi, service_id: int, staking_contract_address: str
182 | ) -> bool:
183 | """Check if service is staked."""
184 | # TODO Not best approach. This is required because staking.py might call
185 | # different contract versions.
186 | for staking_contract in all_staking_contracts:
187 | try:
188 | service_staking_state = staking_contract.get_service_staking_state(
189 | ledger_api, staking_contract_address, service_id
190 | ).pop("data")
191 |
192 | if isinstance(service_staking_state, int):
193 | service_staking_state = StakingState(service_staking_state)
194 |
195 | is_staked = service_staking_state == StakingState.STAKED or service_staking_state == StakingState.EVICTED
196 | return is_staked
197 | except: # noqa
198 | continue
199 |
200 | raise Exception("Unable to retrieve staking state.")
201 |
202 |
203 | def is_service_evicted(
204 | ledger_api: EthereumApi, service_id: int, staking_contract_address: str
205 | ) -> bool:
206 | """Check if service is staked."""
207 | # TODO Not best approach. This is required because staking.py might call
208 | # different contract versions.
209 | for staking_contract in all_staking_contracts:
210 | try:
211 | service_staking_state = staking_contract.get_service_staking_state(
212 | ledger_api, staking_contract_address, service_id
213 | ).pop("data")
214 |
215 | if isinstance(service_staking_state, int):
216 | service_staking_state = StakingState(service_staking_state)
217 |
218 | is_evicted = service_staking_state == StakingState.EVICTED
219 | return is_evicted
220 | except: # noqa
221 | continue
222 |
223 | raise Exception("Unable to retrieve eviction state.")
224 |
225 |
226 | def get_next_checkpoint_ts(
227 | ledger_api: EthereumApi, staking_contract_address: str
228 | ) -> int:
229 | """Check if service is staked."""
230 | checkpoint_ts = staking_contract.get_next_checkpoint_ts(
231 | ledger_api, staking_contract_address
232 | ).pop("data")
233 | return checkpoint_ts
234 |
235 |
236 | def get_staking_rewards(
237 | ledger_api: EthereumApi, service_id: int, staking_contract_address: str
238 | ) -> int:
239 | """Check if service is staked."""
240 | rewards = staking_contract.get_staking_rewards(
241 | ledger_api, staking_contract_address, service_id
242 | ).pop("data")
243 | return rewards
244 |
245 |
246 | def get_liveness_period(
247 | ledger_api: EthereumApi, staking_contract_address: str
248 | ) -> int:
249 | """Get the liveness period."""
250 | liveness_period = staking_contract.get_liveness_period(
251 | ledger_api, staking_contract_address
252 | ).pop("data")
253 | return liveness_period
254 |
255 |
256 | def get_min_staking_duration(
257 | ledger_api: EthereumApi, staking_contract_address: str
258 | ) -> int:
259 | """Get the liveness period."""
260 | min_staking_duration = staking_contract.get_min_staking_duration(
261 | ledger_api, staking_contract_address
262 | ).pop("data")
263 | return min_staking_duration
264 |
265 |
266 | def get_service_info(
267 | ledger_api: EthereumApi, service_id: int, staking_contract_address: str
268 | ) -> typing.List:
269 | """Get the service info."""
270 | info = staking_contract.get_service_info(
271 | ledger_api, staking_contract_address, service_id
272 | ).pop("data")
273 | return info
274 |
275 |
276 | def get_price_with_retries(
277 | ledger_api: EthereumApi, staking_contract_address: str, retries: int = 5
278 | ) -> int:
279 | """Get the price with retries."""
280 | for i in range(retries):
281 | try:
282 | price = staking_contract.try_get_gas_pricing(ledger_api, staking_contract_address)
283 | return price
284 | except Exception as e:
285 | print(e)
286 | continue
287 | raise ValueError("Failed to get price after retries")
288 |
289 |
290 | def get_available_staking_slots(
291 | ledger_api: EthereumApi, staking_contract_address: str
292 | ) -> int:
293 | """Get available staking slots"""
294 | max_num_services = staking_contract.max_num_services(
295 | ledger_api, staking_contract_address).pop("data")
296 |
297 | service_ids = staking_contract.get_service_ids(
298 | ledger_api, staking_contract_address).pop("data")
299 |
300 | return max_num_services - len(service_ids)
301 |
302 |
303 | def get_service_ids(
304 | ledger_api: EthereumApi, staking_contract_address: str
305 | ) -> typing.List[int]:
306 | """Get service Ids"""
307 | service_ids = staking_contract.get_service_ids(
308 | ledger_api, staking_contract_address).pop("data")
309 |
310 | return service_ids
311 |
312 |
313 | def send_tx(
314 | ledger_api: EthereumApi,
315 | crypto: EthereumCrypto,
316 | raw_tx: typing.Dict[str, typing.Any],
317 | timeout: float = DEFAULT_ON_CHAIN_INTERACT_TIMEOUT,
318 | max_retries: int = DEFAULT_ON_CHAIN_INTERACT_RETRIES,
319 | sleep: float = DEFAULT_ON_CHAIN_INTERACT_SLEEP,
320 | ) -> str:
321 | """Send transaction."""
322 | tx_dict = {
323 | **raw_tx,
324 | **GAS_PARAMS,
325 | "from": crypto.address,
326 | "nonce": ledger_api.api.eth.get_transaction_count(crypto.address),
327 | "chainId": ledger_api.api.eth.chain_id,
328 | }
329 | gas_params = ledger_api.try_get_gas_pricing()
330 | if gas_params is not None:
331 | tx_dict.update(gas_params)
332 |
333 | tx_settler = TxSettler(ledger_api, crypto, ChainType.CUSTOM)
334 | retries = 0
335 | tx_digest = None
336 | already_known = False
337 | deadline = datetime.now().timestamp() + timeout
338 | while retries < max_retries and deadline >= datetime.now().timestamp():
339 | retries += 1
340 | try:
341 | if not already_known:
342 | tx_signed = crypto.sign_transaction(transaction=tx_dict)
343 | tx_digest = ledger_api.send_signed_transaction(
344 | tx_signed=tx_signed,
345 | raise_on_try=True,
346 | )
347 | tx_receipt = ledger_api.api.eth.get_transaction_receipt(
348 | typing.cast(str, tx_digest)
349 | )
350 | if tx_receipt is not None:
351 | return tx_receipt
352 | except RequestsConnectionError as e:
353 | raise RPCError("Cannot connect to the given RPC") from e
354 | except Exception as e: # pylint: disable=broad-except
355 | error = str(e)
356 | if tx_settler._already_known(error):
357 | already_known = True
358 | continue # pragma: nocover
359 | if not should_retry(error):
360 | raise ChainInteractionError(error) from e
361 | if should_reprice(error):
362 | print("Repricing the transaction...")
363 | tx_dict = tx_settler._reprice(typing.cast(typing.Dict, tx_dict))
364 | continue
365 | print(f"Error occurred when interacting with chain: {e}; ")
366 | print(f"will retry in {sleep}...")
367 | time.sleep(sleep)
368 | raise ChainTimeoutError("Timed out when waiting for transaction to go through")
369 |
370 |
371 | def send_tx_and_wait_for_receipt(
372 | ledger_api: EthereumApi,
373 | crypto: EthereumCrypto,
374 | raw_tx: typing.Dict[str, typing.Any],
375 | ) -> typing.Dict[str, typing.Any]:
376 | """Send transaction and wait for receipt."""
377 | receipt = HexStr(send_tx(ledger_api, crypto, raw_tx))
378 | if receipt["status"] != 1:
379 | raise ValueError("Transaction failed. Receipt:", receipt)
380 | return receipt
381 |
382 |
383 | # TODO 'staking_contract' refers to the current active program.abs
384 | # There are methods above that will be called for other programs,
385 | # and whose ABI might differ. A "patch" is currently implemented,
386 | # but it should be refactored in a more elegant and robust way.
387 | env_file_vars = dotenv_values(DOTENV_PATH)
388 | activity_checker = env_file_vars.get("MECH_ACTIVITY_CHECKER_CONTRACT")
389 |
390 | if activity_checker is None or activity_checker == ZERO_ADDRESS:
391 | staking_contract = typing.cast(
392 | typing.Type[Contract], load_contract(ServiceStakingTokenContract)
393 | )
394 | else:
395 | staking_contract = typing.cast(
396 | typing.Type[Contract], load_contract(StakingTokenContract)
397 | )
398 |
399 | all_staking_contracts = [
400 | typing.cast(
401 | typing.Type[Contract], load_contract(ServiceStakingTokenContract)
402 | ),
403 | typing.cast(
404 | typing.Type[Contract], load_contract(StakingTokenContract)
405 | )
406 | ]
407 |
408 | erc20 = typing.cast(typing.Type[ERC20], load_contract(ERC20))
409 |
--------------------------------------------------------------------------------
/stop_service.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | # ------------------------------------------------------------------------------
4 | #
5 | # Copyright 2023 Valory AG
6 | #
7 | # Licensed under the Apache License, Version 2.0 (the "License");
8 | # you may not use this file except in compliance with the License.
9 | # You may obtain a copy of the License at
10 | #
11 | # http://www.apache.org/licenses/LICENSE-2.0
12 | #
13 | # Unless required by applicable law or agreed to in writing, software
14 | # distributed under the License is distributed on an "AS IS" BASIS,
15 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16 | # See the License for the specific language governing permissions and
17 | # limitations under the License.
18 | #
19 | # ------------------------------------------------------------------------------
20 |
21 | # force utf mode for python, cause sometimes there are issues with local codepages
22 | export PYTHONUTF8=1
23 |
24 |
25 | cd trader
26 | service_dir="trader_service"
27 | build_dir=$(ls -d "$service_dir"/abci_build_???? 2>/dev/null || echo "$service_dir/abci_build")
28 | poetry run autonomy deploy stop --build-dir "$build_dir"
29 | cd ..
30 |
--------------------------------------------------------------------------------
/terminate_on_chain_service.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | # ------------------------------------------------------------------------------
4 | #
5 | # Copyright 2023-2024 Valory AG
6 | #
7 | # Licensed under the Apache License, Version 2.0 (the "License");
8 | # you may not use this file except in compliance with the License.
9 | # You may obtain a copy of the License at
10 | #
11 | # http://www.apache.org/licenses/LICENSE-2.0
12 | #
13 | # Unless required by applicable law or agreed to in writing, software
14 | # distributed under the License is distributed on an "AS IS" BASIS,
15 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16 | # See the License for the specific language governing permissions and
17 | # limitations under the License.
18 | #
19 | # ------------------------------------------------------------------------------
20 |
21 |
22 | # force utf mode for python, cause sometimes there are issues with local codepages
23 | export PYTHONUTF8=1
24 |
25 |
26 | # Get the address from a keys.json file
27 | get_address() {
28 | local keys_json_path="$1"
29 |
30 | if [ ! -f "$keys_json_path" ]; then
31 | echo "Error: $keys_json_path does not exist."
32 | return 1
33 | fi
34 |
35 | local address_start_position=17
36 | local address=$(sed -n 3p "$keys_json_path")
37 | address=$(echo "$address" |
38 | awk '{ print substr( $0, '$address_start_position', length($0) - '$address_start_position' - 1 ) }')
39 |
40 | echo -n "$address"
41 | }
42 |
43 | # Get the private key from a keys.json file
44 | get_private_key() {
45 | local keys_json_path="$1"
46 |
47 | if [ ! -f "$keys_json_path" ]; then
48 | echo "Error: $keys_json_path does not exist."
49 | return 1
50 | fi
51 |
52 | local private_key_start_position=21
53 | local private_key=$(sed -n 4p "$keys_json_path")
54 | private_key=$(echo -n "$private_key" |
55 | awk '{ printf substr( $0, '$private_key_start_position', length($0) - '$private_key_start_position' ) }')
56 |
57 | private_key=$(echo -n "$private_key" | awk '{gsub(/\\"/, "\"", $0); print $0}')
58 | private_key="${private_key#0x}"
59 |
60 | echo -n "$private_key"
61 | }
62 |
63 | # Function to retrieve on-chain service state (requires env variables set to use --use-custom-chain)
64 | get_on_chain_service_state() {
65 | local service_id="$1"
66 | local service_info=$(poetry run autonomy service --retries $RPC_RETRIES --timeout $RPC_TIMEOUT_SECONDS --use-custom-chain info "$service_id")
67 | local state="$(echo "$service_info" | awk '/Service State/ {sub(/\|[ \t]*Service State[ \t]*\|[ \t]*/, ""); sub(/[ \t]*\|[ \t]*/, ""); print}')"
68 | echo "$state"
69 | }
70 |
71 | # Asks password if key files are password-protected
72 | ask_password_if_needed() {
73 | agent_pkey=$(get_private_key "$keys_json_path")
74 | if [[ "$agent_pkey" = *crypto* ]]; then
75 | echo "Enter your password"
76 | echo "-------------------"
77 | echo "Your key files are protected with a password."
78 | read -s -p "Please, enter your password: " password
79 | use_password=true
80 | password_argument="--password $password"
81 | echo ""
82 | else
83 | echo "Your key files are not protected with a password."
84 | use_password=false
85 | password_argument=""
86 | fi
87 | echo ""
88 | }
89 |
90 | # Validates the provided password
91 | validate_password() {
92 | local is_password_valid_1=$(poetry run python ../scripts/is_keys_json_password_valid.py ../$keys_json_path $password_argument)
93 | local is_password_valid_2=$(poetry run python ../scripts/is_keys_json_password_valid.py ../$operator_keys_file $password_argument)
94 |
95 | if [ "$is_password_valid_1" != "True" ] || [ "$is_password_valid_2" != "True" ]; then
96 | echo "Could not decrypt key files. Please verify if your key files are password-protected, and if the provided password is correct (passwords are case-sensitive)."
97 | echo "Terminating the script."
98 | exit 1
99 | fi
100 | }
101 |
102 | export_dotenv() {
103 | local dotenv_path="$1"
104 | unamestr=$(uname)
105 | if [ "$unamestr" = 'Linux' ]; then
106 | export $(grep -v '^#' $dotenv_path | xargs -d '\n')
107 | elif [ "$unamestr" = 'FreeBSD' ] || [ "$unamestr" = 'Darwin' ]; then
108 | export $(grep -v '^#' $dotenv_path | xargs -0)
109 | fi
110 | }
111 |
112 | store=".trader_runner"
113 | env_file_path="$store/.env"
114 | rpc_path="$store/rpc.txt"
115 | operator_keys_file="$store/operator_keys.json"
116 | operator_pkey_path="$store/operator_pkey.txt"
117 | keys_json="keys.json"
118 | keys_json_path="$store/$keys_json"
119 | agent_pkey_path="$store/agent_pkey.txt"
120 | agent_address_path="$store/agent_address.txt"
121 | service_id_path="$store/service_id.txt"
122 | service_safe_address_path="$store/service_safe_address.txt"
123 | store_readme_path="$store/README.txt"
124 |
125 | source "$env_file_path"
126 | rpc=$(cat $rpc_path)
127 | operator_address=$(get_address $operator_keys_file)
128 | service_id=$(cat $service_id_path)
129 | unstake=true
130 | gnosis_chain_id=100
131 |
132 | # Define constants for on-chain interaction
133 | export RPC_RETRIES=40
134 | export RPC_TIMEOUT_SECONDS=120
135 | export CUSTOM_CHAIN_RPC=$rpc
136 | export CUSTOM_CHAIN_ID=$gnosis_chain_id
137 | export CUSTOM_SERVICE_MANAGER_ADDRESS="0x04b0007b2aFb398015B76e5f22993a1fddF83644"
138 | export CUSTOM_GNOSIS_SAFE_PROXY_FACTORY_ADDRESS="0x3C1fF68f5aa342D296d4DEe4Bb1cACCA912D95fE"
139 | export CUSTOM_GNOSIS_SAFE_SAME_ADDRESS_MULTISIG_ADDRESS="0x6e7f594f680f7aBad18b7a63de50F0FeE47dfD06"
140 | export CUSTOM_MULTISEND_ADDRESS="0x40A2aCCbd92BCA938b02010E17A5b8929b49130D"
141 | export WXDAI_ADDRESS="0xe91D153E0b41518A2Ce8Dd3D7944Fa863463a97d"
142 |
143 | # Check if --attended flag is passed
144 | export ATTENDED=true
145 | for arg in "$@"; do
146 | if [ "$arg" = "--attended=false" ]; then
147 | export ATTENDED=false
148 | fi
149 | done
150 |
151 | export_dotenv "$env_file_path"
152 |
153 | set -e # Exit script on first error
154 | echo "--------------------------"
155 | echo "Terminate on-chain service"
156 | echo "--------------------------"
157 | echo ""
158 | echo "This script will terminate and unbond your on-chain service (id $service_id)."
159 | echo "If your service is staked, you will receive the staking funds to the owner/operator address:"
160 | echo "$operator_address"
161 | echo
162 | response="y"
163 | if [ "$attended" = true ]; then
164 | echo "Please, ensure that your service is stopped (./stop_service.sh) before proceeding."
165 | echo "Do you want to continue? (yes/no)"
166 | read -r response
167 | echo ""
168 | fi
169 |
170 | if [[ ! "$response" =~ ^([yY][eE][sS]|[yY])$ ]]; then
171 | echo "Cancelled."
172 | exit 0
173 | fi
174 |
175 | ask_password_if_needed
176 | cd trader
177 | validate_password
178 |
179 | # Unstake if applicable
180 | poetry run python "../scripts/staking.py" "$service_id" "$CUSTOM_SERVICE_REGISTRY_ADDRESS" "$CUSTOM_STAKING_ADDRESS" "../$operator_pkey_path" "$rpc" "$unstake" $password_argument;
181 |
182 | service_safe_address=$(cat "../$service_safe_address_path")
183 | current_safe_owners=$(poetry run python "../scripts/get_safe_owners.py" "$service_safe_address" "../$agent_pkey_path" "$rpc" $password_argument | awk '{gsub(/"/, "\047", $0); print $0}')
184 | agent_address=$(get_address "../$keys_json_path")
185 |
186 | # transfer the ownership of the Safe from the agent to the service owner
187 | # (in a live service, this should be done by sending a 0 DAI transfer to its Safe)
188 | if [[ "$current_safe_owners" == "['$agent_address']" ]]; then
189 | echo "[Agent instance] Swapping Safe owner..."
190 | poetry run python "../scripts/swap_safe_owner.py" "$service_safe_address" "../$agent_pkey_path" "$operator_address" "$rpc" $password_argument
191 | fi
192 |
193 | # terminate current service
194 | state="$(get_on_chain_service_state "$service_id")"
195 | if [ "$state" == "ACTIVE_REGISTRATION" ] || [ "$state" == "FINISHED_REGISTRATION" ] || [ "$state" == "DEPLOYED" ]; then
196 | echo "[Service owner] Terminating on-chain service $service_id..."
197 | poetry run autonomy service \
198 | --retries $RPC_RETRIES \
199 | --timeout $RPC_TIMEOUT_SECONDS \
200 | --use-custom-chain \
201 | terminate "$service_id" \
202 | --key "../$operator_pkey_path" $password_argument
203 | fi
204 |
205 | # unbond current service
206 | if [ "$(get_on_chain_service_state "$service_id")" == "TERMINATED_BONDED" ]; then
207 | echo "[Operator] Unbonding on-chain service $service_id..."
208 | poetry run autonomy service \
209 | --retries $RPC_RETRIES \
210 | --timeout $RPC_TIMEOUT_SECONDS \
211 | --use-custom-chain \
212 | unbond "$service_id" \
213 | --key "../$operator_pkey_path" $password_argument
214 | fi
215 |
216 | if [ "$(get_on_chain_service_state "$service_id")" == "PRE_REGISTRATION" ]; then
217 | echo "Service $service_id is now terminated and unbonded (i.e., it is on PRE-REGISTRATION state)."
218 | echo "You can check this on https://registry.olas.network/gnosis/services/$service_id."
219 | echo "In order to deploy your on-chain service again, please execute './run_service.sh'."
220 | fi
221 | echo "Finished."
222 |
--------------------------------------------------------------------------------
/trades.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 | # -*- coding: utf-8 -*-
3 | # ------------------------------------------------------------------------------
4 | #
5 | # Copyright 2022-2024 Valory AG
6 | #
7 | # Licensed under the Apache License, Version 2.0 (the "License");
8 | # you may not use this file except in compliance with the License.
9 | # You may obtain a copy of the License at
10 | #
11 | # http://www.apache.org/licenses/LICENSE-2.0
12 | #
13 | # Unless required by applicable law or agreed to in writing, software
14 | # distributed under the License is distributed on an "AS IS" BASIS,
15 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16 | # See the License for the specific language governing permissions and
17 | # limitations under the License.
18 | #
19 | # ------------------------------------------------------------------------------
20 |
21 | """This script queries the OMEN subgraph to obtain the trades of a given address."""
22 |
23 | import datetime
24 | import os
25 | import re
26 | from argparse import Action, ArgumentError, ArgumentParser, Namespace
27 | from collections import defaultdict
28 | from dotenv import load_dotenv
29 | from enum import Enum
30 | from pathlib import Path
31 | from string import Template
32 | from typing import Any, Dict, Optional
33 |
34 | import requests
35 |
36 | from scripts.mech_events import get_mech_requests
37 |
38 |
39 | IRRELEVANT_TOOLS = [
40 | "openai-text-davinci-002",
41 | "openai-text-davinci-003",
42 | "openai-gpt-3.5-turbo",
43 | "openai-gpt-4",
44 | "stabilityai-stable-diffusion-v1-5",
45 | "stabilityai-stable-diffusion-xl-beta-v2-2-2",
46 | "stabilityai-stable-diffusion-512-v2-1",
47 | "stabilityai-stable-diffusion-768-v2-1",
48 | "deepmind-optimization-strong",
49 | "deepmind-optimization",
50 | ]
51 | QUERY_BATCH_SIZE = 1000
52 | DUST_THRESHOLD = 10000000000000
53 | INVALID_ANSWER = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF
54 | FPMM_CREATOR = "0x89c5cc945dd550bcffb72fe42bff002429f46fec"
55 | DEFAULT_FROM_DATE = "1970-01-01T00:00:00"
56 | DEFAULT_TO_DATE = "2038-01-19T03:14:07"
57 | DEFAULT_FROM_TIMESTAMP = 0
58 | DEFAULT_TO_TIMESTAMP = 2147483647
59 | SCRIPT_PATH = Path(__file__).resolve().parent
60 | STORE_PATH = Path(SCRIPT_PATH, ".trader_runner")
61 | RPC_PATH = Path(STORE_PATH, "rpc.txt")
62 | ENV_FILE = Path(STORE_PATH, ".env")
63 | WXDAI_CONTRACT_ADDRESS = "0xe91D153E0b41518A2Ce8Dd3D7944Fa863463a97d"
64 | SCRIPT_PATH = Path(__file__).resolve().parent
65 | STORE_PATH = Path(SCRIPT_PATH, ".trader_runner")
66 | SAFE_ADDRESS_PATH = Path(STORE_PATH, "service_safe_address.txt")
67 |
68 |
69 | load_dotenv(ENV_FILE)
70 |
71 |
72 | headers = {
73 | "Accept": "application/json, multipart/mixed",
74 | "Content-Type": "application/json",
75 | }
76 |
77 |
78 | omen_xdai_trades_query = Template(
79 | """
80 | {
81 | fpmmTrades(
82 | where: {
83 | type: Buy,
84 | creator: "${creator}",
85 | fpmm_: {
86 | creator: "${fpmm_creator}"
87 | creationTimestamp_gte: "${fpmm_creationTimestamp_gte}",
88 | creationTimestamp_lt: "${fpmm_creationTimestamp_lte}"
89 | },
90 | creationTimestamp_gte: "${creationTimestamp_gte}",
91 | creationTimestamp_lte: "${creationTimestamp_lte}"
92 | creationTimestamp_gt: "${creationTimestamp_gt}"
93 | }
94 | first: ${first}
95 | orderBy: creationTimestamp
96 | orderDirection: asc
97 | ) {
98 | id
99 | title
100 | collateralToken
101 | outcomeTokenMarginalPrice
102 | oldOutcomeTokenMarginalPrice
103 | type
104 | creator {
105 | id
106 | }
107 | creationTimestamp
108 | collateralAmount
109 | collateralAmountUSD
110 | feeAmount
111 | outcomeIndex
112 | outcomeTokensTraded
113 | transactionHash
114 | fpmm {
115 | id
116 | outcomes
117 | title
118 | answerFinalizedTimestamp
119 | currentAnswer
120 | isPendingArbitration
121 | arbitrationOccurred
122 | openingTimestamp
123 | condition {
124 | id
125 | }
126 | }
127 | }
128 | }
129 | """
130 | )
131 |
132 |
133 | conditional_tokens_gc_user_query = Template(
134 | """
135 | {
136 | user(id: "${id}") {
137 | userPositions(
138 | first: ${first}
139 | where: {
140 | id_gt: "${userPositions_id_gt}"
141 | }
142 | orderBy: id
143 | ) {
144 | balance
145 | id
146 | position {
147 | id
148 | conditionIds
149 | }
150 | totalBalance
151 | wrappedBalance
152 | }
153 | }
154 | }
155 | """
156 | )
157 |
158 |
159 | class MarketState(Enum):
160 | """Market state"""
161 |
162 | OPEN = 1
163 | PENDING = 2
164 | FINALIZING = 3
165 | ARBITRATING = 4
166 | CLOSED = 5
167 | UNKNOWN = 6
168 |
169 | def __str__(self) -> str:
170 | """Prints the market status."""
171 | return self.name.capitalize()
172 |
173 |
174 | class MarketAttribute(Enum):
175 | """Attribute"""
176 |
177 | NUM_TRADES = "Num_trades"
178 | NUM_VALID_TRADES = "Num_valid_trades"
179 | WINNER_TRADES = "Winner_trades"
180 | NUM_REDEEMED = "Num_redeemed"
181 | NUM_INVALID_MARKET = "Num_invalid_market"
182 | INVESTMENT = "Investment"
183 | FEES = "Fees"
184 | MECH_CALLS = "Mech_calls"
185 | MECH_FEES = "Mech_fees"
186 | EARNINGS = "Earnings"
187 | NET_EARNINGS = "Net_earnings"
188 | REDEMPTIONS = "Redemptions"
189 | ROI = "ROI"
190 |
191 | def __str__(self) -> str:
192 | """Prints the attribute."""
193 | return self.value
194 |
195 | def __repr__(self) -> str:
196 | """Prints the attribute representation."""
197 | return self.name
198 |
199 | @staticmethod
200 | def argparse(s: str) -> "MarketAttribute":
201 | """Performs string conversion to MarketAttribute."""
202 | try:
203 | return MarketAttribute[s.upper()]
204 | except KeyError as e:
205 | raise ValueError(f"Invalid MarketAttribute: {s}") from e
206 |
207 |
208 | STATS_TABLE_COLS = list(MarketState) + ["TOTAL"]
209 | STATS_TABLE_ROWS = list(MarketAttribute)
210 |
211 |
212 | def get_balance(address: str, rpc_url: str) -> int:
213 | """Get the native xDAI balance of an address in wei."""
214 | headers = {"Content-Type": "application/json"}
215 | data = {
216 | "jsonrpc": "2.0",
217 | "method": "eth_getBalance",
218 | "params": [address, "latest"],
219 | "id": 1,
220 | }
221 | response = requests.post(rpc_url, headers=headers, json=data)
222 | return int(response.json().get("result"), 16)
223 |
224 |
225 | def get_token_balance(
226 | gnosis_address: str, token_contract_address: str, rpc_url: str
227 | ) -> int:
228 | """Get the token balance of an address in wei."""
229 | function_selector = "70a08231" # function selector for balanceOf(address)
230 | padded_address = gnosis_address.replace("0x", "").rjust(
231 | 64, "0"
232 | ) # remove '0x' and pad the address to 32 bytes
233 | data = function_selector + padded_address
234 |
235 | payload = {
236 | "jsonrpc": "2.0",
237 | "method": "eth_call",
238 | "params": [{"to": token_contract_address, "data": data}, "latest"],
239 | "id": 1,
240 | }
241 | response = requests.post(rpc_url, json=payload)
242 | result = response.json().get("result", "0x0")
243 | balance_wei = int(result, 16) # convert hex to int
244 | return balance_wei
245 |
246 |
247 | class EthereumAddressAction(Action):
248 | """Argparse class to validate an Ethereum addresses."""
249 |
250 | def __call__(
251 | self,
252 | parser: ArgumentParser,
253 | namespace: Namespace,
254 | values: Any,
255 | option_string: Optional[str] = None,
256 | ) -> None:
257 | """Validates an Ethereum addresses."""
258 |
259 | address = values
260 | if not re.match(r"^0x[a-fA-F0-9]{40}$", address):
261 | raise ArgumentError(self, f"Invalid Ethereum address: {address}")
262 | setattr(namespace, self.dest, address)
263 |
264 |
265 | def _parse_args() -> Any:
266 | """Parse the script arguments."""
267 | parser = ArgumentParser(description="Get trades on Omen for a Safe address.")
268 | parser.add_argument(
269 | "--creator",
270 | action=EthereumAddressAction,
271 | help="Ethereum address of the service Safe",
272 | )
273 | parser.add_argument(
274 | "--from-date",
275 | type=datetime.datetime.fromisoformat,
276 | default=DEFAULT_FROM_DATE,
277 | help="Start date (UTC) in YYYY-MM-DD:HH:mm:ss format",
278 | )
279 | parser.add_argument(
280 | "--to-date",
281 | type=datetime.datetime.fromisoformat,
282 | default=DEFAULT_TO_DATE,
283 | help="End date (UTC) in YYYY-MM-DD:HH:mm:ss format",
284 | )
285 | parser.add_argument(
286 | "--fpmm-created-from-date",
287 | type=datetime.datetime.fromisoformat,
288 | default=DEFAULT_FROM_DATE,
289 | help="Start date (UTC) in YYYY-MM-DD:HH:mm:ss format",
290 | )
291 | parser.add_argument(
292 | "--fpmm-created-to-date",
293 | type=datetime.datetime.fromisoformat,
294 | default=DEFAULT_TO_DATE,
295 | help="End date (UTC) in YYYY-MM-DD:HH:mm:ss format",
296 | )
297 | args = parser.parse_args()
298 |
299 | if args.creator is None:
300 | with open(SAFE_ADDRESS_PATH, "r", encoding="utf-8") as file:
301 | args.creator = file.read().strip()
302 |
303 | args.from_date = args.from_date.replace(tzinfo=datetime.timezone.utc)
304 | args.to_date = args.to_date.replace(tzinfo=datetime.timezone.utc)
305 | args.fpmm_created_from_date = args.fpmm_created_from_date.replace(
306 | tzinfo=datetime.timezone.utc
307 | )
308 | args.fpmm_created_to_date = args.fpmm_created_to_date.replace(
309 | tzinfo=datetime.timezone.utc
310 | )
311 |
312 | return args
313 |
314 |
315 | def _to_content(q: str) -> Dict[str, Any]:
316 | """Convert the given query string to payload content, i.e., add it under a `queries` key and convert it to bytes."""
317 | finalized_query = {
318 | "query": q,
319 | "variables": None,
320 | "extensions": {"headers": None},
321 | }
322 | return finalized_query
323 |
324 |
325 | def _query_omen_xdai_subgraph( # pylint: disable=too-many-locals
326 | creator: str,
327 | from_timestamp: float = DEFAULT_FROM_TIMESTAMP,
328 | to_timestamp: float = DEFAULT_TO_TIMESTAMP,
329 | fpmm_from_timestamp: float = DEFAULT_FROM_TIMESTAMP,
330 | fpmm_to_timestamp: float = DEFAULT_TO_TIMESTAMP,
331 | ) -> Dict[str, Any]:
332 | """Query the subgraph."""
333 | subgraph_api_key = os.getenv('SUBGRAPH_API_KEY')
334 | url = f"https://gateway-arbitrum.network.thegraph.com/api/{subgraph_api_key}/subgraphs/id/9fUVQpFwzpdWS9bq5WkAnmKbNNcoBwatMR4yZq81pbbz"
335 |
336 | grouped_results = defaultdict(list)
337 | creationTimestamp_gt = "0"
338 |
339 | while True:
340 | query = omen_xdai_trades_query.substitute(
341 | creator=creator.lower(),
342 | fpmm_creator=FPMM_CREATOR.lower(),
343 | creationTimestamp_gte=int(from_timestamp),
344 | creationTimestamp_lte=int(to_timestamp),
345 | fpmm_creationTimestamp_gte=int(fpmm_from_timestamp),
346 | fpmm_creationTimestamp_lte=int(fpmm_to_timestamp),
347 | first=QUERY_BATCH_SIZE,
348 | creationTimestamp_gt=creationTimestamp_gt,
349 | )
350 | content_json = _to_content(query)
351 | res = requests.post(url, headers=headers, json=content_json)
352 | result_json = res.json()
353 | trades = result_json.get("data", {}).get("fpmmTrades", [])
354 |
355 | if not trades:
356 | break
357 |
358 | for trade in trades:
359 | fpmm_id = trade.get("fpmm", {}).get("id")
360 | grouped_results[fpmm_id].append(trade)
361 |
362 | creationTimestamp_gt = trades[len(trades) - 1]["creationTimestamp"]
363 |
364 | all_results = {
365 | "data": {
366 | "fpmmTrades": [
367 | trade
368 | for trades_list in grouped_results.values()
369 | for trade in trades_list
370 | ]
371 | }
372 | }
373 |
374 | return all_results
375 |
376 |
377 | def _query_conditional_tokens_gc_subgraph(creator: str) -> Dict[str, Any]:
378 | """Query the subgraph."""
379 | subgraph_api_key = os.getenv('SUBGRAPH_API_KEY')
380 | url = f"https://gateway-arbitrum.network.thegraph.com/api/{subgraph_api_key}/subgraphs/id/7s9rGBffUTL8kDZuxvvpuc46v44iuDarbrADBFw5uVp2"
381 |
382 | all_results: Dict[str, Any] = {"data": {"user": {"userPositions": []}}}
383 | userPositions_id_gt = ""
384 | while True:
385 | query = conditional_tokens_gc_user_query.substitute(
386 | id=creator.lower(),
387 | first=QUERY_BATCH_SIZE,
388 | userPositions_id_gt=userPositions_id_gt,
389 | )
390 | content_json = {"query": query}
391 | res = requests.post(url, headers=headers, json=content_json)
392 | result_json = res.json()
393 | user_data = result_json.get("data", {}).get("user", {})
394 |
395 | if not user_data:
396 | break
397 |
398 | user_positions = user_data.get("userPositions", [])
399 |
400 | if user_positions:
401 | all_results["data"]["user"]["userPositions"].extend(user_positions)
402 | userPositions_id_gt = user_positions[len(user_positions) - 1]["id"]
403 | else:
404 | break
405 |
406 | if len(all_results["data"]["user"]["userPositions"]) == 0:
407 | return {"data": {"user": None}}
408 |
409 | return all_results
410 |
411 |
412 | def wei_to_unit(wei: int) -> float:
413 | """Converts wei to currency unit."""
414 | return wei / 10**18
415 |
416 |
417 | def wei_to_xdai(wei: int) -> str:
418 | """Converts and formats wei to xDAI."""
419 | return "{:.2f} xDAI".format(wei_to_unit(wei))
420 |
421 |
422 | def wei_to_wxdai(wei: int) -> str:
423 | """Converts and formats wei to WxDAI."""
424 | return "{:.2f} WxDAI".format(wei_to_unit(wei))
425 |
426 |
427 | def wei_to_olas(wei: int) -> str:
428 | """Converts and formats wei to WxDAI."""
429 | return "{:.2f} OLAS".format(wei_to_unit(wei))
430 |
431 |
432 | def _is_redeemed(user_json: Dict[str, Any], fpmmTrade: Dict[str, Any]) -> bool:
433 | user_positions = user_json["data"]["user"]["userPositions"]
434 | outcomes_tokens_traded = int(fpmmTrade["outcomeTokensTraded"])
435 | condition_id = fpmmTrade["fpmm"]["condition"]["id"]
436 |
437 | for position in user_positions:
438 | position_condition_ids = position["position"]["conditionIds"]
439 | balance = int(position["balance"])
440 |
441 | if condition_id in position_condition_ids and balance == outcomes_tokens_traded:
442 | return False
443 |
444 | for position in user_positions:
445 | position_condition_ids = position["position"]["conditionIds"]
446 | balance = int(position["balance"])
447 |
448 | if condition_id in position_condition_ids and balance == 0:
449 | return True
450 |
451 | return False
452 |
453 |
454 | def _compute_roi(initial_value: int, final_value: int) -> float:
455 | if initial_value != 0:
456 | roi = (final_value - initial_value) / initial_value
457 | else:
458 | roi = 0.0
459 |
460 | return roi
461 |
462 |
463 | def _compute_totals(
464 | table: Dict[Any, Dict[Any, Any]], mech_statistics: Dict[str, Any]
465 | ) -> None:
466 | for row in table.keys():
467 | total = sum(table[row][c] for c in table[row])
468 | table[row]["TOTAL"] = total
469 |
470 | # Total mech fees and calls need to be recomputed, because there could be mech calls
471 | # for markets that were not traded
472 | total_mech_calls = 0
473 | total_mech_fees = 0
474 |
475 | for _, v in mech_statistics.items():
476 | total_mech_calls += v["count"]
477 | total_mech_fees += v["fees"]
478 |
479 | table[MarketAttribute.MECH_CALLS]["TOTAL"] = total_mech_calls
480 | table[MarketAttribute.MECH_FEES]["TOTAL"] = total_mech_fees
481 |
482 | for col in STATS_TABLE_COLS:
483 | # Omen deducts the fee from collateral_amount (INVESTMENT) to compute outcomes_tokens_traded (EARNINGS).
484 | table[MarketAttribute.INVESTMENT][col] = (
485 | table[MarketAttribute.INVESTMENT][col] - table[MarketAttribute.FEES][col]
486 | )
487 | table[MarketAttribute.NET_EARNINGS][col] = (
488 | table[MarketAttribute.EARNINGS][col]
489 | - table[MarketAttribute.INVESTMENT][col]
490 | - table[MarketAttribute.FEES][col]
491 | - table[MarketAttribute.MECH_FEES][col]
492 | )
493 | # ROI is recomputed here for all columns, including TOTAL.
494 | table[MarketAttribute.ROI][col] = _compute_roi(
495 | table[MarketAttribute.INVESTMENT][col]
496 | + table[MarketAttribute.FEES][col]
497 | + table[MarketAttribute.MECH_FEES][col],
498 | table[MarketAttribute.EARNINGS][col],
499 | )
500 |
501 |
502 | def _get_market_state(market: Dict[str, Any]) -> MarketState:
503 | try:
504 | now = datetime.datetime.utcnow()
505 |
506 | market_state = MarketState.CLOSED
507 | if market[
508 | "currentAnswer"
509 | ] is None and now >= datetime.datetime.utcfromtimestamp(
510 | float(market.get("openingTimestamp", 0))
511 | ):
512 | market_state = MarketState.PENDING
513 | elif market["currentAnswer"] is None:
514 | market_state = MarketState.OPEN
515 | elif market["isPendingArbitration"]:
516 | market_state = MarketState.ARBITRATING
517 | elif now < datetime.datetime.utcfromtimestamp(
518 | float(market.get("answerFinalizedTimestamp", 0))
519 | ):
520 | market_state = MarketState.FINALIZING
521 |
522 | return market_state
523 | except Exception: # pylint: disable=broad-except
524 | return MarketState.UNKNOWN
525 |
526 |
527 | def _format_table(table: Dict[Any, Dict[Any, Any]]) -> str:
528 | column_width = 18
529 |
530 | table_str = " " * column_width
531 |
532 | for col in STATS_TABLE_COLS:
533 | table_str += f"{col:>{column_width}}"
534 |
535 | table_str += "\n"
536 | table_str += "-" * column_width * (len(STATS_TABLE_COLS) + 1) + "\n"
537 |
538 | table_str += (
539 | f"{MarketAttribute.NUM_TRADES:<{column_width}}"
540 | + "".join(
541 | [
542 | f"{table[MarketAttribute.NUM_TRADES][c]:>{column_width}}"
543 | for c in STATS_TABLE_COLS
544 | ]
545 | )
546 | + "\n"
547 | )
548 | table_str += (
549 | f"{MarketAttribute.NUM_VALID_TRADES:<{column_width}}"
550 | + "".join(
551 | [
552 | f"{table[MarketAttribute.NUM_VALID_TRADES][c]:>{column_width}}"
553 | for c in STATS_TABLE_COLS
554 | ]
555 | )
556 | + "\n"
557 | )
558 | table_str += (
559 | f"{MarketAttribute.WINNER_TRADES:<{column_width}}"
560 | + "".join(
561 | [
562 | f"{table[MarketAttribute.WINNER_TRADES][c]:>{column_width}}"
563 | for c in STATS_TABLE_COLS
564 | ]
565 | )
566 | + "\n"
567 | )
568 | table_str += (
569 | f"{MarketAttribute.NUM_REDEEMED:<{column_width}}"
570 | + "".join(
571 | [
572 | f"{table[MarketAttribute.NUM_REDEEMED][c]:>{column_width}}"
573 | for c in STATS_TABLE_COLS
574 | ]
575 | )
576 | + "\n"
577 | )
578 | table_str += (
579 | f"{MarketAttribute.NUM_INVALID_MARKET:<{column_width}}"
580 | + "".join(
581 | [
582 | f"{table[MarketAttribute.NUM_INVALID_MARKET][c]:>{column_width}}"
583 | for c in STATS_TABLE_COLS
584 | ]
585 | )
586 | + "\n"
587 | )
588 | table_str += (
589 | f"{MarketAttribute.MECH_CALLS:<{column_width}}"
590 | + "".join(
591 | [
592 | f"{table[MarketAttribute.MECH_CALLS][c]:>{column_width}}"
593 | for c in STATS_TABLE_COLS
594 | ]
595 | )
596 | + "\n"
597 | )
598 | table_str += (
599 | f"{MarketAttribute.INVESTMENT:<{column_width}}"
600 | + "".join(
601 | [
602 | f"{wei_to_xdai(table[MarketAttribute.INVESTMENT][c]):>{column_width}}"
603 | for c in STATS_TABLE_COLS
604 | ]
605 | )
606 | + "\n"
607 | )
608 | table_str += (
609 | f"{MarketAttribute.FEES:<{column_width}}"
610 | + "".join(
611 | [
612 | f"{wei_to_xdai(table[MarketAttribute.FEES][c]):>{column_width}}"
613 | for c in STATS_TABLE_COLS
614 | ]
615 | )
616 | + "\n"
617 | )
618 | table_str += (
619 | f"{MarketAttribute.MECH_FEES:<{column_width}}"
620 | + "".join(
621 | [
622 | f"{wei_to_xdai(table[MarketAttribute.MECH_FEES][c]):>{column_width}}"
623 | for c in STATS_TABLE_COLS
624 | ]
625 | )
626 | + "\n"
627 | )
628 | table_str += (
629 | f"{MarketAttribute.EARNINGS:<{column_width}}"
630 | + "".join(
631 | [
632 | f"{wei_to_xdai(table[MarketAttribute.EARNINGS][c]):>{column_width}}"
633 | for c in STATS_TABLE_COLS
634 | ]
635 | )
636 | + "\n"
637 | )
638 | table_str += (
639 | f"{MarketAttribute.NET_EARNINGS:<{column_width}}"
640 | + "".join(
641 | [
642 | f"{wei_to_xdai(table[MarketAttribute.NET_EARNINGS][c]):>{column_width}}"
643 | for c in STATS_TABLE_COLS
644 | ]
645 | )
646 | + "\n"
647 | )
648 | table_str += (
649 | f"{MarketAttribute.REDEMPTIONS:<{column_width}}"
650 | + "".join(
651 | [
652 | f"{wei_to_xdai(table[MarketAttribute.REDEMPTIONS][c]):>{column_width}}"
653 | for c in STATS_TABLE_COLS
654 | ]
655 | )
656 | + "\n"
657 | )
658 | table_str += (
659 | f"{MarketAttribute.ROI:<{column_width}}"
660 | + "".join(
661 | [
662 | f"{table[MarketAttribute.ROI][c]*100.0:>{column_width-5}.2f} % "
663 | for c in STATS_TABLE_COLS
664 | ]
665 | )
666 | + "\n"
667 | )
668 |
669 | return table_str
670 |
671 |
672 | def parse_user( # pylint: disable=too-many-locals,too-many-statements
673 | rpc: str,
674 | creator: str,
675 | creator_trades_json: Dict[str, Any],
676 | mech_statistics: Dict[str, Any],
677 | ) -> tuple[str, Dict[Any, Any]]:
678 | """Parse the trades from the response."""
679 |
680 | _mech_statistics = dict(mech_statistics)
681 | user_json = _query_conditional_tokens_gc_subgraph(creator)
682 |
683 | statistics_table = {
684 | row: {col: 0 for col in STATS_TABLE_COLS} for row in STATS_TABLE_ROWS
685 | }
686 |
687 | output = "------\n"
688 | output += "Trades\n"
689 | output += "------\n"
690 |
691 | for fpmmTrade in creator_trades_json["data"]["fpmmTrades"]:
692 | try:
693 | collateral_amount = int(fpmmTrade["collateralAmount"])
694 | outcome_index = int(fpmmTrade["outcomeIndex"])
695 | fee_amount = int(fpmmTrade["feeAmount"])
696 | outcomes_tokens_traded = int(fpmmTrade["outcomeTokensTraded"])
697 | creation_timestamp = float(fpmmTrade["creationTimestamp"])
698 |
699 | fpmm = fpmmTrade["fpmm"]
700 |
701 | output += f' Question: {fpmmTrade["title"]}\n'
702 | output += f' Market URL: https://aiomen.eth.limo/#/{fpmm["id"]}\n'
703 |
704 | creation_timestamp_utc = datetime.datetime.fromtimestamp(
705 | creation_timestamp, tz=datetime.timezone.utc
706 | )
707 | output += f' Trade date: {creation_timestamp_utc.strftime("%Y-%m-%d %H:%M:%S %Z")}\n'
708 |
709 | market_status = _get_market_state(fpmm)
710 |
711 | statistics_table[MarketAttribute.NUM_TRADES][market_status] += 1
712 | statistics_table[MarketAttribute.INVESTMENT][
713 | market_status
714 | ] += collateral_amount
715 | statistics_table[MarketAttribute.FEES][market_status] += fee_amount
716 | mech_data = _mech_statistics.pop(fpmmTrade["title"], {})
717 | statistics_table[MarketAttribute.MECH_CALLS][
718 | market_status
719 | ] += mech_data.get("count", 0)
720 | mech_fees = mech_data.get("fees", 0)
721 | statistics_table[MarketAttribute.MECH_FEES][market_status] += mech_fees
722 |
723 | output += f" Market status: {market_status}\n"
724 | output += f" Bought: {wei_to_xdai(collateral_amount)} for {wei_to_xdai(outcomes_tokens_traded)} {fpmm['outcomes'][outcome_index]!r} tokens\n"
725 | output += f" Fee: {wei_to_xdai(fee_amount)}\n"
726 | output += f" Your answer: {fpmm['outcomes'][outcome_index]!r}\n"
727 |
728 | if market_status == MarketState.FINALIZING:
729 | current_answer = int(fpmm["currentAnswer"], 16) # type: ignore
730 | is_invalid = current_answer == INVALID_ANSWER
731 |
732 | if is_invalid:
733 | earnings = collateral_amount
734 | output += "Current answer: Market has been declared invalid.\n"
735 | elif outcome_index == current_answer:
736 | earnings = outcomes_tokens_traded
737 | output += f"Current answer: {fpmm['outcomes'][current_answer]!r}\n"
738 | statistics_table[MarketAttribute.WINNER_TRADES][market_status] += 1
739 | else:
740 | earnings = 0
741 | output += f"Current answer: {fpmm['outcomes'][current_answer]!r}\n"
742 |
743 | statistics_table[MarketAttribute.EARNINGS][market_status] += earnings
744 |
745 | elif market_status == MarketState.CLOSED:
746 | current_answer = int(fpmm["currentAnswer"], 16) # type: ignore
747 | is_invalid = current_answer == INVALID_ANSWER
748 |
749 | if is_invalid:
750 | earnings = collateral_amount
751 | output += " Final answer: Market has been declared invalid.\n"
752 | output += f" Earnings: {wei_to_xdai(earnings)}\n"
753 | redeemed = _is_redeemed(user_json, fpmmTrade)
754 | if redeemed:
755 | statistics_table[MarketAttribute.NUM_INVALID_MARKET][
756 | market_status
757 | ] += 1
758 | statistics_table[MarketAttribute.REDEMPTIONS][
759 | market_status
760 | ] += earnings
761 |
762 | elif outcome_index == current_answer:
763 | earnings = outcomes_tokens_traded
764 | output += f" Final answer: {fpmm['outcomes'][current_answer]!r} - Congrats! The trade was for the winner answer.\n"
765 | output += f" Earnings: {wei_to_xdai(earnings)}\n"
766 | redeemed = _is_redeemed(user_json, fpmmTrade)
767 | output += f" Redeemed: {redeemed}\n"
768 | statistics_table[MarketAttribute.WINNER_TRADES][market_status] += 1
769 |
770 | if redeemed:
771 | statistics_table[MarketAttribute.NUM_REDEEMED][
772 | market_status
773 | ] += 1
774 | statistics_table[MarketAttribute.REDEMPTIONS][
775 | market_status
776 | ] += earnings
777 | else:
778 | earnings = 0
779 | output += f" Final answer: {fpmm['outcomes'][current_answer]!r} - The trade was for the loser answer.\n"
780 |
781 |
782 | statistics_table[MarketAttribute.EARNINGS][
783 | market_status
784 | ] += earnings
785 |
786 | statistics_table[MarketAttribute.NUM_VALID_TRADES][
787 | market_status
788 | ] = statistics_table[MarketAttribute.NUM_TRADES][
789 | market_status
790 | ] - statistics_table[MarketAttribute.NUM_INVALID_MARKET][
791 | market_status
792 | ]
793 |
794 | if 0 < earnings < DUST_THRESHOLD:
795 | output += "Earnings are dust.\n"
796 |
797 | output += "\n"
798 | except TypeError:
799 | output += "ERROR RETRIEVING TRADE INFORMATION.\n\n"
800 |
801 | output += "\n"
802 | output += "--------------------------\n"
803 | output += "Summary (per market state)\n"
804 | output += "--------------------------\n"
805 | output += "\n"
806 |
807 | # Read rpc and get safe address balance
808 | safe_address_balance = get_balance(creator, rpc)
809 |
810 | output += f"Safe address: {creator}\n"
811 | output += f"Address balance: {wei_to_xdai(safe_address_balance)}\n"
812 |
813 | wxdai_balance = get_token_balance(creator, WXDAI_CONTRACT_ADDRESS, rpc)
814 | output += f"Token balance: {wei_to_wxdai(wxdai_balance)}\n\n"
815 |
816 | _compute_totals(statistics_table, mech_statistics)
817 | output += _format_table(statistics_table)
818 |
819 | return output, statistics_table
820 |
821 |
822 | def get_mech_statistics(mech_requests: Dict[str, Any]) -> Dict[str, Dict[str, int]]:
823 | """Outputs a table with Mech statistics"""
824 |
825 | mech_statistics: Dict[str, Dict[str, int]] = defaultdict(lambda: defaultdict(int))
826 |
827 | for mech_request in mech_requests.values():
828 | if (
829 | "ipfs_contents" not in mech_request
830 | or "tool" not in mech_request["ipfs_contents"]
831 | or "prompt" not in mech_request["ipfs_contents"]
832 | ):
833 | continue
834 |
835 | if mech_request["ipfs_contents"]["tool"] in IRRELEVANT_TOOLS:
836 | continue
837 |
838 | prompt = mech_request["ipfs_contents"]["prompt"]
839 | prompt = prompt.replace("\n", " ")
840 | prompt = prompt.strip()
841 | prompt = re.sub(r"\s+", " ", prompt)
842 | prompt_match = re.search(r"\"(.*)\"", prompt)
843 | if prompt_match:
844 | question = prompt_match.group(1)
845 | else:
846 | question = prompt
847 |
848 | mech_statistics[question]["count"] += 1
849 | mech_statistics[question]["fees"] += mech_request["fee"]
850 |
851 | return mech_statistics
852 |
853 |
854 | if __name__ == "__main__":
855 | user_args = _parse_args()
856 |
857 | with open(RPC_PATH, "r", encoding="utf-8") as rpc_file:
858 | rpc = rpc_file.read()
859 |
860 | mech_requests = get_mech_requests(
861 | user_args.creator,
862 | user_args.from_date.timestamp(),
863 | user_args.to_date.timestamp(),
864 | )
865 | mech_statistics = get_mech_statistics(mech_requests)
866 |
867 | trades_json = _query_omen_xdai_subgraph(
868 | user_args.creator,
869 | user_args.from_date.timestamp(),
870 | user_args.to_date.timestamp(),
871 | user_args.fpmm_created_from_date.timestamp(),
872 | user_args.fpmm_created_to_date.timestamp(),
873 | )
874 | parsed_output, _ = parse_user(rpc, user_args.creator, trades_json, mech_statistics)
875 | print(parsed_output)
876 |
--------------------------------------------------------------------------------